This commit is contained in:
@@ -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} · steps & 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} · steps & 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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user