dragonflight/services/web-ui/public/jobs.html

857 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Jobs — Wild Dragon MAM</title>
<link rel="stylesheet" href="css/common.css">
<style>
/* ── page layout ─────────────────────────────────────────── */
.page-body {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── filter bar ──────────────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-panel);
}
.filter-bar .tabs {
display: flex;
gap: 2px;
background: var(--bg-base);
padding: 3px;
border-radius: var(--r-md);
border: 1px solid var(--border);
}
.filter-bar .tab-btn {
padding: 5px 14px;
border-radius: calc(var(--r-md) - 1px);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.filter-bar .tab-btn:hover {
color: var(--text-primary);
background: var(--bg-surface);
}
.filter-bar .tab-btn.active {
background: var(--bg-surface);
color: var(--text-primary);
}
.filter-bar .tab-btn .count {
font-size: var(--text-xs);
background: var(--bg-raised);
color: var(--text-secondary);
padding: 1px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.filter-bar .tab-btn.active .count {
background: var(--accent-subtle);
color: var(--accent);
}
.filter-bar .spacer { flex: 1; }
.filter-bar .type-select {
padding: 5px 10px;
font-size: var(--text-sm);
min-width: 140px;
}
.refresh-indicator {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.refresh-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--status-gray);
transition: background 0.2s;
}
.refresh-dot.live {
background: var(--status-green);
animation: pulse-green 2s infinite;
}
@keyframes pulse-green {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── jobs area ───────────────────────────────────────────── */
.jobs-area {
flex: 1;
overflow-y: auto;
padding: var(--sp-6);
}
/* ── job table ───────────────────────────────────────────── */
.jobs-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.jobs-table thead tr {
border-bottom: 1px solid var(--border);
}
.jobs-table th {
padding: var(--sp-2) var(--sp-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.jobs-table th:first-child { padding-left: 0; }
.jobs-table th:last-child { padding-right: 0; text-align: right; }
.jobs-table tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.jobs-table tbody tr:hover {
background: var(--bg-surface);
}
.jobs-table td {
padding: var(--sp-3) var(--sp-4);
color: var(--text-primary);
vertical-align: middle;
}
.jobs-table td:first-child { padding-left: 0; }
.jobs-table td:last-child { padding-right: 0; text-align: right; }
/* ── type chip ───────────────────────────────────────────── */
.type-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--text-xs);
font-weight: 500;
padding: 2px 8px;
border-radius: var(--r-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.type-chip.transcode { background: oklch(65% 0.16 245 / 0.12); color: var(--status-blue); }
.type-chip.proxy { background: oklch(60% 0.14 290 / 0.12); color: oklch(70% 0.14 290); }
.type-chip.thumbnail { background: oklch(68% 0.18 148 / 0.10); color: var(--status-green); }
.type-chip.conform { background: var(--accent-subtle); color: var(--accent); }
.type-chip.ingest { background: oklch(62% 0.22 25 / 0.10); color: oklch(72% 0.18 40); }
/* ── progress in table ───────────────────────────────────── */
.progress-cell {
min-width: 140px;
}
.inline-progress {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.inline-progress .bar-track {
flex: 1;
height: 4px;
background: var(--bg-raised);
border-radius: 2px;
overflow: hidden;
}
.inline-progress .bar-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.4s ease;
}
.inline-progress .bar-fill.complete {
background: var(--status-green);
}
.inline-progress .bar-fill.failed {
background: var(--status-red);
}
.inline-progress .pct {
font-size: var(--text-xs);
color: var(--text-secondary);
min-width: 28px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── asset link ──────────────────────────────────────────── */
.asset-link {
font-family: 'Inter', monospace;
font-size: var(--text-xs);
color: var(--text-secondary);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.asset-name {
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: 500;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* ── duration / time ─────────────────────────────────────── */
.time-cell {
font-size: var(--text-xs);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.time-cell .rel {
color: var(--text-tertiary);
font-size: 11px;
margin-top: 1px;
}
/* ── job detail panel ────────────────────────────────────── */
.job-detail-panel .panel-section {
margin-bottom: var(--sp-6);
}
.job-detail-panel .detail-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--sp-2);
}
.job-detail-panel .detail-value {
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
.log-block {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--sp-4);
font-family: 'Courier New', monospace;
font-size: 11px;
color: var(--text-secondary);
max-height: 280px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
.progress-large {
margin: var(--sp-4) 0;
}
.progress-large .bar-track {
height: 8px;
background: var(--bg-raised);
border-radius: 4px;
overflow: hidden;
}
.progress-large .bar-fill {
height: 100%;
border-radius: 4px;
background: var(--accent);
transition: width 0.4s ease;
}
.progress-large .bar-fill.complete { background: var(--status-green); }
.progress-large .bar-fill.failed { background: var(--status-red); }
.progress-large .pct-label {
font-size: var(--text-xl);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
margin-bottom: var(--sp-2);
}
/* ── stats strip ─────────────────────────────────────────── */
.stats-strip {
display: flex;
gap: var(--sp-6);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border);
background: var(--bg-base);
flex-shrink: 0;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-item .stat-val {
font-size: var(--text-lg);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.stat-item .stat-val.amber { color: var(--accent); }
.stat-item .stat-val.green { color: var(--status-green); }
.stat-item .stat-val.red { color: var(--status-red); }
.stat-item .stat-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<div class="shell">
<!-- ── Sidebar ─────────────────────────────────────────── -->
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="home.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>Home</a><a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="projects.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>Projects</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
<a href="tokens.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5.5 9.5h3a1.5 1.5 0 0 0 0-3h-1a1.5 1.5 0 0 1 0-3h3M8 3v1m0 8v1"/></svg>Tokens</a>
<a href="edit.html" class="nav-item" target="_blank" rel="noopener">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
Editor
</a>
</nav>
</nav>
<!-- ── Main area ────────────────────────────────────────── -->
<div class="main">
<!-- Topbar -->
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Jobs</span>
</div>
<div class="topbar-right">
<button class="btn btn-ghost btn-sm" id="btn-clear-done" onclick="clearCompleted()">
Clear completed
</button>
</div>
</header>
<!-- Page body -->
<div class="page-body">
<!-- Stats strip -->
<div class="stats-strip" id="stats-strip">
<div class="stat-item">
<div class="stat-val" id="stat-total"></div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<div class="stat-val amber" id="stat-active"></div>
<div class="stat-label">Active</div>
</div>
<div class="stat-item">
<div class="stat-val green" id="stat-done"></div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-item">
<div class="stat-val red" id="stat-failed"></div>
<div class="stat-label">Failed</div>
</div>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<div class="tabs">
<button class="tab-btn active" data-filter="all" onclick="setFilter('all', this)">
All <span class="count" id="cnt-all">0</span>
</button>
<button class="tab-btn" data-filter="active" onclick="setFilter('active', this)">
Active <span class="count" id="cnt-active">0</span>
</button>
<button class="tab-btn" data-filter="completed" onclick="setFilter('completed', this)">
Completed <span class="count" id="cnt-completed">0</span>
</button>
<button class="tab-btn" data-filter="failed" onclick="setFilter('failed', this)">
Failed <span class="count" id="cnt-failed">0</span>
</button>
</div>
<div class="spacer"></div>
<select class="form-select type-select" id="type-filter" onchange="loadJobs()">
<option value="">All types</option>
<option value="transcode">Transcode</option>
<option value="proxy">Proxy</option>
<option value="thumbnail">Thumbnail</option>
<option value="conform">Conform</option>
<option value="ingest">Ingest</option>
</select>
<div class="refresh-indicator">
<div class="refresh-dot" id="refresh-dot"></div>
<span id="refresh-label">Live</span>
</div>
</div>
<!-- Jobs area -->
<div class="jobs-area" id="jobs-area">
<!-- populated by JS -->
</div>
</div><!-- /page-body -->
</div><!-- /main -->
</div><!-- /shell -->
<!-- ── Job detail slide panel ─────────────────────────────── -->
<div class="slide-overlay" id="detail-overlay" onclick="closeDetail()"></div>
<div class="slide-panel" id="detail-panel" role="dialog" aria-label="Job detail">
<div class="slide-panel-header">
<span class="slide-panel-title" id="detail-title">Job Detail</span>
<button class="btn btn-ghost btn-sm" onclick="closeDetail()" aria-label="Close" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="slide-panel-body job-detail-panel" id="detail-body">
<!-- populated by openDetail() -->
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<script>
/* ────────────────────────────────────────────────────────
Config & state
──────────────────────────────────────────────────────── */
const API = '/api/v1';
let allJobs = [];
let currentFilter = 'all';
let refreshTimer = null;
let activeCount = 0;
/* ────────────────────────────────────────────────────────
API helpers
──────────────────────────────────────────────────────── */
async function api(path, opts = {}) {
const r = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts
});
if (!r.ok) {
const msg = await r.text().catch(() => r.statusText);
throw new Error(msg || r.statusText);
}
return r.json();
}
/* ────────────────────────────────────────────────────────
Load jobs
──────────────────────────────────────────────────────── */
async function loadJobs() {
const type = document.getElementById('type-filter').value;
try {
let url = '/jobs';
const params = [];
if (type) params.push(`type=${encodeURIComponent(type)}`);
if (params.length) url += '?' + params.join('&');
const data = await api(url);
allJobs = Array.isArray(data) ? data : (data.jobs || []);
updateStats();
updateCounts();
renderJobs();
scheduleRefresh();
} catch (e) {
showError('Failed to load jobs: ' + e.message);
}
}
function updateStats() {
const total = allJobs.length;
const active = allJobs.filter(j => j.status === 'active' || j.status === 'waiting').length;
const done = allJobs.filter(j => j.status === 'completed').length;
const failed = allJobs.filter(j => j.status === 'failed').length;
activeCount = active;
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-active').textContent = active;
document.getElementById('stat-done').textContent = done;
document.getElementById('stat-failed').textContent = failed;
}
function updateCounts() {
const counts = {
all: allJobs.length,
active: allJobs.filter(j => j.status === 'active' || j.status === 'waiting').length,
completed: allJobs.filter(j => j.status === 'completed').length,
failed: allJobs.filter(j => j.status === 'failed').length
};
for (const [k, v] of Object.entries(counts)) {
const el = document.getElementById('cnt-' + k);
if (el) el.textContent = v;
}
}
/* ────────────────────────────────────────────────────────
Render
──────────────────────────────────────────────────────── */
function getFilteredJobs() {
if (currentFilter === 'all') return allJobs;
if (currentFilter === 'active') return allJobs.filter(j => j.status === 'active' || j.status === 'waiting');
if (currentFilter === 'completed') return allJobs.filter(j => j.status === 'completed');
if (currentFilter === 'failed') return allJobs.filter(j => j.status === 'failed');
return allJobs;
}
function renderJobs() {
const area = document.getElementById('jobs-area');
const jobs = getFilteredJobs();
if (jobs.length === 0) {
const labels = {
all: 'No jobs yet', active: 'No active jobs',
completed: 'No completed jobs', failed: 'No failed jobs'
};
area.innerHTML = `
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect x="4" y="6" width="24" height="20" rx="2" stroke="var(--border-strong)" stroke-width="1.5"/>
<path d="M9 12h14M9 17h10M9 22h6" stroke="var(--border-strong)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<div class="empty-title">${labels[currentFilter] || 'No jobs'}</div>
<div class="empty-desc">Jobs appear here when assets are processed.</div>
</div>`;
return;
}
area.innerHTML = `
<table class="jobs-table">
<thead>
<tr>
<th>Type</th>
<th>Asset</th>
<th>Status</th>
<th class="progress-cell">Progress</th>
<th>Started</th>
<th>Duration</th>
<th></th>
</tr>
</thead>
<tbody id="jobs-tbody">
</tbody>
</table>`;
const tbody = document.getElementById('jobs-tbody');
for (const job of jobs) {
tbody.appendChild(renderRow(job));
}
}
function renderRow(job) {
const tr = document.createElement('tr');
tr.dataset.jobId = job.id;
const pct = typeof job.progress === 'number' ? job.progress : 0;
const isActive = job.status === 'active' || job.status === 'waiting';
const barClass = job.status === 'completed' ? 'complete' : job.status === 'failed' ? 'failed' : '';
const assetName = job.asset_name || (job.asset_id ? job.asset_id.slice(0, 16) + '…' : '—');
const assetId = job.asset_id ? job.asset_id.slice(0, 8) : '';
const started = job.created_at ? new Date(job.created_at) : null;
const startedStr = started ? started.toLocaleTimeString('en-US', { hour12: false }) : '—';
const relStr = started ? timeAgo(started) : '';
const dur = formatDuration(job.started_at, job.completed_at || job.failed_at);
tr.innerHTML = `
<td><span class="type-chip ${escHtml(job.type || 'conform')}">${escHtml((job.type || 'conform').toUpperCase())}</span></td>
<td>
<span class="asset-name" title="${escHtml(assetName)}">${escHtml(assetName)}</span>
${assetId ? `<span class="asset-link">${escHtml(assetId)}</span>` : ''}
</td>
<td>${statusBadge(job.status)}</td>
<td class="progress-cell">
<div class="inline-progress">
<div class="bar-track"><div class="bar-fill ${barClass}" style="width:${pct}%"></div></div>
<span class="pct">${isActive ? pct + '%' : (job.status === 'completed' ? '100%' : '—')}</span>
</div>
</td>
<td class="time-cell">
<div>${startedStr}</div>
<div class="rel">${relStr}</div>
</td>
<td class="time-cell">${dur}</td>
<td>
<button class="btn btn-ghost" style="font-size:var(--text-xs);padding:4px 10px" onclick="openDetail('${escHtml(job.id)}')">Details</button>
<button class="btn btn-ghost" style="font-size:var(--text-xs);padding:4px 10px;color:var(--signal-bad)" onclick="killJob('${escHtml(job.id)}', event)" title="Remove this job from the queue">Kill</button>
</td>`;
return tr;
}
async function killJob(jobId, ev) {
ev.stopPropagation();
if (!confirm('Remove this job from the queue? If a worker is still processing it, the run is abandoned.')) return;
try {
const r = await fetch('/api/v1/jobs/' + encodeURIComponent(jobId), { method: 'DELETE', credentials: 'include' });
if (r.ok) { toast('Job removed', 'success'); fetchJobs(); }
else { const d = await r.json().catch(()=>({})); toast('Remove failed: ' + (d.error || r.statusText), 'error'); }
} catch (err) {
toast('Remove failed: ' + err.message, 'error');
}
}
function statusBadge(status) {
const map = {
active: '<span class="badge badge-recording">Active</span>',
waiting: '<span class="badge badge-idle">Waiting</span>',
completed: '<span class="badge badge-ready">Done</span>',
failed: '<span class="badge badge-error">Failed</span>',
delayed: '<span class="badge badge-processing">Delayed</span>'
};
return map[status] || `<span class="badge badge-idle">${escHtml(status || 'Unknown')}</span>`;
}
/* ────────────────────────────────────────────────────────
Filter tabs
──────────────────────────────────────────────────────── */
function setFilter(filter, btn) {
currentFilter = filter;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderJobs();
}
/* ────────────────────────────────────────────────────────
Auto-refresh
──────────────────────────────────────────────────────── */
function scheduleRefresh() {
clearTimeout(refreshTimer);
const dot = document.getElementById('refresh-dot');
const label = document.getElementById('refresh-label');
if (activeCount > 0) {
dot.classList.add('live');
label.textContent = 'Live';
refreshTimer = setTimeout(loadJobs, 5000);
} else {
dot.classList.remove('live');
label.textContent = 'Idle';
}
}
/* ────────────────────────────────────────────────────────
Detail panel
──────────────────────────────────────────────────────── */
function openDetail(jobId) {
const job = allJobs.find(j => String(j.id) === String(jobId));
if (!job) return;
const pct = typeof job.progress === 'number' ? job.progress : 0;
const barClass = job.status === 'completed' ? 'complete' : job.status === 'failed' ? 'failed' : '';
const dur = formatDuration(job.started_at, job.completed_at || job.failed_at);
document.getElementById('detail-title').textContent = (job.type || 'Job').toUpperCase() + ' — ' + (job.asset_name || job.id || '');
document.getElementById('detail-body').innerHTML = `
<div class="panel-section">
<div class="detail-label">Status</div>
<div class="detail-value">${statusBadge(job.status)}</div>
</div>
<div class="panel-section">
<div class="detail-label">Progress</div>
<div class="progress-large">
<div class="pct-label">${job.status === 'completed' ? '100' : pct}%</div>
<div class="bar-track">
<div class="bar-fill ${barClass}" style="width:${job.status === 'completed' ? 100 : pct}%"></div>
</div>
</div>
</div>
<div class="panel-section">
<div class="detail-label">Asset ID</div>
<div class="detail-value" style="font-family:monospace;font-size:var(--text-xs)">${escHtml(job.asset_id || '—')}</div>
</div>
<div class="panel-section" style="display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)">
<div>
<div class="detail-label">Created</div>
<div class="detail-value">${job.created_at ? new Date(job.created_at).toLocaleString() : '—'}</div>
</div>
<div>
<div class="detail-label">Duration</div>
<div class="detail-value">${dur}</div>
</div>
</div>
${job.error ? `
<div class="panel-section">
<div class="detail-label">Error</div>
<div class="log-block" style="color:var(--status-red)">${escHtml(job.error)}</div>
</div>` : ''}
${job.logs ? `
<div class="panel-section">
<div class="detail-label">Logs</div>
<div class="log-block">${escHtml(job.logs)}</div>
</div>` : ''}
<div class="panel-section">
<div class="detail-label">Raw data</div>
<div class="log-block">${escHtml(JSON.stringify(job, null, 2))}</div>
</div>`;
document.getElementById('detail-panel').classList.add('open');
document.getElementById('detail-overlay').classList.add('open');
}
function closeDetail() {
document.getElementById('detail-panel').classList.remove('open');
document.getElementById('detail-overlay').classList.remove('open');
}
/* ── formatFileSize + formatDuration from common utils ── */
/* ────────────────────────────────────────────────────────
Clear completed
──────────────────────────────────────────────────────── */
async function clearCompleted() {
const completed = allJobs.filter(j => j.status === 'completed');
if (completed.length === 0) { toast('No completed jobs to clear.'); return; }
try {
await Promise.all(completed.map(j => api(`/jobs/${j.id}`, { method: 'DELETE' }).catch(() => {})));
toast(`Cleared ${completed.length} completed job${completed.length === 1 ? '' : 's'}.`);
loadJobs();
} catch (e) {
showError('Failed to clear jobs: ' + e.message);
}
}
/* ────────────────────────────────────────────────────────
Utilities
──────────────────────────────────────────────────────── */
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function timeAgo(date) {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s/60)}m ago`;
if (s < 86400)return `${Math.floor(s/3600)}h ago`;
return `${Math.floor(s/86400)}d ago`;
}
function formatDuration(start, end) {
if (!start) return '—';
const s = new Date(start);
const e = end ? new Date(end) : new Date();
const ms = e - s;
if (isNaN(ms) || ms < 0) return '—';
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec/60)}m ${sec % 60}s`;
return `${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
function toast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `toast ${type === 'error' ? 'toast-error' : ''}`;
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 3500);
}
function showError(msg) { toast(msg, 'error'); }
/* ────────────────────────────────────────────────────────
Init
──────────────────────────────────────────────────────── */
loadJobs();
</script>
<script src="js/topbar-strip.js?v=1"></script>
</body>
</html>