diff --git a/web/app/workflows/[id]/runs/[runId]/page.tsx b/web/app/workflows/[id]/runs/[runId]/page.tsx index a1fd2dd..0940a45 100644 --- a/web/app/workflows/[id]/runs/[runId]/page.tsx +++ b/web/app/workflows/[id]/runs/[runId]/page.tsx @@ -11,38 +11,38 @@ import { Button } from "@/components/ui"; type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn"; function cellKind(status: string): CellKind { - switch (status) { - case "success": - return "done"; - case "failed": - return "fail"; - case "running": - return "run"; - case "skipped": - return "skip"; - case "cancelled": - return "warn"; - default: - return "wait"; // queued / pending / missing - } + switch (status) { + case "success": + return "done"; + case "failed": + return "fail"; + case "running": + return "run"; + case "skipped": + return "skip"; + case "cancelled": + return "warn"; + default: + return "wait"; // queued / pending / missing + } } const cellGlyph: Record = { - done: "✓", - fail: "✕", - run: "●", - wait: "○", - skip: "–", - warn: "!", + done: "✓", + fail: "✕", + run: "●", + wait: "○", + skip: "–", + warn: "!", }; const cellClass: Record = { - done: "bg-success/15 text-success", - fail: "bg-danger/15 text-danger", - run: "bg-accent/15 text-accent", - wait: "text-border", - skip: "text-text-secondary", - warn: "bg-warning/15 text-warning", + done: "bg-success/15 text-success", + fail: "bg-danger/15 text-danger", + run: "bg-accent/15 text-accent", + wait: "text-border", + skip: "text-text-secondary", + warn: "bg-warning/15 text-warning", }; // ---- run-level status pill ------------------------------------------------ @@ -50,454 +50,359 @@ const cellClass: Record = { type PillKind = "running" | "success" | "failed" | "neutral"; function pillKind(status: string): PillKind { - if (status === "running") return "running"; - if (status === "success") return "success"; - if (status === "failed" || status === "cancelled") return "failed"; - return "neutral"; + if (status === "running") return "running"; + if (status === "success") return "success"; + if (status === "failed" || status === "cancelled") return "failed"; + return "neutral"; } const pillClass: Record = { - running: "text-accent border-accent/40 bg-accent/10", - success: "text-success border-success/35 bg-success/10", - failed: "text-danger border-danger/35 bg-danger/10", - neutral: "text-text-secondary border-border bg-surface-2", + running: "text-accent border-accent/40 bg-accent/10", + success: "text-success border-success/35 bg-success/10", + failed: "text-danger border-danger/35 bg-danger/10", + neutral: "text-text-secondary border-border bg-surface-2", }; const pillLed: Record = { - running: "bg-accent led-pulse", - success: "bg-success", - failed: "bg-danger", - neutral: "bg-text-secondary", + running: "bg-accent led-pulse", + success: "bg-success", + failed: "bg-danger", + neutral: "bg-text-secondary", }; function StatusPill({ status, small }: { status: string; small?: boolean }) { - const kind = pillKind(status); - return ( - - - {status} - - ); + const kind = pillKind(status); + return ( + + + {status} + + ); } // ---- time helpers --------------------------------------------------------- function fmtDuration(ms: number): string { - if (ms < 0) ms = 0; - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rem = s % 60; - if (m < 60) return `${m}m ${rem}s`; - const h = Math.floor(m / 60); - return `${h}h ${m % 60}m`; + if (ms < 0) ms = 0; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + if (m < 60) return `${m}m ${rem}s`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; } function stepDuration(st: StepRun, running: boolean, now: number): string { - if (!st.started_at) return st.status === "queued" ? "queued" : ""; - const start = new Date(st.started_at).getTime(); - const end = st.finished_at ? new Date(st.finished_at).getTime() : running ? now : start; - return fmtDuration(end - start); + if (!st.started_at) return st.status === "queued" ? "queued" : ""; + const start = new Date(st.started_at).getTime(); + const end = st.finished_at ? new Date(st.finished_at).getTime() : running ? now : start; + return fmtDuration(end - start); } // ---- live log terminal ---------------------------------------------------- -function LogTerminal({ - runId, - server, -}: { - runId: string; - server: ServerRun; -}) { - const [text, setText] = useState(""); - const preRef = useRef(null); - const running = server.status === "running"; - const serverId = server.server_id; +function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) { + const [text, setText] = useState(""); + const preRef = useRef(null); + const running = server.status === "running"; + const serverId = server.server_id; - useEffect(() => { - setText(""); - if (running) { - const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), { - withCredentials: true, - }); - es.onmessage = (e) => setText((t) => t + e.data + "\n"); - es.addEventListener("done", () => es.close()); - es.onerror = () => es.close(); - return () => es.close(); - } - api - .getServerRunLog(runId, serverId) - .then(setText) - .catch(() => setText("")); - }, [running, runId, serverId]); + useEffect(() => { + setText(""); + if (running) { + const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), { + withCredentials: true, + }); + es.onmessage = (e) => setText((t) => t + e.data + "\n"); + es.addEventListener("done", () => es.close()); + es.onerror = () => es.close(); + return () => es.close(); + } + api.getServerRunLog(runId, serverId) + .then(setText) + .catch(() => setText("")); + }, [running, runId, serverId]); - useEffect(() => { - preRef.current?.scrollTo(0, preRef.current.scrollHeight); - }, [text]); + useEffect(() => { + preRef.current?.scrollTo(0, preRef.current.scrollHeight); + }, [text]); - const activeStep = - server.steps.find((s) => s.status === "running") ?? - [...server.steps].reverse().find((s) => s.started_at); + const activeStep = server.steps.find((s) => s.status === "running") ?? [...server.steps].reverse().find((s) => s.started_at); - return ( -
-
- - {activeStep ? activeStep.name : "Output"}{" "} - {server.hostname} - - {running && ( - - - Streaming - - )} -
-
- {text || (running ? "Waiting for output…" : "No output.")} - {running && text && ( - - )} -
-
- ); + return ( +
+
+ + {activeStep ? activeStep.name : "Output"} {server.hostname} + + {running && ( + + + Streaming + + )} +
+
+ {text || (running ? "Waiting for output…" : "No output.")} + {running && text && } +
+
+ ); } // ---- step list ------------------------------------------------------------ -function StepList({ - server, - now, -}: { - server: ServerRun; - now: number; -}) { - const running = server.status === "running"; - return ( -
-
- - Steps {server.steps.length} - - -
-
- {server.steps.map((st) => { - const kind = cellKind(st.status); - return ( -
- - {String(st.order + 1).padStart(2, "0")} - - - - {cellGlyph[kind]} +function StepList({ server, now }: { server: ServerRun; now: number }) { + const running = server.status === "running"; + return ( +
+
+ + Steps {server.steps.length} - {st.name} - - - {st.status === "failed" && ( - exit {st.exit_code} · - )} - {st.attempts > 1 ? `${st.attempts} tries` : "1 try"} - {stepDuration(st, running, now) ? ` · ${stepDuration(st, running, now)}` : ""} - +
- ); - })} - {server.steps.length === 0 && ( -

No steps yet.

- )} -
-
- ); +
+ {server.steps.map((st) => { + const kind = cellKind(st.status); + return ( +
+ {String(st.order + 1).padStart(2, "0")} + + {cellGlyph[kind]} + {st.name} + + + {st.status === "failed" && exit {st.exit_code} · } + {st.attempts > 1 ? `${st.attempts} tries` : "1 try"} + {stepDuration(st, running, now) ? ` · ${stepDuration(st, running, now)}` : ""} + +
+ ); + })} + {server.steps.length === 0 &&

No steps yet.

} +
+
+ ); } // ---- execution matrix (signature) ----------------------------------------- interface Column { - order: number; - name: string; + order: number; + name: string; } function buildColumns(run: WorkflowRun): Column[] { - const byOrder = new Map(); - for (const sr of run.server_runs) { - for (const st of sr.steps) { - if (!byOrder.has(st.order)) byOrder.set(st.order, st.name); + const byOrder = new Map(); + for (const sr of run.server_runs) { + for (const st of sr.steps) { + if (!byOrder.has(st.order)) byOrder.set(st.order, st.name); + } } - } - return [...byOrder.entries()] - .map(([order, name]) => ({ order, name })) - .sort((a, b) => a.order - b.order); + return [...byOrder.entries()].map(([order, name]) => ({ order, name })).sort((a, b) => a.order - b.order); } -function ExecutionMatrix({ - run, - columns, - selected, - onSelect, -}: { - run: WorkflowRun; - columns: Column[]; - selected: string; - onSelect: (serverId: string) => void; -}) { - return ( -
- - - - - {columns.map((c) => ( - - ))} - - - - {run.server_runs.map((sr) => { - const byOrder = new Map(sr.steps.map((s) => [s.order, s])); - const isSel = sr.server_id === selected; - return ( - onSelect(sr.server_id)} - className={`cursor-pointer ${isSel ? "bg-accent/5" : "hover:bg-white/[0.02]"}`} - > - - {columns.map((c) => { - const st = byOrder.get(c.order); - const kind = st ? cellKind(st.status) : "wait"; - return ( - - ); - })} - - ); - })} - -
- Server - - - {String(c.order + 1).padStart(2, "0")} - - {c.name} -
- {sr.hostname} - - - - - {kind === "run" && ( - - )} - {cellGlyph[kind]} - - -
-
- ); +function ExecutionMatrix({ run, columns, selected, onSelect }: { run: WorkflowRun; columns: Column[]; selected: string; onSelect: (serverId: string) => void }) { + return ( +
+ + + + + {columns.map((c) => ( + + ))} + + + + {run.server_runs.map((sr) => { + const byOrder = new Map(sr.steps.map((s) => [s.order, s])); + const isSel = sr.server_id === selected; + return ( + onSelect(sr.server_id)} className={`cursor-pointer ${isSel ? "bg-accent/5" : "hover:bg-white/[0.02]"}`}> + + {columns.map((c) => { + const st = byOrder.get(c.order); + const kind = st ? cellKind(st.status) : "wait"; + return ( + + ); + })} + + ); + })} + +
Server + {String(c.order + 1).padStart(2, "0")} + {c.name} +
+
+ {sr.hostname} + +
+
+ + + {kind === "run" && } + {cellGlyph[kind]} + + +
+
+ ); } // ---- page ----------------------------------------------------------------- function SectionLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} - -
- ); + return ( +
+ {children} + +
+ ); } export default function RunDetail() { - const { runId } = useParams<{ runId: string }>(); - const queryClient = useQueryClient(); - const [selected, setSelected] = useState(null); - const [now, setNow] = useState(() => Date.now()); + const { runId } = useParams<{ runId: string }>(); + const queryClient = useQueryClient(); + const [selected, setSelected] = useState(null); + const [now, setNow] = useState(() => Date.now()); - const { data: run, isLoading } = useQuery({ - queryKey: ["run", runId], - queryFn: () => api.getRun(runId), - refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false), - }); + const { data: run, isLoading } = useQuery({ + queryKey: ["run", runId], + queryFn: () => api.getRun(runId), + refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false), + }); - const running = run?.status === "running"; + const running = run?.status === "running"; - // tick the elapsed clock while running - useEffect(() => { - if (!running) return; - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, [running]); + // tick the elapsed clock while running + useEffect(() => { + if (!running) return; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [running]); - const columns = useMemo(() => (run ? buildColumns(run) : []), [run]); + const columns = useMemo(() => (run ? buildColumns(run) : []), [run]); - // default selection: first running server, else first server - const selectedServer = useMemo(() => { - if (!run || run.server_runs.length === 0) return null; - if (selected) { - const match = run.server_runs.find((s) => s.server_id === selected); - if (match) return match; + // default selection: first running server, else first server + const selectedServer = useMemo(() => { + if (!run || run.server_runs.length === 0) return null; + if (selected) { + const match = run.server_runs.find((s) => s.server_id === selected); + if (match) return match; + } + return run.server_runs.find((s) => s.status === "running") ?? run.server_runs[0]; + }, [run, selected]); + + const cancel = async () => { + await api.cancelRun(runId); + queryClient.invalidateQueries({ queryKey: ["run", runId] }); + }; + + if (isLoading || !run) { + return
Loading…
; } - return run.server_runs.find((s) => s.status === "running") ?? run.server_runs[0]; - }, [run, selected]); - const cancel = async () => { - await api.cancelRun(runId); - queryClient.invalidateQueries({ queryKey: ["run", runId] }); - }; + const totalSteps = run.server_runs.reduce((n, s) => n + s.steps.length, 0); + const doneSteps = run.server_runs.reduce((n, s) => n + s.steps.filter((st) => st.status === "success").length, 0); + const succeeded = run.server_runs.filter((s) => s.status === "success").length; + const failed = run.server_runs.filter((s) => s.status === "failed" || s.status === "cancelled").length; - if (isLoading || !run) { - return
Loading…
; - } + const startMs = run.started_at ? new Date(run.started_at).getTime() : now; + const endMs = run.finished_at ? new Date(run.finished_at).getTime() : now; + const elapsed = fmtDuration(endMs - startMs); + const ago = fmtDuration(now - startMs); - const totalSteps = run.server_runs.reduce((n, s) => n + s.steps.length, 0); - const doneSteps = run.server_runs.reduce( - (n, s) => n + s.steps.filter((st) => st.status === "success").length, - 0 - ); - const succeeded = run.server_runs.filter((s) => s.status === "success").length; - const failed = run.server_runs.filter( - (s) => s.status === "failed" || s.status === "cancelled" - ).length; + return ( +
+ {/* identity bar */} +
+
+
Workflows / {run.name} / Runs
+

{run.name}

+
+ + run {run.run_id.slice(0, 8)} + + + + triggered by {run.triggered_by || "—"} + + + + started {ago} ago + + + + elapsed {elapsed} + +
+
+
+ + {running && ( + + )} +
+
- const startMs = run.started_at ? new Date(run.started_at).getTime() : now; - const endMs = run.finished_at ? new Date(run.finished_at).getTime() : now; - const elapsed = fmtDuration(endMs - startMs); - const ago = fmtDuration(now - startMs); + {/* summary strip */} +
+
+
Servers
+
{run.server_runs.length}
+
+
+
Succeeded
+
+ {succeeded} + / {run.server_runs.length} +
+
+
+
Failed
+
0 ? "text-danger" : "text-text-primary"}`}>{failed}
+
+
+
Steps done
+
+ {doneSteps} + / {totalSteps} +
+
+
- return ( -
- {/* identity bar */} -
-
-
- Workflows / {run.name} / Runs -
-

- {run.name} -

-
- - run {run.run_id.slice(0, 8)} - - - - triggered by{" "} - {run.triggered_by || "—"} - - - - started {ago} ago - - - - elapsed {elapsed} - -
+ {run.server_runs.length === 0 ? ( +

No servers targeted by this run.

+ ) : ( + <> + Execution matrix + + + {selectedServer && ( + <> + {selectedServer.hostname} · steps & live output +
+ + +
+ + )} + + )}
-
- - {running && ( - - )} -
-
- - {/* summary strip */} -
-
-
- Servers -
-
- {run.server_runs.length} -
-
-
-
- Succeeded -
-
- {succeeded} - - {" "} - / {run.server_runs.length} - -
-
-
-
- Failed -
-
0 ? "text-danger" : "text-text-primary" - }`} - > - {failed} -
-
-
-
- Steps done -
-
- {doneSteps} - / {totalSteps} -
-
-
- - {run.server_runs.length === 0 ? ( -

No servers targeted by this run.

- ) : ( - <> - Execution matrix - - - {selectedServer && ( - <> - - {selectedServer.hostname} · steps & live output - -
- - -
- - )} - - )} -
- ); + ); }