import { useMemo, type ReactNode } from "react"; import { Link } from "react-router"; import { StageState, type RunStage } from "@qltysh/fabro-api-client"; import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui"; import { isVisibleStage } from "../data/runs"; import { formatAbsoluteTs, formatDurationMs } from "../lib/format"; import { formatStageLabel, stageStatusLabel, stageStatusTone, } from "../lib/stage-sidebar"; import { deriveRunPhases, type RunPhase } from "../lib/run-phases"; import { useTickingNow } from "../lib/time"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; interface WaterfallProps { runId: string; events: EventEnvelope[]; stages: RunStage[]; createdAtIso: string; completedAtIso: string | null; } interface Row { key: string; kind: "phase" | "stage"; label: string; startMs: number; endMs: number | null; durationMs: number | null; barClass: string; href: string | null; popover: ReactNode; } const MIN_BAR_WIDTH_PCT = 0.4; function stageBarClass(status: StageState): string { switch (status) { case StageState.RUNNING: case StageState.RETRYING: return "bg-teal-500 animate-pulse"; case StageState.SUCCEEDED: return "bg-mint"; case StageState.PARTIALLY_SUCCEEDED: return "bg-amber"; case StageState.FAILED: return "bg-coral"; case StageState.PENDING: return "bg-overlay-strong"; case StageState.SKIPPED: case StageState.CANCELLED: return "bg-fg-muted/40"; } } function isStageInFlight(status: StageState): boolean { return status === StageState.RUNNING || status === StageState.RETRYING; } function chooseTickIntervalMs(rangeMs: number): number { if (rangeMs < 30_000) return 5_000; if (rangeMs < 120_000) return 15_000; if (rangeMs < 600_000) return 60_000; if (rangeMs < 3_600_000) return 5 * 60_000; if (rangeMs < 6 * 3_600_000) return 30 * 60_000; return 60 * 60_000; } function phasePopover(phase: RunPhase, durationMs: number | null, inFlight: boolean): ReactNode { return ( <> {phase.label} {formatAbsoluteTs(new Date(phase.startMs).toISOString())} {durationMs != null ? formatDurationMs(durationMs) : "--"} ); } function phaseRow(phase: RunPhase, nowMs: number): Row { const endMs = phase.endMs; const inFlight = endMs == null; const closedEnd = endMs ?? nowMs; const rawDuration = closedEnd - phase.startMs; const durationMs = rawDuration >= 0 ? rawDuration : null; return { key: `phase:${phase.kind}`, kind: "phase", label: phase.label, startMs: phase.startMs, endMs, durationMs, barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", href: null, popover: phasePopover(phase, durationMs, inFlight), }; } function stagePopover( stage: RunStage, durationMs: number | null, inFlight: boolean, ): ReactNode { return ( <> {formatStageLabel(stage)} {stageStatusLabel(stage.status)} {stage.started_at && ( {formatAbsoluteTs(stage.started_at)} )} {durationMs != null ? formatDurationMs(durationMs) : "--"} ); } function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { if (!stage.started_at) return null; const startMs = Date.parse(stage.started_at); if (Number.isNaN(startMs)) return null; const inFlight = isStageInFlight(stage.status); const wallMs = stage.wall_time_ms ?? null; const endMs = inFlight ? null : wallMs != null ? startMs + wallMs : null; const durationMs = inFlight ? nowMs - startMs : wallMs; return { key: `stage:${stage.id}`, kind: "stage", label: formatStageLabel(stage), startMs, endMs, durationMs, barClass: stageBarClass(stage.status), href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`, popover: stagePopover(stage, durationMs, inFlight), }; } function buildRows({ runId, events, stages, createdAtIso, nowMs, }: { runId: string; events: EventEnvelope[]; stages: RunStage[]; createdAtIso: string; nowMs: number; }): Row[] { const phases = deriveRunPhases(events, createdAtIso).map((p) => phaseRow(p, nowMs)); const stageRows: Row[] = []; for (const stage of stages) { if (!isVisibleStage(stage.node_id)) continue; const row = stageRow(runId, stage, nowMs); if (row) stageRows.push(row); } stageRows.sort((a, b) => a.startMs - b.startMs); return [...phases, ...stageRows]; } export function RunWaterfall({ runId, events, stages, createdAtIso, completedAtIso, }: WaterfallProps) { const nowMs = useTickingNow(true, 1000); const rows = useMemo( () => buildRows({ runId, events, stages, createdAtIso, nowMs }), [runId, events, stages, createdAtIso, nowMs], ); const createdMs = Date.parse(createdAtIso); const completedMs = completedAtIso ? Date.parse(completedAtIso) : null; const hasInFlight = rows.some((r) => r.endMs == null); const lastRowEnd = rows.reduce( (max, r) => Math.max(max, r.endMs ?? r.startMs + (r.durationMs ?? 0)), createdMs, ); const timelineStartMs = createdMs; const timelineEndMs = Math.max( timelineStartMs + 1_000, completedMs ?? (hasInFlight ? nowMs : lastRowEnd), ); const rangeMs = timelineEndMs - timelineStartMs; const ticks = useMemo(() => { const interval = chooseTickIntervalMs(rangeMs); const out: { offsetMs: number; pct: number; label: string }[] = []; for (let t = 0; t <= rangeMs + 1; t += interval) { out.push({ offsetMs: t, pct: (t / rangeMs) * 100, label: t === 0 ? "0s" : formatDurationMs(t), }); } return out; }, [rangeMs]); if (rows.length === 0) { return (
Waterfall will populate as the run progresses.
); } return (
{ticks.map((tick) => (
{tick.label}
))}
{rows.map((row) => { const startPct = ((row.startMs - timelineStartMs) / rangeMs) * 100; const closedEnd = row.endMs ?? timelineEndMs; const rawWidthPct = ((closedEnd - row.startMs) / rangeMs) * 100; const widthPct = Math.max(MIN_BAR_WIDTH_PCT, rawWidthPct); const durationLabel = row.durationMs != null ? formatDurationMs(row.durationMs) : ""; return ( ); })}
); } function WaterfallRow({ row, startPct, widthPct, durationLabel, }: { row: Row; startPct: number; widthPct: number; durationLabel: string; }) { const labelClass = row.kind === "phase" ? "text-fg-muted" : "text-fg-2"; const inner = (
{row.label}
{durationLabel}
); const trigger = row.href ? ( {inner} ) : ( inner ); return ( {trigger} ); }