processing card: meaningful progress display
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m42s

Two issues with the old bar:
1. progress state was never cleared between jobs — when a job finished,
   its 100% bar lingered on the next job's card until that job emitted
   its first progress event. Clear progress on any job_update where
   status != 'running', and on the column side ignore progress unless
   progress.id matches the current job.id.
2. labels were misleading: the left/right times were ffmpeg's *input*
   timestamp position (how far into the source it had read), not wall-
   clock elapsed/remaining. For -c copy jobs ripping a 90-min file in
   5 wall-seconds, the user saw '0:45 / 90:00' jump straight to
   '90:00 / 90:00' which looks broken.

New display: 'elapsed M:SS  N%  ~M:SS left'. Elapsed is wall-clock
since the job started (re-renders every second), percent comes from
ffmpeg input progress as before, ETA is derived from elapsed × (100-p)/p
once we have at least 1% to avoid wild guesses.
This commit is contained in:
2026-04-13 10:29:49 +02:00
parent 37e30e9ade
commit 5fa39aee7c
2 changed files with 49 additions and 19 deletions

View File

@@ -69,7 +69,17 @@ export function PipelinePage() {
}, 1000);
};
const es = new EventSource("/api/execute/events");
es.addEventListener("job_update", scheduleReload);
es.addEventListener("job_update", (e) => {
// When a job leaves 'running' (done / error / cancelled), drop any
// stale progress so the bar doesn't linger on the next job's card.
try {
const upd = JSON.parse((e as MessageEvent).data) as { id: number; status: string };
if (upd.status !== "running") setProgress(null);
} catch {
/* ignore malformed events */
}
scheduleReload();
});
es.addEventListener("job_progress", (e) => {
setProgress(JSON.parse((e as MessageEvent).data));
});

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import { ColumnShell } from "./ColumnShell";
@@ -12,10 +13,31 @@ interface ProcessingColumnProps {
export function ProcessingColumn({ items, progress, queueStatus, onMutate }: ProcessingColumnProps) {
const job = items[0]; // at most one running job
const formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, "0")}`;
// Wall-clock elapsed since the job started — re-renders every second.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!job) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [job]);
// Only trust progress if it belongs to the current job — stale events from
// a previous job would otherwise show wrong numbers until the new job emits.
const liveProgress = job && progress && progress.id === job.id ? progress : null;
const startedAt = job?.started_at ? new Date(`${job.started_at}Z`).getTime() : null;
const elapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
const percent = liveProgress && liveProgress.total > 0 ? (liveProgress.seconds / liveProgress.total) * 100 : null;
// ETA = elapsed × (1 - p) / p — only meaningful once we have a few percent
// of progress and the rate has stabilised.
const etaMs = percent != null && percent >= 1 && elapsedMs > 0 ? (elapsedMs * (100 - percent)) / percent : null;
const formatDuration = (ms: number) => {
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
};
const stop = async () => {
@@ -55,21 +77,19 @@ export function ProcessingColumn({ items, progress, queueStatus, onMutate }: Pro
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
</div>
{progress && progress.total > 0 && (
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{formatTime(progress.seconds)}</span>
<span>{Math.round((progress.seconds / progress.total) * 100)}%</span>
<span>{formatTime(progress.total)}</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100)}%` }}
/>
</div>
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1 tabular-nums">
<span>elapsed {formatDuration(elapsedMs)}</span>
<span>{percent != null ? `${Math.round(percent)}%` : "starting…"}</span>
<span>{etaMs != null ? `~${formatDuration(etaMs)} left` : "—"}</span>
</div>
)}
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${percent != null ? Math.min(100, percent) : 0}%` }}
/>
</div>
</div>
</div>
) : (
<p className="text-sm text-gray-400 text-center py-8">No active job</p>