Skip to content

Commit 106d1e2

Browse files
authored
Add run treemap visualization (#3)
* Add run treemap visualization * Fix treemap duration and error states
1 parent abcee2f commit 106d1e2

File tree

19 files changed

+981
-28
lines changed

19 files changed

+981
-28
lines changed

apps/api/src/app.integration.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,123 @@ describe.runIf(runIntegration)("api integration", () => {
9696
});
9797
expect(stepResponse.statusCode).toBe(404);
9898
expect(stepResponse.json()).toMatchObject({ message: "Step not found" });
99+
100+
const treemapResponse = await app.inject({
101+
method: "GET",
102+
url: "/runs/00000000-0000-0000-0000-000000000001/treemap",
103+
});
104+
expect(treemapResponse.statusCode).toBe(404);
105+
expect(treemapResponse.json()).toMatchObject({ message: "Run not found" });
106+
}, 30_000);
107+
108+
it("returns a run treemap with step, file, and process nodes", async () => {
109+
const createResponse = await app.inject({
110+
method: "POST",
111+
url: "/runs/manual",
112+
payload: {
113+
repositorySlug: "verge",
114+
commitSha: "treemap-sha",
115+
requestedStepKeys: ["test"],
116+
disableReuse: true,
117+
},
118+
});
119+
120+
expect(createResponse.statusCode).toBe(200);
121+
const createPayload = createResponse.json() as {
122+
runId: string;
123+
stepRunIds: string[];
124+
};
125+
126+
const initialTreemapResponse = await app.inject({
127+
method: "GET",
128+
url: `/runs/${createPayload.runId}/treemap`,
129+
});
130+
expect(initialTreemapResponse.statusCode).toBe(200);
131+
const initialTreemap = initialTreemapResponse.json() as {
132+
runId: string;
133+
tree: {
134+
kind: string;
135+
children?: Array<{
136+
kind: string;
137+
stepKey?: string | null;
138+
children?: Array<{ kind: string; children?: Array<{ kind: string }> }>;
139+
}>;
140+
};
141+
};
142+
143+
expect(initialTreemap.runId).toBe(createPayload.runId);
144+
expect(initialTreemap.tree.kind).toBe("run");
145+
const testStepNode = initialTreemap.tree.children?.find((node) => node.stepKey === "test");
146+
expect(testStepNode?.kind).toBe("step");
147+
expect(testStepNode?.children?.some((child) => child.kind === "file")).toBe(true);
148+
expect(
149+
testStepNode?.children?.some(
150+
(child) =>
151+
child.kind === "file" &&
152+
child.children?.some((grandchild) => grandchild.kind === "process"),
153+
),
154+
).toBe(true);
155+
156+
const claimResponse = await app.inject({
157+
method: "POST",
158+
url: "/workers/claim",
159+
payload: {
160+
workerId: "treemap-worker",
161+
},
162+
});
163+
expect(claimResponse.statusCode).toBe(200);
164+
const assignment = claimResponse.json() as {
165+
assignment: {
166+
stepRunId: string;
167+
processRunId: string;
168+
processKey: string;
169+
} | null;
170+
};
171+
expect(assignment.assignment?.stepRunId).toBe(createPayload.stepRunIds[0]);
172+
173+
await app.inject({
174+
method: "POST",
175+
url: `/workers/steps/${assignment.assignment?.stepRunId}/events`,
176+
payload: {
177+
workerId: "treemap-worker",
178+
processRunId: assignment.assignment?.processRunId,
179+
kind: "started",
180+
message: "Started treemap process",
181+
},
182+
});
183+
184+
await new Promise((resolve) => setTimeout(resolve, 15));
185+
186+
await app.inject({
187+
method: "POST",
188+
url: `/workers/steps/${assignment.assignment?.stepRunId}/events`,
189+
payload: {
190+
workerId: "treemap-worker",
191+
processRunId: assignment.assignment?.processRunId,
192+
kind: "passed",
193+
message: "Completed treemap process",
194+
},
195+
});
196+
197+
const finalTreemapResponse = await app.inject({
198+
method: "GET",
199+
url: `/runs/${createPayload.runId}/treemap`,
200+
});
201+
expect(finalTreemapResponse.statusCode).toBe(200);
202+
const finalTreemap = finalTreemapResponse.json() as {
203+
tree: {
204+
children?: Array<{
205+
children?: Array<{ children?: Array<{ processKey?: string | null; valueMs?: number }> }>;
206+
}>;
207+
};
208+
};
209+
210+
const processNode = finalTreemap.tree.children
211+
?.flatMap((child) => child.children ?? [])
212+
.flatMap((child) => child.children ?? [])
213+
.find((node) => node.processKey === assignment.assignment?.processKey);
214+
215+
expect(processNode?.valueMs).toBeGreaterThan(0);
99216
}, 30_000);
100217

