diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index 46138ac5e..2d3122602 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -157,4 +157,4 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta ); -} \ No newline at end of file +} diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index 61700901c..6e323e751 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -123,6 +123,27 @@ describe("subscribeToRunEvents", () => { cleanup(); }); + test("falls back to node_id when an event has no stage_id", () => { + const source = new FakeEventSource(); + const keys: string[] = []; + const cleanup = subscribeToRunEvents( + "run-stage-node", + (key) => { + keys.push(key); + return Promise.resolve(); + }, + () => source, + { debounceMs: 0 }, + ); + + source.emit({ event: "stage.started", node_id: "verify" }); + + expect(keys).toContain(queryKeys.runs.stageTurns("run-stage-node", "verify")); + expect(keys).toContain(queryKeys.runs.stages("run-stage-node")); + + cleanup(); + }); + test("malformed events are ignored and StrictMode-style cleanup does not underflow", () => { const firstSource = new FakeEventSource(); const secondSource = new FakeEventSource(); @@ -156,4 +177,4 @@ describe("subscribeToRunEvents", () => { expect(firstSource.closed).toBe(true); expect(secondSource.closed).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 28115899b..00bd93e4f 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -136,10 +136,10 @@ export function subscribeToRunEvents( } function stageIdFromPayload(payload: RunEventPayload): string | undefined { - // Only return a true `node_id@visit` StageId. A bare `node_id` would not - // match the suffixed `stageTurns(runId, "verify@1")` cache key, so falling - // back to it would silently no-op the invalidation. - return typeof payload.stage_id === "string" ? payload.stage_id : undefined; + if (typeof payload.stage_id === "string") return payload.stage_id; + if (typeof payload.node_id === "string") return payload.node_id; + const nodeId = payload.properties?.node_id; + return typeof nodeId === "string" ? nodeId : undefined; } export function useRunEvents(runId: string | undefined) { @@ -149,4 +149,4 @@ export function useRunEvents(runId: string | undefined) { if (!runId) return; return subscribeToRunEvents(runId, mutate as MutateFn); }, [mutate, runId]); -} \ No newline at end of file +} diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index c34132e14..9e8891645 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { PaginatedRunStageList, StageState } from "@qltysh/fabro-api-client"; import type { Stage } from "../components/stage-sidebar"; -import { aggregateGraphNodeStatus, mapRunStagesToSidebarStages } from "./stage-sidebar"; +import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar"; function makeStage(nodeId: string, visit: number, status: StageState): Stage { return { @@ -44,10 +44,12 @@ describe("mapRunStagesToSidebarStages", () => { expect(result[0].id).toBe("apply-changes@1"); expect(result[0].nodeId).toBe("apply"); expect(result[0].visit).toBe(1); + expect(formatStageLabel(result[0])).toBe("Apply Changes"); expect(result[1].id).toBe("apply-changes@2"); expect(result[1].nodeId).toBe("apply"); expect(result[1].visit).toBe(2); + expect(formatStageLabel(result[1])).toBe("Apply Changes (2)"); }); test("filters by node_id (suffixed start@1 / exit@1 are still hidden)", () => { @@ -167,4 +169,4 @@ describe("aggregateGraphNodeStatus", () => { latestStageId: "apply@1", }); }); -}); \ No newline at end of file +}); diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index a820f5498..e44e5f4a5 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -71,4 +71,4 @@ export function aggregateGraphNodeStatus(stages: readonly Stage[]): Map< result.set(nodeId, { displayStatus: display.status, latestStageId: latestStage.id }); } return result; -} \ No newline at end of file +} diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index 179825560..48066eae0 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -54,9 +54,6 @@ export default function RunOverview() { const inner = innerRef.current; if (!inner || !graphSvg) return; - let cancelled = false; - (async () => { - if (cancelled) return; inner.innerHTML = graphSvg; const svg = inner.querySelector("svg"); if (!svg) return; @@ -151,8 +148,6 @@ export default function RunOverview() { } } } - })(); - return () => { cancelled = true; }; }, [stages, graphSvg, id, navigate, terminalOutcome]); const onPointerDown = useCallback((e: React.PointerEvent) => { @@ -237,4 +232,4 @@ export default function RunOverview() { ); -} \ No newline at end of file +} diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 9a7d9bc43..704c5cac8 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -108,4 +108,4 @@ describe("turnsFromEvents", () => { expect(turn.running).toBe(false); } }); -}); \ No newline at end of file +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 90ba4ed0c..1176cb71a 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -41,7 +41,7 @@ import { EmptyState } from "../components/state"; import { CopyButton } from "../components/ui"; import { formatDurationSecs } from "../lib/format"; import { fetchRunCommandLog, useRunEventsList, useRunStageTurns, useRunStages } from "../lib/queries"; -import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import { ACTIVE_STAGE_STATES, formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; import { CommandOutputStream, @@ -614,7 +614,7 @@ export default function RunStages() { () => mapTurns(turnsQuery.data, eventsQuery.data, selectedStage?.id), [eventsQuery.data, selectedStage?.id, turnsQuery.data], ); - const isRunning = selectedStage?.status === "running"; + const isActive = selectedStage ? ACTIVE_STAGE_STATES.has(selectedStage.status) : false; if (!id || !stages.length) { return ( @@ -636,13 +636,13 @@ export default function RunStages() {