dragonflight/services/web-ui/public/screens-jobs.jsx
opencode 04ce096e67 chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
  validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
  production React UMD instead of dev builds + in-browser Babel (#139,
  #122)
- Search wrapper gets role=search; global search input gets aria-label,
  role=combobox, aria-controls/aria-expanded/aria-activedescendant
  wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
  mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
  flex container — switched to flex:1 + min-height:0 (#131, #132,
  editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
  the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
  data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
  window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
  uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)

Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
  Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
  of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
  tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
  transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
  detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
  tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
  create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
  private + loopback hosts for non-admins, denies common service
  ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
  rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
  survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
  server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
  (ampp_sync_status / attempts / next_attempt_at + scheduler retry
  loop with exponential backoff) (#77)

Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)

Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
  Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
  migration tool to v1.3
2026-05-27 02:06:14 +00:00

254 lines
12 KiB
JavaScript

// screens-jobs.jsx
// Pick the most-meaningful timestamp + label for a job's current state.
// Returns { label, iso } — caller renders "<label> <relative-time>" with
// the full ISO as a tooltip.
function _jobTimeFor(job) {
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
if (job.status === 'failed' && job.failed_at) return { label: 'failed', iso: job.failed_at };
if (job.status === 'running' && job.started_at) return { label: 'started', iso: job.started_at };
if (job.created_at) return { label: 'queued', iso: job.created_at };
return null;
}
function _fmtAbsolute(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', second: '2-digit',
});
} catch { return iso; }
}
// Compact clock for the inline jobs cell — "2:23 PM" if today,
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
function _fmtCompact(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
const now = new Date();
const sameDay = d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
if (sameDay) return time;
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return date + ' · ' + time;
} catch { return iso; }
}
function Jobs({ navigate }) {
const [tab, setTab] = React.useState('all');
const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
const [lastFetch, setLastFetch] = React.useState(Date.now());
const normalizeJob = (j) => {
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' };
const meta = j.metadata || {};
return {
...j,
status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '—',
eta: '—',
node: meta.node || '—',
priority: meta.priority || 'normal',
error: j.error || null,
progress: j.progress || 0,
};
};
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/jobs')
.then(raw => {
const norm = (raw || []).map(normalizeJob);
window.ZAMPP_DATA.JOBS = norm;
setJobs(norm);
setLastFetch(Date.now());
})
.catch(() => {});
}, []);
React.useEffect(() => {
const i = setInterval(refresh, 5000);
return () => clearInterval(i);
}, [refresh]);
const handleRetry = React.useCallback((job) => {
window.ZAMPP_API.fetch('/jobs/' + job.id + '/retry', { method: 'POST' })
.then(() => refresh())
.catch(e => alert('Retry failed: ' + e.message));
}, [refresh]);
// One handler covers cancel (running) AND delete (queued / done / failed).
// BullMQ's job.remove() — what the API calls — works on any state, so a
// stalled-active job (worker died mid-process, holding a concurrency slot)
// gets yanked and the next queued job runs. mode just changes the prompt
// copy so the operator knows what they're doing.
const handleDelete = React.useCallback((job, mode) => {
const msg = mode === 'cancel'
? 'Cancel this running ' + job.kind + ' job?\n\nThe worker may run a few seconds longer in the background, but its result will be discarded and the queue slot frees up immediately.'
: 'Remove this ' + job.status + ' ' + job.kind + ' job from the queue?';
if (!window.confirm(msg)) return;
window.ZAMPP_API.fetch('/jobs/' + job.id, { method: 'DELETE' })
.then(() => setJobs(prev => prev.filter(j => j.id !== job.id)))
.catch(e => alert((mode === 'cancel' ? 'Cancel' : 'Delete') + ' failed: ' + e.message));
}, []);
// Retry every failed job at once. Useful after a transient infra issue
// (S3 outage, hung worker) — one click per job is painful with 20+ failures.
const handleRetryAll = React.useCallback(() => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
if (!window.confirm(`Re-queue all ${failedJobs.length} failed jobs?`)) return;
Promise.allSettled(
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
).then(refresh);
}, [jobs, refresh]);
const counts = {
all: jobs.length,
running: jobs.filter(j => j.status === 'running').length,
queued: jobs.filter(j => j.status === 'queued').length,
done: jobs.filter(j => j.status === 'done').length,
failed: jobs.filter(j => j.status === 'failed').length,
};
const filtered = tab === 'all' ? jobs : jobs.filter(j => j.status === tab);
return (
<div className="page">
<div className="page-header">
<h1>Jobs</h1>
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
<div className="spacer" />
{counts.failed > 0 && (
<button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
<Icon name="refresh" />Retry all failed
</button>
)}
<button className="btn ghost sm" onClick={refresh}>
<Icon name="refresh" />Refresh
</button>
</div>
<div className="page-body">
<div className="jobs-stats">
<div className="stat-card">
<div className="label">Running</div>
<div className="value">{counts.running}</div>
<div className="delta">{counts.queued} queued</div>
</div>
<div className="stat-card">
<div className="label">Completed</div>
<div className="value">{counts.done}</div>
<div className="delta">Total done</div>
</div>
<div className="stat-card">
<div className="label">Failed</div>
<div className="value">{counts.failed}</div>
<div className="delta" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}>
{counts.failed > 0 ? 'Needs attention' : 'All clear'}
</div>
</div>
<div className="stat-card">
<div className="label">Total jobs</div>
<div className="value">{counts.all}</div>
<div className="delta muted" style={{ fontSize: 10.5 }}>Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
</div>
</div>
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}>
{[
{ id: 'all', label: 'All · ' + counts.all },
{ id: 'running', label: 'Running · ' + counts.running },
{ id: 'queued', label: 'Queued · ' + counts.queued },
{ id: 'done', label: 'Done · ' + counts.done },
{ id: 'failed', label: 'Failed · ' + counts.failed },
].map(t => (
<button key={t.id} className={tab === t.id ? 'active' : ''} onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="panel" style={{ marginTop: 12 }}>
<div className="job-row head">
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
</div>
{filtered.length === 0
? <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div>
: filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}
</div>
</div>
</div>
);
}
function JobRow({ job, onRetry, onDelete }) {
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' };
return (
<div className="job-row">
<div><StatusDot status={job.status} /></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
<span style={{ fontWeight: 500 }}>{job.kind}</span>
</div>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div>
<div>
{job.status === 'running' && (
<div className="job-progress-wrap">
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span>
</div>
)}
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting</span>}
{job.status === 'failed' && (
<span title={job.error || 'Failed'}
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
<Icon name="alert" size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{(job.error || 'Failed').slice(0, 120)}
</span>
</span>
)}
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
{(() => {
const t = _jobTimeFor(job);
if (!t) return '—';
// Terminal states (done/failed) anchor on the absolute clock so the
// operator can correlate with logs; queued/running show relative
// since it's a moving target.
if (job.status === 'done' || job.status === 'failed') {
return t.label + ' ' + _fmtCompact(t.iso) + ' · ' + window.ZAMPP_API.fmtRelative(t.iso);
}
return t.label + ' ' + window.ZAMPP_API.fmtRelative(t.iso);
})()}
</div>
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
{job.status === 'failed' && (
<button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
)}
{job.status === 'running' && (
/* Cancel a stalled-active job — frees the BullMQ concurrency slot
so anything queued behind it can run. The worker may finish in
the background but its result is discarded. */
<button className="btn ghost sm" onClick={() => onDelete(job, 'cancel')}
style={{ color: 'var(--danger)' }} title="Cancel this running job and free its queue slot">
<Icon name="x" />Cancel
</button>
)}
{(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && (
<button className="icon-btn" aria-label="Remove job from the queue" title="Remove job from the queue"
onClick={() => onDelete(job, 'delete')}><Icon name="x" /></button>
)}
</div>
</div>
);
}
window.Jobs = Jobs;