From 7666bdc2a4388995fb26f788bcdaf07649082eb2 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 14 Apr 2026 02:37:27 +0200 Subject: [PATCH 1/2] Add run treemap visualization --- apps/api/src/app.integration.test.ts | 117 ++++++++++ apps/api/src/routes/public.ts | 12 + apps/web/package.json | 1 + apps/web/src/App.tsx | 4 +- apps/web/src/components/RunTreemap.tsx | 256 ++++++++++++++++++++++ apps/web/src/hooks/use-run-detail-data.ts | 10 +- apps/web/src/lib/format.ts | 26 ++- apps/web/src/pages/RunDetailPage.tsx | 30 ++- apps/web/src/pages/RunsPage.tsx | 2 +- apps/web/src/pages/StepDetailPage.tsx | 43 +++- apps/web/src/styles.css | 159 ++++++++++++++ package.json | 1 + packages/contracts/src/index.ts | 41 ++++ packages/db/src/migrations.ts | 8 + packages/db/src/run-reads.ts | 179 +++++++++++++++ packages/db/src/run-writes.ts | 48 +++- packages/db/src/shared.ts | 20 ++ pnpm-lock.yaml | 17 ++ 18 files changed, 947 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/RunTreemap.tsx diff --git a/apps/api/src/app.integration.test.ts b/apps/api/src/app.integration.test.ts index 5aa2c04..8c5c19c 100644 --- a/apps/api/src/app.integration.test.ts +++ b/apps/api/src/app.integration.test.ts @@ -96,6 +96,123 @@ describe.runIf(runIntegration)("api integration", () => { }); expect(stepResponse.statusCode).toBe(404); expect(stepResponse.json()).toMatchObject({ message: "Step not found" }); + + const treemapResponse = await app.inject({ + method: "GET", + url: "/runs/00000000-0000-0000-0000-000000000001/treemap", + }); + expect(treemapResponse.statusCode).toBe(404); + expect(treemapResponse.json()).toMatchObject({ message: "Run not found" }); + }, 30_000); + + it("returns a run treemap with step, file, and process nodes", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/runs/manual", + payload: { + repositorySlug: "verge", + commitSha: "treemap-sha", + requestedStepKeys: ["test"], + disableReuse: true, + }, + }); + + expect(createResponse.statusCode).toBe(200); + const createPayload = createResponse.json() as { + runId: string; + stepRunIds: string[]; + }; + + const initialTreemapResponse = await app.inject({ + method: "GET", + url: `/runs/${createPayload.runId}/treemap`, + }); + expect(initialTreemapResponse.statusCode).toBe(200); + const initialTreemap = initialTreemapResponse.json() as { + runId: string; + tree: { + kind: string; + children?: Array<{ + kind: string; + stepKey?: string | null; + children?: Array<{ kind: string; children?: Array<{ kind: string }> }>; + }>; + }; + }; + + expect(initialTreemap.runId).toBe(createPayload.runId); + expect(initialTreemap.tree.kind).toBe("run"); + const testStepNode = initialTreemap.tree.children?.find((node) => node.stepKey === "test"); + expect(testStepNode?.kind).toBe("step"); + expect(testStepNode?.children?.some((child) => child.kind === "file")).toBe(true); + expect( + testStepNode?.children?.some( + (child) => + child.kind === "file" && + child.children?.some((grandchild) => grandchild.kind === "process"), + ), + ).toBe(true); + + const claimResponse = await app.inject({ + method: "POST", + url: "/workers/claim", + payload: { + workerId: "treemap-worker", + }, + }); + expect(claimResponse.statusCode).toBe(200); + const assignment = claimResponse.json() as { + assignment: { + stepRunId: string; + processRunId: string; + processKey: string; + } | null; + }; + expect(assignment.assignment?.stepRunId).toBe(createPayload.stepRunIds[0]); + + await app.inject({ + method: "POST", + url: `/workers/steps/${assignment.assignment?.stepRunId}/events`, + payload: { + workerId: "treemap-worker", + processRunId: assignment.assignment?.processRunId, + kind: "started", + message: "Started treemap process", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 15)); + + await app.inject({ + method: "POST", + url: `/workers/steps/${assignment.assignment?.stepRunId}/events`, + payload: { + workerId: "treemap-worker", + processRunId: assignment.assignment?.processRunId, + kind: "passed", + message: "Completed treemap process", + }, + }); + + const finalTreemapResponse = await app.inject({ + method: "GET", + url: `/runs/${createPayload.runId}/treemap`, + }); + expect(finalTreemapResponse.statusCode).toBe(200); + const finalTreemap = finalTreemapResponse.json() as { + tree: { + children?: Array<{ + children?: Array<{ children?: Array<{ processKey?: string | null; valueMs?: number }> }>; + }>; + }; + }; + + const processNode = finalTreemap.tree.children + ?.flatMap((child) => child.children ?? []) + .flatMap((child) => child.children ?? []) + .find((node) => node.processKey === assignment.assignment?.processKey); + + expect(processNode?.valueMs).toBeGreaterThan(0); }, 30_000); it("ingests GitHub webhooks idempotently and exposes pull request detail", async () => { diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index a02776a..e466694 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -8,6 +8,7 @@ import { getRepositoryBySlug, getRepositoryHealth, getRunDetail, + getRunTreemap, getStepRunDetail, listRepositories, listRepositoryRuns, @@ -57,6 +58,17 @@ export const registerPublicRoutes = (app: FastifyInstance, context: ApiContext): return detail; }); + app.get("/runs/:id/treemap", async (request, reply) => { + const treemap = await getRunTreemap( + context.connection.db, + (request.params as { id: string }).id, + ); + if (!treemap) { + return reply.code(404).send({ message: "Run not found" }); + } + return treemap; + }); + app.get("/runs/:runId/steps/:stepId", async (request, reply) => { const { runId, stepId } = request.params as { runId: string; stepId: string }; const detail = await getStepRunDetail(context.connection.db, stepId); diff --git a/apps/web/package.json b/apps/web/package.json index 3d0b82b..38a412e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@verge/contracts": "workspace:*", + "d3-hierarchy": "^3.1.2", "react": "^19.1.1", "react-dom": "^19.1.1" } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0975460..2d2054a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -31,7 +31,7 @@ export const App = () => { const currentRepositorySlug = route.repositorySlug ?? preferredRepositorySlug; const { health, processSpecs, error: overviewError } = useOverviewData(currentRepositorySlug); const { runsPage, error: runsError } = useRunsPageData(route, currentRepositorySlug); - const { run, step, error: runError } = useRunDetailData(route); + const { run, treemap, step, error: runError } = useRunDetailData(route); const [commitSha, setCommitSha] = useState(""); const [branch, setBranch] = useState("main"); @@ -319,7 +319,7 @@ export const App = () => { /> ) : null} - {route.name === "run" ? : null} + {route.name === "run" ? : null} {route.name === "step" ? : null} ); diff --git a/apps/web/src/components/RunTreemap.tsx b/apps/web/src/components/RunTreemap.tsx new file mode 100644 index 0000000..1038362 --- /dev/null +++ b/apps/web/src/components/RunTreemap.tsx @@ -0,0 +1,256 @@ +import { + hierarchy, + treemap, + type HierarchyNode, + type HierarchyRectangularNode, +} from "d3-hierarchy"; +import { useState } from "react"; + +import type { RunTreemap, TreemapNode } from "@verge/contracts"; + +import { EmptyState, StatusPill } from "./common.js"; +import { formatDurationMs, statusTone } from "../lib/format.js"; +import { buildStepPath, navigate } from "../lib/routing.js"; + +const treemapWidth = 1200; +const treemapHeight = 520; +type TreemapLayoutNode = HierarchyRectangularNode; + +const nodePaddingTop = (depth: number): number => { + if (depth === 1) { + return 28; + } + + if (depth === 2) { + return 22; + } + + return 0; +}; + +const shouldShowLabel = (node: TreemapLayoutNode): boolean => { + const width = node.x1 - node.x0; + const height = node.y1 - node.y0; + + if (node.data.kind === "step") { + return width >= 120 && height >= 48; + } + + if (node.data.kind === "file") { + return width >= 100 && height >= 42; + } + + return width >= 124 && height >= 48; +}; + +const buildProcessTargetPath = (input: { + repositorySlug: string; + runId: string; + stepId: string | null; + processId: string; +}): string | null => { + if (!input.stepId) { + return null; + } + + return `${buildStepPath(input.repositorySlug, input.runId, input.stepId)}#process-${input.processId}`; +}; + +const buildTreemapLayout = (tree: TreemapNode): TreemapLayoutNode => { + const root = hierarchy(tree) + .sum((node: TreemapNode) => node.valueMs) + .sort( + (left: HierarchyNode, right: HierarchyNode) => + (right.value ?? 0) - (left.value ?? 0), + ); + + return treemap() + .size([treemapWidth, treemapHeight]) + .paddingOuter(8) + .paddingInner(4) + .paddingTop((node: TreemapLayoutNode) => (node.depth > 0 ? nodePaddingTop(node.depth) : 0))( + root, + ); +}; + +export const RunTreemapView = ({ + runId, + repositorySlug, + treemapData, +}: { + runId: string; + repositorySlug: string; + treemapData: RunTreemap | null; +}) => { + const [hoveredNode, setHoveredNode] = useState<{ + node: TreemapNode; + x: number; + y: number; + } | null>(null); + + if (!treemapData) { + return ( + + ); + } + + if (!treemapData.tree.children?.length || treemapData.tree.valueMs === 0) { + return ( + + ); + } + + const root = buildTreemapLayout(treemapData.tree); + const renderedNodes = root.descendants().filter((node: TreemapLayoutNode) => node.depth > 0); + + return ( +
+
+ {["passed", "failed", "reused", "running", "queued", "interrupted"].map((status) => ( + + + {status} + + ))} +
+
+ + {renderedNodes.map((node) => { + const width = node.x1 - node.x0; + const height = node.y1 - node.y0; + const targetPath = + node.data.kind === "step" + ? buildStepPath(repositorySlug, runId, node.data.id) + : node.data.kind === "process" + ? buildProcessTargetPath({ + repositorySlug, + runId, + stepId: + node.parent?.data.kind === "step" + ? node.parent.data.id + : node.parent?.parent?.data.kind === "step" + ? node.parent.parent.data.id + : null, + processId: node.data.id, + }) + : node.parent?.data.kind === "step" + ? buildStepPath(repositorySlug, runId, node.parent.data.id) + : null; + + return ( + + { + if (targetPath) { + navigate(targetPath); + } + }} + onMouseEnter={(event) => { + setHoveredNode({ + node: node.data, + x: event.clientX, + y: event.clientY, + }); + }} + onMouseLeave={() => setHoveredNode(null)} + onMouseMove={(event) => { + setHoveredNode((current) => + current + ? { + ...current, + x: event.clientX, + y: event.clientY, + } + : current, + ); + }} + /> + {shouldShowLabel(node) ? ( + + {node.data.label} + + ) : null} + {shouldShowLabel(node) && node.data.kind !== "step" ? ( + + {formatDurationMs(node.data.valueMs)} + + ) : null} + {shouldShowLabel(node) && node.data.kind === "step" ? ( + + {formatDurationMs(node.data.valueMs)} process time + + ) : null} + + ); + })} + + {hoveredNode ? ( +
+
+ {hoveredNode.node.label} + +
+
+ Process time + {formatDurationMs(hoveredNode.node.valueMs)} + Wall time + {formatDurationMs(hoveredNode.node.wallDurationMs)} + Kind + {hoveredNode.node.kind} + {hoveredNode.node.filePath ? ( + <> + File + {hoveredNode.node.filePath} + + ) : null} + {hoveredNode.node.processKey ? ( + <> + Process key + {hoveredNode.node.processKey} + + ) : null} + {hoveredNode.node.attemptCount !== null ? ( + <> + Attempts + {hoveredNode.node.attemptCount} + + ) : null} + {hoveredNode.node.reused ? ( + <> + Execution + reused + + ) : null} +
+
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/src/hooks/use-run-detail-data.ts b/apps/web/src/hooks/use-run-detail-data.ts index d016874..00352ab 100644 --- a/apps/web/src/hooks/use-run-detail-data.ts +++ b/apps/web/src/hooks/use-run-detail-data.ts @@ -1,24 +1,27 @@ import { useEffect, useState } from "react"; -import type { RunDetail, StepRunDetail } from "@verge/contracts"; +import type { RunDetail, RunTreemap, StepRunDetail } from "@verge/contracts"; import { describeLoadError, fetchJson } from "../lib/api.js"; import type { AppRoute } from "../lib/routing.js"; export const useRunDetailData = (route: AppRoute) => { const [run, setRun] = useState(null); + const [treemap, setTreemap] = useState(null); const [step, setStep] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (route.name !== "run" && route.name !== "step") { setRun(null); + setTreemap(null); setStep(null); setError(null); return; } setRun(null); + setTreemap(null); setStep(null); const refresh = async (): Promise => { @@ -30,6 +33,9 @@ export const useRunDetailData = (route: AppRoute) => { `/runs/${route.runId}/steps/${route.stepId}`, ); setStep(nextStep); + } else { + const nextTreemap = await fetchJson(`/runs/${route.runId}/treemap`); + setTreemap(nextTreemap); } setError(null); } catch (nextError) { @@ -44,5 +50,5 @@ export const useRunDetailData = (route: AppRoute) => { return () => window.clearInterval(interval); }, [route]); - return { run, step, error }; + return { run, treemap, step, error }; }; diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 28c3103..b64e11f 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -23,14 +23,12 @@ export const formatRelativeTime = (value: string): string => { return `${diffDays}d ago`; }; -export const formatDuration = (startedAt: string | null, finishedAt: string | null): string => { - if (!startedAt) { +export const formatDurationMs = (durationMs: number | null): string => { + if (durationMs === null) { return "Pending"; } - const start = new Date(startedAt).getTime(); - const end = finishedAt ? new Date(finishedAt).getTime() : Date.now(); - const diffSeconds = Math.max(0, Math.round((end - start) / 1000)); + const diffSeconds = Math.max(0, Math.round(durationMs / 1000)); if (diffSeconds < 60) { return `${diffSeconds}s`; @@ -47,6 +45,24 @@ export const formatDuration = (startedAt: string | null, finishedAt: string | nu return `${hours}h ${remainingMinutes}m`; }; +export const formatDuration = ( + startedAt: string | null, + finishedAt: string | null, + durationMs?: number | null, +): string => { + if (durationMs !== undefined) { + return formatDurationMs(durationMs); + } + + if (!startedAt) { + return "Pending"; + } + + const start = new Date(startedAt).getTime(); + const end = finishedAt ? new Date(finishedAt).getTime() : Date.now(); + return formatDurationMs(Math.max(0, end - start)); +}; + const normalizeLabel = (value: string): string => value.trim().toLowerCase(); export const shouldShowSecondaryKey = (displayName: string, key: string): boolean => diff --git a/apps/web/src/pages/RunDetailPage.tsx b/apps/web/src/pages/RunDetailPage.tsx index b71b9d0..320c0b3 100644 --- a/apps/web/src/pages/RunDetailPage.tsx +++ b/apps/web/src/pages/RunDetailPage.tsx @@ -1,6 +1,7 @@ -import type { RunDetail } from "@verge/contracts"; +import type { RunDetail, RunTreemap } from "@verge/contracts"; import { EmptyState, StatusPill } from "../components/common.js"; +import { RunTreemapView } from "../components/RunTreemap.js"; import { classifyStepExecutionMode, formatDateTime, @@ -10,7 +11,15 @@ import { } from "../lib/format.js"; import { buildStepPath, navigate } from "../lib/routing.js"; -export const RunDetailPage = ({ run, error }: { run: RunDetail | null; error: string | null }) => { +export const RunDetailPage = ({ + run, + treemap, + error, +}: { + run: RunDetail | null; + treemap: RunTreemap | null; + error: string | null; +}) => { if (!run) { return (
Duration - {formatDuration(run.startedAt, run.finishedAt)} + {formatDuration(run.startedAt, run.finishedAt, run.durationMs)} {run.startedAt ? `${formatDateTime(run.startedAt)} to ${formatDateTime(run.finishedAt)}` @@ -66,6 +75,19 @@ export const RunDetailPage = ({ run, error }: { run: RunDetail | null; error: st
+
+
+
+

Duration map

+

+ Area shows accumulated process time for this run. Color shows the final status for + each node. +

+
+
+ +
+

Steps

@@ -101,7 +123,7 @@ export const RunDetailPage = ({ run, error }: { run: RunDetail | null; error: st {step.processCount} {formatDateTime(step.startedAt)} {formatDateTime(step.finishedAt)} - {formatDuration(step.startedAt, step.finishedAt)} + {formatDuration(step.startedAt, step.finishedAt, step.durationMs)} {formatRelativeTime(run.createdAt)} - {formatDuration(run.startedAt, run.finishedAt)} + {formatDuration(run.startedAt, run.finishedAt, run.durationMs)} ))} diff --git a/apps/web/src/pages/StepDetailPage.tsx b/apps/web/src/pages/StepDetailPage.tsx index c5c1aa3..2a92803 100644 --- a/apps/web/src/pages/StepDetailPage.tsx +++ b/apps/web/src/pages/StepDetailPage.tsx @@ -21,12 +21,43 @@ export const StepDetailPage = ({ error: string | null; }) => { const [processQuery, setProcessQuery] = useState(""); + const [locationHash, setLocationHash] = useState(() => window.location.hash); const deferredProcessQuery = useDeferredValue(processQuery); useEffect(() => { setProcessQuery(""); }, [step?.id]); + useEffect(() => { + const syncHash = () => setLocationHash(window.location.hash); + window.addEventListener("hashchange", syncHash); + syncHash(); + + return () => { + window.removeEventListener("hashchange", syncHash); + }; + }, []); + + const highlightedProcessId = locationHash.startsWith("#process-") + ? locationHash.slice("#process-".length) + : null; + + useEffect(() => { + if (!highlightedProcessId) { + return; + } + + const frame = window.requestAnimationFrame(() => { + document + .getElementById(`process-${highlightedProcessId}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [highlightedProcessId, step?.id]); + if (!run || !step) { return ( {visibleProcesses.map((process) => ( - + {process.processDisplayName} @@ -149,7 +186,9 @@ export const StepDetailPage = ({ {process.attemptCount} {formatDateTime(process.startedAt)} {formatDateTime(process.finishedAt)} - {formatDuration(process.startedAt, process.finishedAt)} + + {formatDuration(process.startedAt, process.finishedAt, process.durationMs)} + ))} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 11d1607..2312966 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -464,6 +464,160 @@ table { overflow: hidden; } +.treemapSection { + display: grid; + gap: 16px; +} + +.treemapLegend { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.treemapLegendItem { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fg-muted); + font-size: 12px; + font-weight: 600; +} + +.treemapLegendSwatch { + width: 10px; + height: 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.treemapLegendSwatch.passed { + background: rgba(63, 185, 80, 0.92); +} + +.treemapLegendSwatch.failed { + background: rgba(248, 81, 73, 0.92); +} + +.treemapLegendSwatch.reused { + background: rgba(47, 129, 247, 0.92); +} + +.treemapLegendSwatch.running { + background: rgba(210, 153, 34, 0.92); +} + +.treemapLegendSwatch.queued { + background: rgba(139, 148, 158, 0.82); +} + +.treemapLegendSwatch.interrupted { + background: rgba(210, 153, 34, 0.76); +} + +.treemapWrap { + position: relative; +} + +.treemapSvg { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--border-muted); + border-radius: var(--radius-lg); + background: + linear-gradient(180deg, rgba(22, 27, 34, 0.94), rgba(13, 17, 23, 1)), var(--canvas-default); +} + +.treemapNodeRect { + cursor: pointer; + stroke: rgba(255, 255, 255, 0.08); + stroke-width: 1px; + transition: + fill 140ms ease, + stroke 140ms ease, + opacity 140ms ease; +} + +.treemapNodeRect:hover { + stroke: rgba(240, 246, 252, 0.52); +} + +.treemapNodeRect.status-passed { + fill: rgba(35, 134, 54, 0.88); +} + +.treemapNodeRect.status-failed { + fill: rgba(218, 54, 51, 0.92); +} + +.treemapNodeRect.status-reused { + fill: rgba(31, 111, 235, 0.88); +} + +.treemapNodeRect.status-running { + fill: rgba(154, 103, 0, 0.92); +} + +.treemapNodeRect.status-queued, +.treemapNodeRect.status-planned, +.treemapNodeRect.status-skipped { + fill: rgba(110, 118, 129, 0.62); +} + +.treemapNodeRect.status-interrupted { + fill: rgba(187, 128, 9, 0.88); +} + +.treemapNodeRect.status-muted { + fill: rgba(110, 118, 129, 0.52); +} + +.treemapLabel, +.treemapMeta { + pointer-events: none; + fill: rgba(240, 246, 252, 0.98); +} + +.treemapLabel { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.treemapMeta { + font-size: 11px; + fill: rgba(230, 237, 243, 0.86); +} + +.treemapTooltip { + position: fixed; + z-index: 40; + width: min(380px, calc(100vw - 32px)); + padding: 14px; + border: 1px solid rgba(48, 54, 61, 0.94); + border-radius: var(--radius-md); + background: rgba(13, 17, 23, 0.96); + box-shadow: 0 16px 40px rgba(1, 4, 9, 0.52); + backdrop-filter: blur(14px); +} + +.treemapTooltipHeader { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.treemapTooltipGrid { + display: grid; + grid-template-columns: minmax(0, 110px) minmax(0, 1fr); + gap: 8px 12px; + align-items: start; + font-size: 13px; +} + .panelSection { padding: 0 16px 16px; } @@ -503,6 +657,11 @@ table { border-bottom: 0; } +.dataTable tbody tr:target, +.dataTable tbody tr.processRowHighlighted { + background: rgba(47, 129, 247, 0.18); +} + .clickableRow { cursor: pointer; } diff --git a/package.json b/package.json index 96eb5ed..111c45b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@verge/cli": "workspace:*" }, "devDependencies": { + "@types/d3-hierarchy": "^3.1.7", "@types/node": "^22.17.0", "@types/pg": "^8.15.4", "@types/react": "^19.1.8", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c77f08c..1670dd7 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -251,6 +251,7 @@ export const processRunSummarySchema = z.object({ attemptCount: z.number().int().nonnegative(), startedAt: z.string().datetime().nullable(), finishedAt: z.string().datetime().nullable(), + durationMs: z.number().int().nonnegative().nullable(), }); export const stepRunSummarySchema = z.object({ @@ -266,6 +267,7 @@ export const stepRunSummarySchema = z.object({ createdAt: z.string().datetime(), startedAt: z.string().datetime().nullable(), finishedAt: z.string().datetime().nullable(), + durationMs: z.number().int().nonnegative().nullable(), processCount: z.number().int().nonnegative(), }); @@ -289,6 +291,7 @@ export const runSummarySchema = z.object({ createdAt: z.string().datetime(), startedAt: z.string().datetime().nullable(), finishedAt: z.string().datetime().nullable(), + durationMs: z.number().int().nonnegative().nullable(), steps: z.array(stepRunSummarySchema), }); @@ -356,6 +359,42 @@ export const runDetailSchema = runSummarySchema.extend({ steps: z.array(stepRunSummarySchema), }); +export const treemapNodeKindSchema = z.enum(["run", "step", "file", "process"]); + +export const treemapNodeStatusSchema = z.enum([ + "planned", + "queued", + "running", + "passed", + "failed", + "reused", + "interrupted", + "skipped", +]); + +export const treemapNodeSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string().min(1), + kind: treemapNodeKindSchema, + label: z.string().min(1), + valueMs: z.number().int().nonnegative(), + wallDurationMs: z.number().int().nonnegative().nullable(), + status: treemapNodeStatusSchema, + filePath: z.string().nullable(), + stepKey: z.string().nullable(), + processKey: z.string().nullable(), + reused: z.boolean(), + attemptCount: z.number().int().nonnegative().nullable(), + children: z.array(treemapNodeSchema).optional(), + }), +); + +export const runTreemapSchema = z.object({ + runId: z.string().uuid(), + repositorySlug: z.string(), + tree: treemapNodeSchema, +}); + export const repoAreaStateSchema = z.object({ key: z.string(), displayName: z.string(), @@ -388,6 +427,7 @@ export const pullRequestDetailSchema = z.object({ export type RunStatus = z.infer; export type StepRunStatus = z.infer; export type ProcessRunStatus = z.infer; +export type TreemapNode = z.infer; export type ObservationStatus = z.infer; export type FreshnessBucket = z.infer; export type RunTrigger = z.infer; @@ -415,3 +455,4 @@ export type StepSpecSummary = z.infer; export type RepositoryHealth = z.infer; export type CommitDetail = z.infer; export type PullRequestDetail = z.infer; +export type RunTreemap = z.infer; diff --git a/packages/db/src/migrations.ts b/packages/db/src/migrations.ts index 04df6a4..4ed26b6 100644 --- a/packages/db/src/migrations.ts +++ b/packages/db/src/migrations.ts @@ -397,6 +397,14 @@ export const schemaMigrations: SchemaMigration[] = [ create index if not exists idx_checkpoints_step_fingerprint on checkpoints(step_key, fingerprint, created_at desc); `, }, + { + id: "003_duration_columns", + sql: ` + alter table runs add column if not exists duration_ms integer; + alter table step_runs add column if not exists duration_ms integer; + alter table process_runs add column if not exists duration_ms integer; + `, + }, ]; export const runMigrations = async (db: Kysely): Promise => { diff --git a/packages/db/src/run-reads.ts b/packages/db/src/run-reads.ts index 5e967e8..773f0f2 100644 --- a/packages/db/src/run-reads.ts +++ b/packages/db/src/run-reads.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { type Kysely } from "kysely"; import type { @@ -8,15 +9,19 @@ import type { RunDetail, RunListItem, RunListQuery, + RunTreemap, RunTrigger, StepRunDetail, StepRunSummary, + TreemapNode, } from "@verge/contracts"; import { determineFreshnessBucket } from "@verge/core"; import { + coalesceDurationMs, iso, parseJson, + summarizeStatuses, type CheckpointRow, type ProcessRunRow, type VergeDatabase, @@ -144,6 +149,7 @@ const selectStepRunRows = (db: Kysely, repositorySlug?: string) = "step_runs.created_at as stepCreatedAt", "step_runs.started_at as stepStartedAt", "step_runs.finished_at as stepFinishedAt", + "step_runs.duration_ms as stepDurationMs", "repositories.slug as repositorySlug", "runs.trigger as trigger", "runs.commit_sha as commitSha", @@ -175,6 +181,7 @@ const selectRunRows = (db: Kysely, repositorySlug?: string) => { "runs.created_at as createdAt", "runs.started_at as startedAt", "runs.finished_at as finishedAt", + "runs.duration_ms as durationMs", ]); if (repositorySlug) { @@ -207,10 +214,116 @@ const toStepRunSummary = async ( createdAt: row.stepCreatedAt.toISOString(), startedAt: iso(row.stepStartedAt), finishedAt: iso(row.stepFinishedAt), + durationMs: coalesceDurationMs(row.stepDurationMs, row.stepStartedAt, row.stepFinishedAt), processCount: processRuns.length, }; }; +const readProcessDurationMs = (process: { + duration_ms: number | null; + started_at: Date | null; + finished_at: Date | null; +}): number => { + const liveDurationMs = coalesceDurationMs( + process.duration_ms, + process.started_at, + process.finished_at, + ); + if (liveDurationMs !== null) { + return liveDurationMs; + } + + if (process.started_at) { + return Math.max(0, Date.now() - process.started_at.getTime()); + } + + return 0; +}; + +const normalizeTreemapStatus = (status: string): TreemapNode["status"] => + status === "claimed" ? "running" : (status as TreemapNode["status"]); + +const sumValueMs = (children: TreemapNode[]): number => + children.reduce((total, child) => total + child.valueMs, 0); + +const sortNodesByValue = (nodes: TreemapNode[]): TreemapNode[] => + [...nodes].sort( + (left, right) => right.valueMs - left.valueMs || left.label.localeCompare(right.label), + ); + +const buildProcessTreemapNodes = (processes: ProcessRunRow[]): TreemapNode[] => + processes.map((process) => ({ + id: process.id, + kind: "process", + label: process.display_name, + valueMs: readProcessDurationMs(process), + wallDurationMs: coalesceDurationMs( + process.duration_ms, + process.started_at, + process.finished_at, + ), + status: normalizeTreemapStatus(process.status), + filePath: process.file_path, + stepKey: null, + processKey: process.process_key, + reused: process.status === "reused", + attemptCount: process.attempt_count, + })); + +const shouldGroupProcessesByFile = (processes: ProcessRunRow[]): boolean => { + const filePaths = processes + .map((process) => process.file_path) + .filter((filePath): filePath is string => typeof filePath === "string" && filePath.length > 0); + + if (filePaths.length < 2) { + return false; + } + + return new Set(filePaths).size < filePaths.length; +}; + +const buildStepTreemapChildren = (stepRunId: string, processes: ProcessRunRow[]): TreemapNode[] => { + if (!shouldGroupProcessesByFile(processes)) { + return sortNodesByValue(buildProcessTreemapNodes(processes)); + } + + const processesByFile = new Map(); + const filelessProcesses: ProcessRunRow[] = []; + + for (const process of processes) { + if (!process.file_path) { + filelessProcesses.push(process); + continue; + } + + const existing = processesByFile.get(process.file_path) ?? []; + existing.push(process); + processesByFile.set(process.file_path, existing); + } + + const fileNodes = [...processesByFile.entries()].map(([filePath, fileProcesses]) => { + const children = sortNodesByValue(buildProcessTreemapNodes(fileProcesses)); + return { + id: `file:${stepRunId}:${filePath}`, + kind: "file" as const, + label: path.basename(filePath), + valueMs: sumValueMs(children), + wallDurationMs: null, + status: normalizeTreemapStatus( + summarizeStatuses(fileProcesses.map((process) => process.status)), + ), + filePath, + stepKey: null, + processKey: null, + reused: fileProcesses.every((process) => process.status === "reused"), + attemptCount: null, + children, + }; + }); + + return sortNodesByValue([...fileNodes, ...buildProcessTreemapNodes(filelessProcesses)]); +}; + export const getStepRunDetail = async ( db: Kysely, stepRunId: string, @@ -259,6 +372,7 @@ export const getStepRunDetail = async ( attemptCount: process.attempt_count, startedAt: iso(process.started_at), finishedAt: iso(process.finished_at), + durationMs: coalesceDurationMs(process.duration_ms, process.started_at, process.finished_at), })), observations: observations.map((observation) => ({ id: observation.id, @@ -329,6 +443,7 @@ export const getRunDetail = async ( createdAt: run.createdAt.toISOString(), startedAt: iso(run.startedAt), finishedAt: iso(run.finishedAt), + durationMs: coalesceDurationMs(run.durationMs, run.startedAt, run.finishedAt), steps, }; }; @@ -364,6 +479,7 @@ export const listRepositoryRuns = async ( createdAt: row.createdAt.toISOString(), startedAt: iso(row.startedAt), finishedAt: iso(row.finishedAt), + durationMs: coalesceDurationMs(row.durationMs, row.startedAt, row.finishedAt), steps, }; }), @@ -393,6 +509,69 @@ export const listRepositoryRuns = async ( }; }; +export const getRunTreemap = async ( + db: Kysely, + runId: string, +): Promise => { + const run = await selectRunRows(db).where("runs.id", "=", runId).executeTakeFirst(); + if (!run) { + return null; + } + + const stepRows = await db + .selectFrom("step_runs") + .selectAll() + .where("run_id", "=", runId) + .orderBy("created_at", "asc") + .execute(); + + const stepChildren = await Promise.all( + stepRows.map(async (stepRow): Promise => { + const processes = await listProcessRuns(db, stepRow.id); + const children = buildStepTreemapChildren(stepRow.id, processes); + + return { + id: stepRow.id, + kind: "step", + label: stepRow.display_name, + valueMs: sumValueMs(children), + wallDurationMs: coalesceDurationMs( + stepRow.duration_ms, + stepRow.started_at, + stepRow.finished_at, + ), + status: normalizeTreemapStatus(stepRow.status), + filePath: null, + stepKey: stepRow.step_key, + processKey: null, + reused: stepRow.status === "reused", + attemptCount: null, + children, + }; + }), + ); + + const sortedChildren = sortNodesByValue(stepChildren); + return { + runId: run.id, + repositorySlug: run.repositorySlug, + tree: { + id: run.id, + kind: "run", + label: run.commitSha.slice(0, 7), + valueMs: sumValueMs(sortedChildren), + wallDurationMs: coalesceDurationMs(run.durationMs, run.startedAt, run.finishedAt), + status: normalizeTreemapStatus(run.status), + filePath: null, + stepKey: null, + processKey: null, + reused: run.status === "reused", + attemptCount: null, + children: sortedChildren, + }, + }; +}; + export const getRepositoryHealth = async ( db: Kysely, repositorySlug: string, diff --git a/packages/db/src/run-writes.ts b/packages/db/src/run-writes.ts index 837c6b9..7d914b3 100644 --- a/packages/db/src/run-writes.ts +++ b/packages/db/src/run-writes.ts @@ -14,6 +14,7 @@ import type { import { listProcessRuns } from "./run-reads.js"; import { + durationMsBetween, json, parseJson, summarizeStatuses, @@ -99,6 +100,7 @@ export const createRun = async ( status: input.status ?? "queued", started_at: null, finished_at: null, + duration_ms: null, }) .returningAll() .executeTakeFirstOrThrow(); @@ -143,6 +145,7 @@ export const createStepRun = async ( input.status === "passed" || input.status === "failed" || input.status === "reused" ? now : null, + duration_ms: null, }) .returningAll() .executeTakeFirstOrThrow(); @@ -162,6 +165,7 @@ export const createProcessRuns = async ( selectionPayload: unknown; status?: string; attemptCount?: number; + durationMs?: number | null; }>; }, ) => { @@ -184,6 +188,7 @@ export const createProcessRuns = async ( selection_payload: json(processRun.selectionPayload), status: processRun.status ?? "queued", attempt_count: processRun.attemptCount ?? 0, + duration_ms: processRun.durationMs ?? null, created_at: new Date(), })), ) @@ -212,6 +217,7 @@ export const cloneStepRunForReuse = async ( selectionPayload: parseJson(process.selection_payload), status: "reused", attemptCount: process.attempt_count, + durationMs: process.duration_ms, })), }); } @@ -293,6 +299,7 @@ export const cloneCompletedProcessesFromCheckpoint = async ( selectionPayload: parseJson(process.selection_payload), status: "reused", attemptCount: process.attempt_count, + durationMs: process.duration_ms, })), }); @@ -486,6 +493,7 @@ export const refreshRunStatus = async (db: Kysely, runId: string) const stepRows = await db .selectFrom("step_runs") .select(["status", "started_at", "finished_at"]) + .select("duration_ms") .where("run_id", "=", runId) .execute(); @@ -502,16 +510,22 @@ export const refreshRunStatus = async (db: Kysely, runId: string) .map((row) => row.finished_at) .filter((value): value is Date => Boolean(value)) .map((value) => value.getTime()); + const hasIncompleteRows = stepRows.some((row) => + ["queued", "claimed", "running"].includes(row.status), + ); + const runFinishedAt = + !hasIncompleteRows && finishedCandidates.length > 0 + ? new Date(Math.max(...finishedCandidates)) + : null; + const runStartedAt = startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null; return db .updateTable("runs") .set({ status, - started_at: startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null, - finished_at: - finishedCandidates.length === stepRows.length && finishedCandidates.length > 0 - ? new Date(Math.max(...finishedCandidates)) - : null, + started_at: runStartedAt, + finished_at: runFinishedAt, + duration_ms: durationMsBetween(runStartedAt, runFinishedAt), }) .where("id", "=", runId) .returningAll() @@ -533,16 +547,22 @@ export const refreshStepRunStatus = async (db: Kysely, stepRunId: .map((process) => process.finished_at) .filter((value): value is Date => Boolean(value)) .map((value) => value.getTime()); + const hasIncompleteRows = processRows.some((process) => + ["queued", "claimed", "running"].includes(process.status), + ); + const stepFinishedAt = + !hasIncompleteRows && finishedCandidates.length > 0 + ? new Date(Math.max(...finishedCandidates)) + : null; + const stepStartedAt = startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null; const updated = await db .updateTable("step_runs") .set({ status, - started_at: startedCandidates.length ? new Date(Math.min(...startedCandidates)) : null, - finished_at: - finishedCandidates.length === processRows.length && finishedCandidates.length > 0 - ? new Date(Math.max(...finishedCandidates)) - : null, + started_at: stepStartedAt, + finished_at: stepFinishedAt, + duration_ms: durationMsBetween(stepStartedAt, stepFinishedAt), }) .where("id", "=", stepRunId) .returningAll() @@ -583,6 +603,7 @@ export const recordRunEvent = async ( status: "running", started_at: new Date(), attempt_count: sql`attempt_count + 1`, + duration_ms: null, }) .where("id", "=", input.processRunId) .execute(); @@ -615,11 +636,16 @@ export const recordRunEvent = async ( } if (input.kind === "passed" || input.kind === "failed" || input.kind === "interrupted") { + const finishedAt = new Date(); await db .updateTable("process_runs") .set({ status: input.kind === "passed" ? "passed" : input.kind, - finished_at: new Date(), + finished_at: finishedAt, + duration_ms: sql`greatest( + 0, + extract(epoch from ${finishedAt} - coalesce(started_at, ${finishedAt})) * 1000 + )::integer`, }) .where("id", "=", input.processRunId) .execute(); diff --git a/packages/db/src/shared.ts b/packages/db/src/shared.ts index f07397c..921ce9e 100644 --- a/packages/db/src/shared.ts +++ b/packages/db/src/shared.ts @@ -80,6 +80,7 @@ type RunsTable = { created_at: Generated; started_at: Date | null; finished_at: Date | null; + duration_ms: number | null; }; type StepRunsTable = { @@ -103,6 +104,7 @@ type StepRunsTable = { created_at: Generated; started_at: Date | null; finished_at: Date | null; + duration_ms: number | null; }; type ProcessRunsTable = { @@ -123,6 +125,7 @@ type ProcessRunsTable = { created_at: Generated; started_at: Date | null; finished_at: Date | null; + duration_ms: number | null; }; type RunEventsTable = { @@ -248,6 +251,23 @@ export const json = (value: unknown): string => JSON.stringify(value); export const iso = (value: Date | null): string | null => (value ? value.toISOString() : null); +export const durationMsBetween = ( + startedAt: Date | null, + finishedAt: Date | null, +): number | null => { + if (!startedAt || !finishedAt) { + return null; + } + + return Math.max(0, finishedAt.getTime() - startedAt.getTime()); +}; + +export const coalesceDurationMs = ( + storedDurationMs: number | null, + startedAt: Date | null, + finishedAt: Date | null, +): number | null => storedDurationMs ?? durationMsBetween(startedAt, finishedAt); + export const parseJson = (value: unknown): T => { if (typeof value === "string") { return JSON.parse(value) as T; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79c7036..3fbe277 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: workspace:* version: link:packages/cli devDependencies: + '@types/d3-hierarchy': + specifier: ^3.1.7 + version: 3.1.7 '@types/node': specifier: ^22.17.0 version: 22.19.17 @@ -81,6 +84,9 @@ importers: '@verge/contracts': specifier: workspace:* version: link:../../packages/contracts + d3-hierarchy: + specifier: ^3.1.2 + version: 3.1.2 react: specifier: ^19.1.1 version: 19.2.5 @@ -776,6 +782,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -960,6 +969,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -2218,6 +2231,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-hierarchy@3.1.7': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -2406,6 +2421,8 @@ snapshots: csstype@3.2.3: {} + d3-hierarchy@3.1.2: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 From 7aa1088d823ff7a28f5db3663fa33ba1c5613d6d Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 14 Apr 2026 02:44:30 +0200 Subject: [PATCH 2/2] Fix treemap duration and error states --- apps/web/src/App.test.tsx | 13 ++++++++++++- apps/web/src/App.tsx | 6 ++++-- apps/web/src/components/RunTreemap.tsx | 6 ++++-- apps/web/src/hooks/use-run-detail-data.ts | 17 ++++++++++++++--- apps/web/src/lib/format.ts | 2 +- apps/web/src/pages/RunDetailPage.tsx | 9 ++++++++- 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 56693cf..33ba526 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { statusTone } from "./lib/format.js"; +import { formatDuration, statusTone } from "./lib/format.js"; describe("statusTone", () => { it("maps successful states to the good tone", () => { @@ -13,3 +13,14 @@ describe("statusTone", () => { expect(statusTone("stale")).toBe("bad"); }); }); + +describe("formatDuration", () => { + it("falls back to live elapsed time when durationMs is null", () => { + const startedAt = new Date(Date.now() - 4_200).toISOString(); + expect(formatDuration(startedAt, null, null)).toBe("4s"); + }); + + it("prefers the stored duration when it exists", () => { + expect(formatDuration(null, null, 65_000)).toBe("1m 5s"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2d2054a..1e6eb42 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -31,7 +31,7 @@ export const App = () => { const currentRepositorySlug = route.repositorySlug ?? preferredRepositorySlug; const { health, processSpecs, error: overviewError } = useOverviewData(currentRepositorySlug); const { runsPage, error: runsError } = useRunsPageData(route, currentRepositorySlug); - const { run, treemap, step, error: runError } = useRunDetailData(route); + const { run, treemap, step, error: runError, treemapError } = useRunDetailData(route); const [commitSha, setCommitSha] = useState(""); const [branch, setBranch] = useState("main"); @@ -319,7 +319,9 @@ export const App = () => { /> ) : null} - {route.name === "run" ? : null} + {route.name === "run" ? ( + + ) : null} {route.name === "step" ? : null} ); diff --git a/apps/web/src/components/RunTreemap.tsx b/apps/web/src/components/RunTreemap.tsx index 1038362..9781a1d 100644 --- a/apps/web/src/components/RunTreemap.tsx +++ b/apps/web/src/components/RunTreemap.tsx @@ -77,10 +77,12 @@ export const RunTreemapView = ({ runId, repositorySlug, treemapData, + treemapError, }: { runId: string; repositorySlug: string; treemapData: RunTreemap | null; + treemapError: string | null; }) => { const [hoveredNode, setHoveredNode] = useState<{ node: TreemapNode; @@ -91,8 +93,8 @@ export const RunTreemapView = ({ if (!treemapData) { return ( ); } diff --git a/apps/web/src/hooks/use-run-detail-data.ts b/apps/web/src/hooks/use-run-detail-data.ts index 00352ab..7cc5f9c 100644 --- a/apps/web/src/hooks/use-run-detail-data.ts +++ b/apps/web/src/hooks/use-run-detail-data.ts @@ -10,6 +10,7 @@ export const useRunDetailData = (route: AppRoute) => { const [treemap, setTreemap] = useState(null); const [step, setStep] = useState(null); const [error, setError] = useState(null); + const [treemapError, setTreemapError] = useState(null); useEffect(() => { if (route.name !== "run" && route.name !== "step") { @@ -17,12 +18,14 @@ export const useRunDetailData = (route: AppRoute) => { setTreemap(null); setStep(null); setError(null); + setTreemapError(null); return; } setRun(null); setTreemap(null); setStep(null); + setTreemapError(null); const refresh = async (): Promise => { try { @@ -34,8 +37,16 @@ export const useRunDetailData = (route: AppRoute) => { ); setStep(nextStep); } else { - const nextTreemap = await fetchJson(`/runs/${route.runId}/treemap`); - setTreemap(nextTreemap); + try { + const nextTreemap = await fetchJson(`/runs/${route.runId}/treemap`); + setTreemap(nextTreemap); + setTreemapError(null); + } catch (nextTreemapError) { + setTreemap(null); + setTreemapError( + describeLoadError(route, nextTreemapError, "Failed to load duration map"), + ); + } } setError(null); } catch (nextError) { @@ -50,5 +61,5 @@ export const useRunDetailData = (route: AppRoute) => { return () => window.clearInterval(interval); }, [route]); - return { run, treemap, step, error }; + return { run, treemap, step, error, treemapError }; }; diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index b64e11f..b44ac20 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -50,7 +50,7 @@ export const formatDuration = ( finishedAt: string | null, durationMs?: number | null, ): string => { - if (durationMs !== undefined) { + if (durationMs !== undefined && durationMs !== null) { return formatDurationMs(durationMs); } diff --git a/apps/web/src/pages/RunDetailPage.tsx b/apps/web/src/pages/RunDetailPage.tsx index 320c0b3..f771a97 100644 --- a/apps/web/src/pages/RunDetailPage.tsx +++ b/apps/web/src/pages/RunDetailPage.tsx @@ -14,10 +14,12 @@ import { buildStepPath, navigate } from "../lib/routing.js"; export const RunDetailPage = ({ run, treemap, + treemapError, error, }: { run: RunDetail | null; treemap: RunTreemap | null; + treemapError: string | null; error: string | null; }) => { if (!run) { @@ -85,7 +87,12 @@ export const RunDetailPage = ({

- +