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