101218
it("ingests GitHub webhooks idempotently and exposes pull request detail", async () => {

apps/api/src/routes/public.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getRepositoryBySlug,
99
getRepositoryHealth,
1010
getRunDetail,
11+
getRunTreemap,
1112
getStepRunDetail,
1213
listRepositories,
1314
listRepositoryRuns,
@@ -57,6 +58,17 @@ export const registerPublicRoutes = (app: FastifyInstance, context: ApiContext):
5758
return detail;
5859
});
5960

61+
app.get("/runs/:id/treemap", async (request, reply) => {
62+
const treemap = await getRunTreemap(
63+
context.connection.db,
64+
(request.params as { id: string }).id,
65+
);
66+
if (!treemap) {
67+
return reply.code(404).send({ message: "Run not found" });
68+
}
69+
return treemap;
70+
});
71+
6072
app.get("/runs/:runId/steps/:stepId", async (request, reply) => {
6173
const { runId, stepId } = request.params as { runId: string; stepId: string };
6274
const detail = await getStepRunDetail(context.connection.db, stepId);

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@verge/contracts": "workspace:*",
13+
"d3-hierarchy": "^3.1.2",
1314
"react": "^19.1.1",
1415
"react-dom": "^19.1.1"
1516
}

apps/web/src/App.test.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { statusTone } from "./lib/format.js";
3+
import { formatDuration, statusTone } from "./lib/format.js";
44

55
describe("statusTone", () => {
66
it("maps successful states to the good tone", () => {
@@ -13,3 +13,14 @@ describe("statusTone", () => {
1313
expect(statusTone("stale")).toBe("bad");
1414
});
1515
});
16+
17+
describe("formatDuration", () => {
18+
it("falls back to live elapsed time when durationMs is null", () => {
19+
const startedAt = new Date(Date.now() - 4_200).toISOString();
20+
expect(formatDuration(startedAt, null, null)).toBe("4s");
21+
});
22+
23+
it("prefers the stored duration when it exists", () => {
24+
expect(formatDuration(null, null, 65_000)).toBe("1m 5s");
25+
});
26+
});

apps/web/src/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const App = () => {
3131
const currentRepositorySlug = route.repositorySlug ?? preferredRepositorySlug;
3232
const { health, processSpecs, error: overviewError } = useOverviewData(currentRepositorySlug);
3333
const { runsPage, error: runsError } = useRunsPageData(route, currentRepositorySlug);
34-
const { run, step, error: runError } = useRunDetailData(route);
34+
const { run, treemap, step, error: runError, treemapError } = useRunDetailData(route);
3535

3636
const [commitSha, setCommitSha] = useState("");
3737
const [branch, setBranch] = useState("main");
@@ -319,7 +319,9 @@ export const App = () => {
319319
/>
320320
) : null}
321321

322-
{route.name === "run" ? <RunDetailPage run={run} error={error} /> : null}
322+
{route.name === "run" ? (
323+
<RunDetailPage run={run} treemap={treemap} treemapError={treemapError} error={error} />
324+
) : null}
323325
{route.name === "step" ? <StepDetailPage run={run} step={step} error={error} /> : null}
324326
</main>
325327
);

0 commit comments

Comments
 (0)