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) => (
))}
{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}
);
}