diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx
index e6f1790..cfaec9b 100644
--- a/src/features/pipeline/PipelinePage.tsx
+++ b/src/features/pipeline/PipelinePage.tsx
@@ -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));
});
diff --git a/src/features/pipeline/ProcessingColumn.tsx b/src/features/pipeline/ProcessingColumn.tsx
index 5174cc7..5357a7f 100644
--- a/src/features/pipeline/ProcessingColumn.tsx
+++ b/src/features/pipeline/ProcessingColumn.tsx
@@ -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
No active job