Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal codec stubs) deferred per user request. ## #146 sweep em-dashes (186 to 0) - Replace placeholder '—' with '·' across all jsx - Convert ' — ' to ': ' or '. ' in copy where context permits - Comment-only em-dashes converted to ASCII dash - Sweep css files too (16 comments) ## #147 remove glassmorphism + accent gradients - Strip 8 backdrop-filter declarations from styles-screens.css and styles-asset.css. Only legit modal scrim in styles-modal.css remains. - Replace .job-progress-fill gradient with solid var(--accent) - Replace .monitor-tile.audio gradient with flat var(--bg-1) ## #148 extract Jobs inline styles to CSS - Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic width on progress bar). Live DOM was 487 inline-styled elements due to per-row repetition; now ~0. - Added job-row-kind, job-row-asset, job-row-node, job-row-time, job-row-actions, job-row-status-* utility classes in styles-screens.css ## #149 sidebar IA reorganized - Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS: Workspace / Ingest / Operations / Admin - Move Capture out of Ingest into Operations (it's a live-signal monitor, not an ingest action) - Drop the 0/N capture badge from nav (belongs in topbar) - Add BETA badge to Editor ## #151 redesign Editor 'Coming Soon' bumper - Replace fullscreen glassmorphism + gradient + glow overlay with a flat beta banner across the top of the editor area - New .editor-beta-banner CSS class (flat, accent-soft tint, no blur) ## #152 hide Tokens parody, restore real API token mgmt - New top-level Tokens admin page wraps existing ApiTokensSection - Old parody renamed to TokensParody, accessible at /tokens-parody route - Add window-level df:nav event for cross-component routing ## #153 make Home actually useful - New activity strip below the launcher grid: 'Recording now' tiles for live recorders, 'Last 24 hours' tiles for newly created assets, plus an attention strip when there are failed jobs or errored recorders - Each item is clickable and routes to the relevant screen ## #154 aria-labels on icon-only buttons - Projects + Library grid/list view toggles now have aria-label + title ## #155 page-header pattern - Dashboard now renders a proper .page-header h1 with subtitle + alert badge + cluster status pip - Library toolbar-title promoted to h1 for screen-reader hierarchy - Document Home/Library/Editor full-bleed exceptions in DESIGN.md - Editor's chrome is the beta banner (covered by #151)
253 lines
11 KiB
JavaScript
253 lines
11 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' + (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;
|