2026-05-16 17:02:39 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-05-18 10:13:08 -04:00
< link rel = "icon" type = "image/x-icon" href = "favicon.ico" >
2026-05-21 22:42:56 -04:00
< title > Jobs — Dragonflight< / title >
2026-05-21 23:12:58 -04:00
< link rel = "stylesheet" href = "/dist/app.css" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< div class = "wd-shell" style = "display:flex;min-height:100vh;" >
2026-05-16 17:02:39 -04:00
<!-- ── Sidebar ─────────────────────────────────────────── -->
2026-05-21 23:12:58 -04:00
< nav class = "wd-sidebar" aria-label = "Main navigation" >
< div class = "wd-sidebar-header" >
< img src = "img/dragon-logo.png?v=1" alt = "Dragonflight" style = "width:18px;height:18px;" >
< span class = "wd-sidebar-brand" > Dragonflight< / span >
2026-05-16 17:02:39 -04:00
< / div >
2026-05-21 23:12:58 -04:00
< div class = "wd-sidebar-nav" >
< a href = "home.html" class = "wd-nav-item" >
2026-05-18 22:56:51 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< a href = "index.html" class = "wd-nav-item" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< a href = "projects.html" class = "wd-nav-item" >
2026-05-18 22:56:51 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< a href = "upload.html" class = "wd-nav-item" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< a href = "recorders.html" class = "wd-nav-item" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< a href = "capture.html" class = "wd-nav-item" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< a href = "jobs.html" class = "wd-nav-item is-active" >
2026-05-16 17:02:39 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 4h12M2 8h8M2 12h5" / > < / svg >
Jobs
< / a >
2026-05-21 23:12:58 -04:00
< a href = "editor.html" class = "wd-nav-item" >
2026-05-17 21:44:15 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< div class = "wd-sidebar-section" > Admin< / div >
< a href = "users.html" class = "wd-nav-item" >
2026-05-18 22:56:51 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "6" cy = "5" r = "2.5" / > < path d = "M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5" / > < circle cx = "12" cy = "5" r = "2" / > < path d = "M15 12c0-1.9-1.3-3.5-3-4" / > < / svg >
Users
< / a >
2026-05-21 23:12:58 -04:00
< a href = "tokens.html" class = "wd-nav-item" >
2026-05-18 22:56:51 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "6" cy = "10" r = "3.5" / > < path d = "M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1" / > < / svg >
Tokens
< / a >
2026-05-21 23:12:58 -04:00
< a href = "containers.html" class = "wd-nav-item" >
2026-05-20 00:22:57 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "5" width = "14" height = "4" rx = "1" / > < rect x = "1" y = "10" width = "14" height = "4" rx = "1" / > < path d = "M4 7h1M4 12h1" / > < / svg >
Containers
< / a >
2026-05-21 23:12:58 -04:00
< a href = "cluster.html" class = "wd-nav-item" >
2026-05-20 00:22:57 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "2" / > < circle cx = "2" cy = "3" r = "1.5" / > < circle cx = "14" cy = "3" r = "1.5" / > < circle cx = "2" cy = "13" r = "1.5" / > < circle cx = "14" cy = "13" r = "1.5" / > < path d = "M3.1 4.1L6.5 6.5M12.9 4.1L9.5 6.5M3.1 11.9L6.5 9.5M12.9 11.9L9.5 9.5" / > < / svg >
Cluster
< / a >
2026-05-21 23:12:58 -04:00
< a href = "settings.html" class = "wd-nav-item" >
2026-05-20 14:50:02 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "2.5" / > < path d = "M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1" / > < / svg >
Settings
< / a >
2026-05-21 23:12:58 -04:00
< / div >
< div class = "wd-sidebar-footer" >
< div class = "wd-sidebar-user" >
< div class = "wd-sidebar-user-avatar" id = "userAvatar" > ?< / div >
< div class = "wd-sidebar-user-info" >
< div class = "wd-sidebar-user-name" id = "userName" > —< / div >
< div class = "wd-sidebar-user-role" id = "userRole" > < / div >
2026-05-18 22:56:51 -04:00
< / div >
2026-05-21 23:12:58 -04:00
< button class = "wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id = "logoutBtn" title = "Sign out" >
2026-05-18 22:56:51 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" width = "14" height = "14" > < path d = "M10 8H3M6 5l-3 3 3 3" / > < path d = "M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7" / > < / svg >
< / button >
< / div >
< / div >
2026-05-16 17:02:39 -04:00
< / nav >
<!-- ── Main area ────────────────────────────────────────── -->
2026-05-21 23:12:58 -04:00
< div style = "flex:1;display:flex;flex-direction:column;" >
2026-05-16 17:02:39 -04:00
<!-- Topbar -->
2026-05-21 23:12:58 -04:00
< header class = "wd-topbar" >
< div class = "wd-topbar-left" >
2026-05-16 17:02:39 -04:00
< span class = "page-title" > Jobs< / span >
< / div >
2026-05-21 23:12:58 -04:00
< div class = "wd-topbar-right" >
< button class = "wd-btn wd-btn--ghost wd-btn--sm" id = "btn-clear-done" onclick = "clearCompleted()" >
2026-05-16 17:02:39 -04:00
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 >
2026-05-21 23:12:58 -04:00
< select class = "wd-select type-select" id = "type-filter" onchange = "renderJobs()" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-19 23:17:18 -04:00
< span id = "refresh-label" > Connecting…< / span >
2026-05-16 17:02:39 -04:00
< / 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 ─────────────────────────────── -->
2026-05-21 23:12:58 -04:00
< div class = "wd-slide-overlay" id = "detail-overlay" onclick = "closeDetail()" > < / div >
< div class = "wd-slide-panel" id = "detail-panel" role = "dialog" aria-label = "Job detail" >
< div class = "wd-slide-panel-header" >
< span class = "wd-slide-panel-title" id = "detail-title" > Job Detail< / span >
< button class = "wd-btn wd-btn--ghost wd-btn--sm" onclick = "closeDetail()" aria-label = "Close" style = "padding:0;width:28px;height:28px;" >
2026-05-16 17:02:39 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M3 3l10 10M13 3L3 13" / > < / svg >
< / button >
< / div >
2026-05-21 23:12:58 -04:00
< div class = "wd-slide-panel-body job-detail-panel" id = "detail-body" >
2026-05-16 17:02:39 -04:00
<!-- populated by openDetail() -->
< / div >
< / div >
2026-05-21 23:12:58 -04:00
< div id = "toast-container" class = "wd-toast-container" > < / div >
2026-05-16 17:02:39 -04:00
< script >
/* ────────────────────────────────────────────────────────
Config & state
──────────────────────────────────────────────────────── */
const API = '/api/v1';
2026-05-19 23:17:18 -04:00
let allJobs = [];
2026-05-16 17:02:39 -04:00
let currentFilter = 'all';
2026-05-19 23:17:18 -04:00
let sseSource = null;
2026-05-16 17:02:39 -04:00
let activeCount = 0;
/* ────────────────────────────────────────────────────────
API helpers
──────────────────────────────────────────────────────── */
async function api(path, opts = {}) {
const r = await fetch(API + path, {
2026-05-18 23:48:56 -04:00
credentials: 'include',
2026-05-16 17:02:39 -04:00
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();
}
/* ────────────────────────────────────────────────────────
2026-05-19 23:17:18 -04:00
SSE live feed
2026-05-16 17:02:39 -04:00
──────────────────────────────────────────────────────── */
2026-05-19 23:17:18 -04:00
function startSSE() {
const dot = document.getElementById('refresh-dot');
const label = document.getElementById('refresh-label');
if (sseSource) { sseSource.close(); sseSource = null; }
sseSource = new EventSource('/api/v1/jobs/events');
sseSource.addEventListener('open', () => {
dot.classList.add('live');
label.textContent = 'Live';
});
sseSource.addEventListener('message', (ev) => {
try {
const payload = JSON.parse(ev.data);
if (payload.type !== 'jobs') return;
allJobs = payload.jobs;
updateStats();
updateCounts();
renderJobs();
} catch (_) {}
});
sseSource.addEventListener('error', () => {
dot.classList.remove('live');
label.textContent = 'Reconnecting…';
});
2026-05-16 17:02:39 -04:00
}
2026-05-19 23:17:18 -04:00
/* ────────────────────────────────────────────────────────
Stats + counts
──────────────────────────────────────────────────────── */
2026-05-16 17:02:39 -04:00
function updateStats() {
2026-05-19 23:17:18 -04:00
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;
2026-05-16 17:02:39 -04:00
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() {
2026-05-19 23:17:18 -04:00
const typeFilter = document.getElementById('type-filter').value;
const base = typeFilter ? allJobs.filter(j => j.type === typeFilter) : allJobs;
2026-05-16 17:02:39 -04:00
const counts = {
2026-05-19 23:17:18 -04:00
all: base.length,
active: base.filter(j => j.status === 'active' || j.status === 'waiting').length,
completed: base.filter(j => j.status === 'completed').length,
failed: base.filter(j => j.status === 'failed').length
2026-05-16 17:02:39 -04:00
};
for (const [k, v] of Object.entries(counts)) {
const el = document.getElementById('cnt-' + k);
if (el) el.textContent = v;
}
}
/* ────────────────────────────────────────────────────────
Render
──────────────────────────────────────────────────────── */
function getFilteredJobs() {
2026-05-19 23:17:18 -04:00
const typeFilter = document.getElementById('type-filter').value;
let jobs = typeFilter ? allJobs.filter(j => j.type === typeFilter) : allJobs;
if (currentFilter === 'active') return jobs.filter(j => j.status === 'active' || j.status === 'waiting');
if (currentFilter === 'completed') return jobs.filter(j => j.status === 'completed');
if (currentFilter === 'failed') return jobs.filter(j => j.status === 'failed');
return jobs;
2026-05-16 17:02:39 -04:00
}
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 = `
2026-05-21 23:12:58 -04:00
< div class = "wd-empty" >
< div class = "wd-empty-icon" >
2026-05-16 17:02:39 -04:00
< 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 >
2026-05-21 23:12:58 -04:00
< div class = "wd-empty-title" > ${labels[currentFilter] || 'No jobs'}< / div >
< div class = "wd-empty-body" > Jobs appear here when assets are processed.< / div >
2026-05-16 17:02:39 -04:00
< / 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);
2026-05-19 00:54:47 -04:00
const retryBtn = (job.status === 'failed' & & job.asset_id)
2026-05-21 23:12:58 -04:00
? `< button class = "wd-btn wd-btn--ghost" style = "font-size:var(--text-xs);padding:4px 10px;color:var(--status-green)" onclick = "retryJob('${escHtml(job.asset_id)}', event)" title = "Re-queue asset processing" > Retry< / button > `
2026-05-19 00:54:47 -04:00
: '';
2026-05-16 17:02:39 -04:00
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 >
2026-05-21 23:12:58 -04:00
< button class = "wd-btn wd-btn--ghost" style = "font-size:var(--text-xs);padding:4px 10px" onclick = "openDetail('${escHtml(job.id)}')" > Details< / button >
2026-05-19 00:54:47 -04:00
${retryBtn}
2026-05-21 23:12:58 -04:00
< button class = "wd-btn wd-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 >
2026-05-16 17:02:39 -04:00
< / td > `;
return tr;
}
2026-05-17 19:10:08 -04:00
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' });
2026-05-19 23:17:18 -04:00
if (r.ok) { toast('Job removed', 'success'); }
2026-05-17 19:10:08 -04:00
else { const d = await r.json().catch(()=>({})); toast('Remove failed: ' + (d.error || r.statusText), 'error'); }
} catch (err) {
toast('Remove failed: ' + err.message, 'error');
}
}
2026-05-19 00:54:47 -04:00
async function retryJob(assetId, ev) {
ev.stopPropagation();
try {
await api('/assets/' + encodeURIComponent(assetId) + '/retry', { method: 'POST' });
toast('Job re-queued — processing will restart shortly.');
} catch (e) {
showError('Retry failed: ' + e.message);
}
}
2026-05-16 17:02:39 -04:00
function statusBadge(status) {
const map = {
2026-05-21 23:12:58 -04:00
active: '< span class = "wd-badge wd-badge--bad" > Active< / span > ',
waiting: '< span class = "wd-badge wd-badge--idle" > Waiting< / span > ',
completed: '< span class = "wd-badge wd-badge--good" > Done< / span > ',
failed: '< span class = "wd-badge wd-badge--bad" > Failed< / span > ',
delayed: '< span class = "wd-badge wd-badge--warn" > Delayed< / span > '
2026-05-16 17:02:39 -04:00
};
2026-05-21 23:12:58 -04:00
return map[status] || `< span class = "wd-badge wd-badge--idle" > ${escHtml(status || 'Unknown')}< / span > `;
2026-05-16 17:02:39 -04:00
}
/* ────────────────────────────────────────────────────────
Filter tabs
──────────────────────────────────────────────────────── */
function setFilter(filter, btn) {
currentFilter = filter;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderJobs();
}
/* ────────────────────────────────────────────────────────
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 >
2026-05-19 00:54:47 -04:00
${job.status === 'failed' & & job.asset_id ? `
< div class = "panel-section" >
2026-05-21 23:12:58 -04:00
< button class = "wd-btn wd-btn--secondary wd-btn--sm" onclick = "retryJob('${escHtml(job.asset_id)}', event); closeDetail();" >
2026-05-19 00:54:47 -04:00
Retry — re-queue processing
< / button >
< / div > ` : ''}
2026-05-16 17:02:39 -04:00
${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 > `;
2026-05-21 23:12:58 -04:00
document.getElementById('detail-panel').classList.add('is-open');
document.getElementById('detail-overlay').classList.add('is-open');
2026-05-16 17:02:39 -04:00
}
function closeDetail() {
2026-05-21 23:12:58 -04:00
document.getElementById('detail-panel').classList.remove('is-open');
document.getElementById('detail-overlay').classList.remove('is-open');
2026-05-16 17:02:39 -04:00
}
/* ────────────────────────────────────────────────────────
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'}.`);
} catch (e) {
showError('Failed to clear jobs: ' + e.message);
}
}
/* ────────────────────────────────────────────────────────
Utilities
──────────────────────────────────────────────────────── */
function escHtml(s) {
return String(s ?? '').replace(/&/g,'& ').replace(/< /g,'< ').replace(/>/g,'> ').replace(/"/g,'" ');
}
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');
2026-05-21 23:12:58 -04:00
el.className = `wd-toast ${type === 'error' ? 'wd-toast--error' : 'wd-toast--success'}`;
2026-05-16 17:02:39 -04:00
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
──────────────────────────────────────────────────────── */
2026-05-19 23:17:18 -04:00
startSSE();
2026-05-16 17:02:39 -04:00
< / script >
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
< script src = "js/topbar-strip.js?v=1" > < / script >
2026-05-18 13:43:22 -04:00
< script src = "js/auth-guard.js" > < / script >
2026-05-16 17:02:39 -04:00
< / body >
< / html >