fix: Fixed style on workflow run
Server Deploy / deploy (push) Successful in 42s

This commit is contained in:
2026-07-20 16:44:53 +01:00
parent 397016ad68
commit 82d7dde5f8
+319 -414
View File
@@ -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<CellKind, string> = {
done: "✓",
fail: "✕",
run: "●",
wait: "○",
skip: "",
warn: "!",
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",
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<CellKind, string> = {
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<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",
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",
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>
);
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`;
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<HTMLDivElement>(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<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,
});
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 (
<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>
);
return (
<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]}
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>
{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>
<StatusPill status={server.status} small />
</div>
);
})}
{server.steps.length === 0 && (
<p className="px-3 py-2 text-xs text-text-secondary">No steps yet.</p>
)}
</div>
</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;
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);
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);
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>
);
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="min-w-[240px] border-b border-r border-border px-4 py-3 text-left font-medium text-text-primary">
<div className="flex items-center gap-3">
<span className="flex-1 whitespace-nowrap">{sr.hostname}</span>
<StatusPill status={sr.status} small />
</div>
</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>
);
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 { 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],
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 <div className="p-8 text-text-secondary">Loading</div>;
}
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 <div className="p-8 text-text-secondary">Loading</div>;
}
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 (
<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>
<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>
</div>
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 */}
<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>
return (
<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>
<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>
{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>
<div className="flex items-center gap-3.5">
<StatusPill status={run.status} />
{running && (
<Button variant="danger" onClick={cancel}>
Cancel run
</Button>
)}
</div>
</div>
{/* 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>
);
);
}