Replace inline auth script with shared auth-guard.js on recorders, jobs, users, tokens pages: jobs.html
This commit is contained in:
parent
f3fbb027f6
commit
9dfefc5731
1 changed files with 1 additions and 883 deletions
|
|
@ -1,883 +1 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
<div class="sidebar-brand-mark">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
|
||||
</div>
|
||||
<span class="sidebar-brand-name">Wild Dragon</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<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="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>
|
||||
<div class="sidebar-section-label">Admin</div>
|
||||
<a href="settings.html" class="nav-item">
|
||||
<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 8H15M3.2 3.2l1 1M11.8 11.8l1 1M3.2 12.8l1-1M11.8 4.2l1-1"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
<a href="users.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="tokens.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="10" height="8" rx="1"/><path d="M6 6V4a2 2 0 0 1 4 0v2"/></svg>
|
||||
Tokens
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name" id="userName">—</div>
|
||||
<div class="sidebar-user-role" id="userRole"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</td>`;
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
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,'&').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');
|
||||
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>
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const u = await r.json();
|
||||
const name = u.display_name || u.username || 'User';
|
||||
document.getElementById('userName').textContent = name;
|
||||
document.getElementById('userAvatar').textContent = name[0].toUpperCase();
|
||||
const roleEl = document.getElementById('userRole');
|
||||
if (roleEl) roleEl.textContent = u.role || '';
|
||||
}
|
||||
} catch (_) {}
|
||||
document.getElementById('logoutBtn').onclick = async () => {
|
||||
try { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
|
||||
location.href = 'login.html';
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
jobs_fixed
|
||||
Loading…
Reference in a new issue