feat: Updated workflow runs page
Server Deploy / deploy (push) Successful in 1m20s

This commit is contained in:
2026-07-20 16:00:41 +01:00
parent 39348c9491
commit 397016ad68
11 changed files with 456 additions and 5786 deletions
+19
View File
@@ -36,3 +36,22 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #3e4160;
}
@keyframes led-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.led-pulse { animation: led-pulse 1.4s ease-in-out infinite; }
@keyframes cell-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
50% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 0); }
}
.cell-ring { animation: cell-ring 1.4s ease-in-out infinite; }
@keyframes caret-blink { 50% { opacity: 0; } }
.caret-blink { animation: caret-blink 1s step-end infinite; }
@media (prefers-reduced-motion: reduce) {
.led-pulse, .cell-ring, .caret-blink { animation: none; }
}
+436 -77
View File
@@ -1,40 +1,125 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ServerRun, StepRun } from "@/lib/api";
import { Button, Badge, Card } from "@/components/ui";
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
import { Button } from "@/components/ui";
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
// ---- status vocabulary ----------------------------------------------------
const statusVariant: Record<string, BadgeVariant> = {
success: "success",
failed: "danger",
running: "accent",
queued: "neutral",
skipped: "neutral",
cancelled: "warning",
};
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
function StatusBadge({ status }: { status: string }) {
return <Badge variant={statusVariant[status] ?? "neutral"}>{status}</Badge>;
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
}
}
function ServerLog({
const cellGlyph: Record<CellKind, string> = {
done: "✓",
fail: "✕",
run: "●",
wait: "○",
skip: "",
warn: "!",
};
const cellClass: Record<CellKind, string> = {
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 ------------------------------------------------
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";
}
const pillClass: Record<PillKind, string> = {
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<PillKind, string> = {
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 (
<span
className={`inline-flex items-center gap-2 rounded-full border font-mono font-semibold uppercase tracking-wide ${
small ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"
} ${pillClass[kind]}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${pillLed[kind]}`} />
{status}
</span>
);
}
// ---- 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`;
}
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);
}
// ---- live log terminal ----------------------------------------------------
function LogTerminal({
runId,
serverId,
status,
server,
}: {
runId: string;
serverId: string;
status: string;
server: ServerRun;
}) {
const [text, setText] = useState("");
const preRef = useRef<HTMLPreElement>(null);
const running = status === "running";
const preRef = useRef<HTMLDivElement>(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,
@@ -44,7 +129,6 @@ function ServerLog({
es.onerror = () => es.close();
return () => es.close();
}
// terminal: fetch the whole file once
api
.getServerRunLog(runId, serverId)
.then(setText)
@@ -55,19 +139,203 @@ function ServerLog({
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);
return (
<pre
ref={preRef}
className="mt-3 max-h-80 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 font-mono text-xs text-text-secondary"
>
{text || (running ? "Waiting for output…" : "No output.")}
</pre>
<div className="overflow-hidden rounded-xl border border-border bg-[#0a0b10]">
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface px-4 py-3">
<span className="truncate font-mono text-[13px] font-semibold text-text-primary">
{activeStep ? activeStep.name : "Output"}{" "}
<span className="font-normal text-text-secondary">{server.hostname}</span>
</span>
{running && (
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-wide text-accent">
<span className="h-1.5 w-1.5 rounded-full bg-accent led-pulse" />
Streaming
</span>
)}
</div>
<div
ref={preRef}
className="max-h-[340px] overflow-auto whitespace-pre-wrap px-4 py-3.5 font-mono text-[12.5px] leading-relaxed text-text-secondary"
>
{text || (running ? "Waiting for output…" : "No output.")}
{running && text && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] translate-y-[2px] bg-accent caret-blink align-baseline" />
)}
</div>
</div>
);
}
// ---- step list ------------------------------------------------------------
function StepList({
server,
now,
}: {
server: ServerRun;
now: number;
}) {
const running = server.status === "running";
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<span className="font-mono text-[13px] font-semibold text-text-primary">
Steps <span className="font-normal text-text-secondary">{server.steps.length}</span>
</span>
<StatusPill status={server.status} small />
</div>
<div className="flex flex-col gap-0.5 p-1.5">
{server.steps.map((st) => {
const kind = cellKind(st.status);
return (
<div
key={st.order}
className={`grid grid-cols-[20px_1fr_auto] items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] hover:bg-surface-2 ${
st.status === "running" ? "bg-accent/[0.06]" : ""
}`}
>
<span className="text-right font-mono text-[11px] text-text-secondary">
{String(st.order + 1).padStart(2, "0")}
</span>
<span className="flex items-center gap-2 font-medium text-text-primary">
<span className={`font-mono ${cellClass[kind].replace(/bg-\S+/, "")}`}>
{cellGlyph[kind]}
</span>
{st.name}
</span>
<span className="text-right font-mono text-[10.5px] text-text-secondary">
{st.status === "failed" && (
<span className="text-danger">exit {st.exit_code} · </span>
)}
{st.attempts > 1 ? `${st.attempts} tries` : "1 try"}
{stepDuration(st, running, now) ? ` · ${stepDuration(st, running, now)}` : ""}
</span>
</div>
);
})}
{server.steps.length === 0 && (
<p className="px-3 py-2 text-xs text-text-secondary">No steps yet.</p>
)}
</div>
</div>
);
}
// ---- execution matrix (signature) -----------------------------------------
interface Column {
order: number;
name: string;
}
function buildColumns(run: WorkflowRun): Column[] {
const byOrder = new Map<number, string>();
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);
}
function ExecutionMatrix({
run,
columns,
selected,
onSelect,
}: {
run: WorkflowRun;
columns: Column[];
selected: string;
onSelect: (serverId: string) => void;
}) {
return (
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
<table className="w-full border-collapse font-mono text-[12.5px]">
<thead>
<tr>
<th className="border-b border-border px-4 py-3 text-left align-bottom text-xs font-semibold uppercase tracking-wider text-text-primary">
Server
</th>
{columns.map((c) => (
<th
key={c.order}
className="whitespace-nowrap border-b border-border px-3.5 py-3 align-bottom text-[11px] font-medium text-text-secondary"
>
<span className="block text-[10px] text-border">
{String(c.order + 1).padStart(2, "0")}
</span>
{c.name}
</th>
))}
</tr>
</thead>
<tbody>
{run.server_runs.map((sr) => {
const byOrder = new Map(sr.steps.map((s) => [s.order, s]));
const isSel = sr.server_id === selected;
return (
<tr
key={sr.server_id}
onClick={() => onSelect(sr.server_id)}
className={`cursor-pointer ${isSel ? "bg-accent/5" : "hover:bg-white/[0.02]"}`}
>
<th className="whitespace-nowrap border-b border-r border-border px-4 py-3 text-left font-medium text-text-primary">
{sr.hostname}
<StatusPill status={sr.status} small />
</th>
{columns.map((c) => {
const st = byOrder.get(c.order);
const kind = st ? cellKind(st.status) : "wait";
return (
<td
key={c.order}
className="relative border-b border-r border-border last:border-r-0"
>
<span className="flex h-[54px] items-center justify-center">
<span
className={`relative flex h-[26px] w-[26px] items-center justify-center rounded-md ${cellClass[kind]}`}
>
{kind === "run" && (
<span className="absolute inset-0 rounded-md border border-accent/50 cell-ring" />
)}
{cellGlyph[kind]}
</span>
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ---- page -----------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3 mt-8 flex items-center gap-2.5 font-mono text-[11px] uppercase tracking-widest text-text-secondary">
{children}
<span className="h-px flex-1 bg-border" />
</div>
);
}
export default function RunDetail() {
const { runId } = useParams<{ runId: string }>();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const { data: run, isLoading } = useQuery({
queryKey: ["run", runId],
@@ -75,6 +343,27 @@ export default function RunDetail() {
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
});
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]);
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;
}
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] });
@@ -84,61 +373,131 @@ export default function RunDetail() {
return <div className="p-8 text-text-secondary">Loading</div>;
}
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;
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);
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="mx-auto max-w-[1180px] p-8 pb-16">
{/* identity bar */}
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
<h1 className="text-2xl font-bold text-text-primary">{run.name}</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-text-secondary">
<span>Run {run.run_id.slice(0, 8)}</span>
<StatusBadge status={run.status} />
</p>
<div className="mb-2 font-mono text-xs uppercase tracking-wide text-text-secondary">
Workflows / {run.name} / Runs
</div>
<h1 className="text-[28px] font-semibold tracking-tight text-text-primary">
{run.name}
</h1>
<div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-[12.5px] text-text-secondary">
<span>
run <b className="font-medium text-text-primary">{run.run_id.slice(0, 8)}</b>
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
triggered by{" "}
<b className="font-medium text-text-primary">{run.triggered_by || "—"}</b>
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
started <b className="font-medium text-text-primary">{ago}</b> ago
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
elapsed <b className="font-medium text-text-primary">{elapsed}</b>
</span>
</div>
</div>
<div className="flex items-center gap-3.5">
<StatusPill status={run.status} />
{running && (
<Button variant="danger" onClick={cancel}>
Cancel run
</Button>
)}
</div>
{run.status === "running" && (
<Button variant="danger" onClick={cancel}>
Cancel
</Button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{run.server_runs.map((sr: ServerRun) => (
<Card key={sr.server_id}>
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-text-primary">{sr.hostname}</span>
<StatusBadge status={sr.status} />
</div>
<div className="space-y-2">
{sr.steps.map((st: StepRun) => (
<div
key={st.order}
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-surface-2 p-2"
>
<span className="text-sm text-text-primary">{st.name}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-text-secondary">
attempts: {st.attempts}
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
</span>
<StatusBadge status={st.status} />
</span>
</div>
))}
{sr.steps.length === 0 && (
<p className="text-xs text-text-secondary">No steps yet.</p>
)}
</div>
<ServerLog
runId={run.run_id}
serverId={sr.server_id}
status={sr.status}
/>
</Card>
))}
{run.server_runs.length === 0 && (
<p className="text-text-secondary">No servers targeted by this run.</p>
)}
{/* summary strip */}
<div className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-4">
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Servers
</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">
{run.server_runs.length}
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Succeeded
</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-success">
{succeeded}
<small className="text-sm font-medium text-text-secondary">
{" "}
/ {run.server_runs.length}
</small>
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Failed
</div>
<div
className={`mt-1 font-mono text-[22px] font-semibold tabular-nums ${
failed > 0 ? "text-danger" : "text-text-primary"
}`}
>
{failed}
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Steps done
</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">
{doneSteps}
<small className="text-sm font-medium text-text-secondary"> / {totalSteps}</small>
</div>
</div>
</div>
{run.server_runs.length === 0 ? (
<p className="mt-8 text-text-secondary">No servers targeted by this run.</p>
) : (
<>
<SectionLabel>Execution matrix</SectionLabel>
<ExecutionMatrix
run={run}
columns={columns}
selected={selectedServer?.server_id ?? ""}
onSelect={setSelected}
/>
{selectedServer && (
<>
<SectionLabel>
{selectedServer.hostname}&nbsp;·&nbsp;steps &amp; live output
</SectionLabel>
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-[320px_1fr]">
<StepList server={selectedServer} now={now} />
<LogTerminal runId={run.run_id} server={selectedServer} />
</div>
</>
)}
</>
)}
</div>
);
}
File diff suppressed because one or more lines are too long