feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-jobs.jsx
This commit is contained in:
parent
bc03ee866b
commit
1392e28a88
1 changed files with 81 additions and 97 deletions
|
|
@ -1,98 +1,106 @@
|
||||||
// screens-jobs.jsx — Jobs queue with real-time progress visualization
|
// screens-jobs.jsx
|
||||||
|
|
||||||
const { JOBS: ALL_JOBS } = window.ZAMPP_DATA;
|
|
||||||
|
|
||||||
function Jobs({ navigate }) {
|
function Jobs({ navigate }) {
|
||||||
const [tab, setTab] = React.useState("all");
|
const [tab, setTab] = React.useState('all');
|
||||||
const [jobs, setJobs] = React.useState(ALL_JOBS);
|
const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
|
||||||
|
const [lastFetch, setLastFetch] = React.useState(Date.now());
|
||||||
|
|
||||||
|
// Poll for job updates every 5s
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const i = setInterval(() => {
|
const poll = () => {
|
||||||
setJobs(js => js.map(j => {
|
window.ZAMPP_API.fetch('/jobs')
|
||||||
if (j.status !== "running") return j;
|
.then(raw => {
|
||||||
const next = Math.min(100, j.progress + Math.random() * 3);
|
const normalized = (raw || []).map(j => {
|
||||||
if (next >= 100) return { ...j, progress: 100, status: "done", eta: "—" };
|
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
|
||||||
const [m, s] = j.eta.split(":").map(Number);
|
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode' };
|
||||||
const total = m * 60 + s - 1;
|
const meta = j.metadata || {};
|
||||||
const newEta = total > 0 ? `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}` : "00:00";
|
return {
|
||||||
return { ...j, progress: next, eta: newEta };
|
...j,
|
||||||
}));
|
status: statusMap[j.status] || j.status,
|
||||||
}, 1200);
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
window.ZAMPP_DATA.JOBS = normalized;
|
||||||
|
setJobs(normalized);
|
||||||
|
setLastFetch(Date.now());
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
const i = setInterval(poll, 5000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
all: jobs.length,
|
all: jobs.length,
|
||||||
running: jobs.filter(j => j.status === "running").length,
|
running: jobs.filter(j => j.status === 'running').length,
|
||||||
queued: jobs.filter(j => j.status === "queued").length,
|
queued: jobs.filter(j => j.status === 'queued').length,
|
||||||
done: jobs.filter(j => j.status === "done").length,
|
done: jobs.filter(j => j.status === 'done').length,
|
||||||
failed: jobs.filter(j => j.status === "failed").length,
|
failed: jobs.filter(j => j.status === 'failed').length,
|
||||||
};
|
};
|
||||||
const filtered = tab === "all" ? jobs : jobs.filter(j => j.status === tab);
|
const filtered = tab === 'all' ? jobs : jobs.filter(j => j.status === tab);
|
||||||
|
|
||||||
const throughput = [12, 14, 13, 16, 15, 18, 22, 24, 22, 28, 32, 38, 42, 40];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Jobs</h1>
|
<h1>Jobs</h1>
|
||||||
<span className="subtitle">Proxy generation, transcoding, AMPP sync — live across worker pool</span>
|
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<button className="btn ghost sm"><Icon name="filter" />Filter</button>
|
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/jobs').then(raw => { const norm = (raw||[]).map(j => ({ ...j, status: ({waiting:'queued',active:'running',completed:'done',failed:'failed'})[j.status]||j.status, kind: ({proxy:'Proxy',thumbnail:'Thumbnail',conform:'Conform',transcode:'Transcode'})[j.type]||j.type||'Job', asset: j.asset_name||'—', eta:'—', node:(j.metadata||{}).node||'—', priority:(j.metadata||{}).priority||'normal', error:j.error||null, progress:j.progress||0 })); setJobs(norm); })}>
|
||||||
<button className="btn ghost sm"><Icon name="refresh" />Refresh</button>
|
<Icon name="refresh" />Refresh
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
<div className="jobs-stats">
|
<div className="jobs-stats">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="label">Throughput</div>
|
<div className="label">Running</div>
|
||||||
<div className="value">42<span style={{ fontSize: 14, color: "var(--text-3)", fontWeight: 400 }}> / min</span></div>
|
<div className="value">{counts.running}</div>
|
||||||
<div className="delta up">+18% vs last hour</div>
|
<div className="delta">{counts.queued} queued</div>
|
||||||
<Sparkline data={throughput} color="#5B7CFA" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="label">Avg duration</div>
|
<div className="label">Completed</div>
|
||||||
<div className="value">4.2<span style={{ fontSize: 14, color: "var(--text-3)", fontWeight: 400 }}> min</span></div>
|
<div className="value">{counts.done}</div>
|
||||||
<div className="delta">Proxy: 6.1m · Transcode: 2.4m</div>
|
<div className="delta">Total done</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="label">Worker pool</div>
|
<div className="label">Failed</div>
|
||||||
<div className="value">3<span style={{ fontSize: 14, color: "var(--text-3)", fontWeight: 400 }}> / 4 active</span></div>
|
|
||||||
<div className="delta">GPU util: 67%</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="label">Failed (24h)</div>
|
|
||||||
<div className="value">{counts.failed}</div>
|
<div className="value">{counts.failed}</div>
|
||||||
<div className="delta" style={{ color: counts.failed > 0 ? "var(--warning)" : "var(--text-3)" }}>
|
<div className="delta" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}>
|
||||||
{counts.failed > 0 ? "1 needs attention" : "All good"}
|
{counts.failed > 0 ? 'Needs attention' : 'All clear'}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="tab-group" style={{ marginTop: 20, width: "fit-content" }}>
|
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}>
|
||||||
{[
|
{[
|
||||||
{ id: "all", label: `All · ${counts.all}` },
|
{ id: 'all', label: 'All · ' + counts.all },
|
||||||
{ id: "running", label: `Running · ${counts.running}` },
|
{ id: 'running', label: 'Running · ' + counts.running },
|
||||||
{ id: "queued", label: `Queued · ${counts.queued}` },
|
{ id: 'queued', label: 'Queued · ' + counts.queued },
|
||||||
{ id: "done", label: `Done · ${counts.done}` },
|
{ id: 'done', label: 'Done · ' + counts.done },
|
||||||
{ id: "failed", label: `Failed · ${counts.failed}` },
|
{ id: 'failed', label: 'Failed · ' + counts.failed },
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button key={t.id} className={tab === t.id ? "active" : ""} onClick={() => setTab(t.id)}>{t.label}</button>
|
<button key={t.id} className={tab === t.id ? 'active' : ''} onClick={() => setTab(t.id)}>{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel" style={{ marginTop: 12 }}>
|
<div className="panel" style={{ marginTop: 12 }}>
|
||||||
<div className="job-row head">
|
<div className="job-row head">
|
||||||
<div></div>
|
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>ETA</div><div>Priority</div><div></div>
|
||||||
<div>Job</div>
|
|
||||||
<div>Asset</div>
|
|
||||||
<div>Node</div>
|
|
||||||
<div>Progress</div>
|
|
||||||
<div>ETA</div>
|
|
||||||
<div>Priority</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
</div>
|
||||||
{filtered.map(j => <JobRow key={j.id} job={j} />)}
|
{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} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,59 +108,35 @@ function Jobs({ navigate }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobRow({ job }) {
|
function JobRow({ job }) {
|
||||||
|
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' };
|
||||||
return (
|
return (
|
||||||
<div className="job-row">
|
<div className="job-row">
|
||||||
<div><StatusDot status={job.status} /></div>
|
<div><StatusDot status={job.status} /></div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Icon name={iconForJob(job.kind)} size={13} style={{ color: "var(--text-3)" }} />
|
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
|
||||||
<span style={{ fontWeight: 500 }}>{job.kind}</span>
|
<span style={{ fontWeight: 500 }}>{job.kind}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text-2)" }}>
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div>
|
||||||
{job.asset}
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div>
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>{job.node}</div>
|
|
||||||
<div>
|
<div>
|
||||||
{job.status === "running" && (
|
{job.status === 'running' && (
|
||||||
<div className="job-progress-wrap">
|
<div className="job-progress-wrap">
|
||||||
<div className="job-progress-bar">
|
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
|
||||||
<div className="job-progress-fill" style={{ width: `${job.progress}%` }} />
|
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span>
|
||||||
</div>
|
|
||||||
<span className="mono" style={{ fontSize: 10.5, color: "var(--text-3)", minWidth: 32, textAlign: "right" }}>
|
|
||||||
{Math.round(job.progress)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{job.status === "done" && (
|
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
|
||||||
<span className="badge success" style={{ background: "transparent", padding: 0 }}>
|
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting…</span>}
|
||||||
<Icon name="check" size={12} /> Complete
|
{job.status === 'failed' && <span style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4 }}><Icon name="alert" size={12} />{job.error || 'Failed'}</span>}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{job.status === "queued" && (
|
|
||||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>Waiting…</span>
|
|
||||||
)}
|
|
||||||
{job.status === "failed" && (
|
|
||||||
<span style={{ fontSize: 12, color: "var(--danger)", display: "flex", alignItems: "center", gap: 4 }}>
|
|
||||||
<Icon name="alert" size={12} /> {job.error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{job.eta}</div>
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-3)' }}>{job.eta}</div>
|
||||||
|
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
|
||||||
<div>
|
<div>
|
||||||
<span className={`badge ${job.priority === "high" ? "warning" : job.priority === "low" ? "neutral" : "outline"}`}>
|
{job.status === 'failed' && <button className="btn ghost sm"><Icon name="refresh" />Retry</button>}
|
||||||
{job.priority}
|
{(job.status === 'queued' || job.status === 'done') && <button className="icon-btn"><Icon name="more" /></button>}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{job.status === "running" && <button className="btn ghost sm">Cancel</button>}
|
|
||||||
{job.status === "failed" && <button className="btn ghost sm"><Icon name="refresh" />Retry</button>}
|
|
||||||
{(job.status === "queued" || job.status === "done") && <button className="icon-btn"><Icon name="more" /></button>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconForJob(kind) {
|
|
||||||
return { Proxy: "proxy", Transcode: "film", Thumbnail: "image", "AMPP Sync": "refresh" }[kind] || "jobs";
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Jobs = Jobs;
|
window.Jobs = Jobs;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue