dragonflight/services/web-ui/public/screens-jobs.jsx
ZGaetano 6f64b55824 feat(ui): add 'Cancel all failed' button to Jobs screen
Pair with the existing 'Retry all failed'. Drops every failed job from
the queue at once. Single confirm prompt. Optimistic local update so the
list clears instantly instead of waiting for the 5s poll tick.

.jobs-cancel-all CSS tinted danger-red without being a loud .btn danger,
matching the per-row Cancel pattern.
2026-05-29 00:02:55 +00:00

275 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]);
// Drop every failed job from the queue. The opposite of Retry all — used
// when a batch of jobs is unrecoverable (e.g. assets that were deleted
// mid-encode) and the operator just wants the queue cleared.
const handleCancelAll = React.useCallback(() => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
if (!window.confirm(`Remove all ${failedJobs.length} failed jobs from the queue?\nThis cannot be undone.`)) return;
Promise.allSettled(
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id, { method: 'DELETE' }))
).then(() => {
// Optimistic local drop so the UI updates the instant the modal closes,
// not 5s later on the next poll tick.
setJobs(prev => prev.filter(j => j.status !== 'failed'));
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 jobs-cancel-all" onClick={handleCancelAll} title={`Remove all ${counts.failed} failed jobs from the queue`}>
<Icon name="x" />Cancel 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' + (counts.failed > 0 ? ' delta-warn' : '')}>
{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 delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
</div>
</div>
<div className="tab-group jobs-tabs">
{[
{ 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 jobs-panel">
<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 className="jobs-empty">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 className="job-row-kind">
<Icon name={iconMap[job.kind] || 'jobs'} size={13} className="job-row-kind-icon" />
<span className="job-row-kind-name">{job.kind}</span>
</div>
<div className="job-row-asset">{job.asset}</div>
<div className="mono job-row-node">{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 job-row-progress-pct">{Math.round(job.progress)}%</span>
</div>
)}
{job.status === 'done' && <span className="badge success job-row-status-done"><Icon name="check" size={12} /> Complete</span>}
{job.status === 'queued' && <span className="job-row-status-queued">Waiting</span>}
{job.status === 'failed' && (
<span title={job.error || 'Failed'} className="job-row-status-failed">
<Icon name="alert" size={12} />
<span className="job-row-status-failed-msg">
{(job.error || 'Failed').slice(0, 120)}
</span>
</span>
)}
</div>
<div className="mono job-row-time"
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 className="job-row-actions">
{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 job-row-cancel" onClick={() => onDelete(job, 'cancel')}
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;