refactor(web): simplify SWR event plumbing

Share SSE subscription management, reuse query key builders, and remove duplicate route mapping/error helpers from the SWR refactor.
This commit is contained in:
Bryan Helmkamp 2026-04-25 07:37:17 -04:00
parent a1f032e166
commit d4104be841
No known key found for this signature in database
18 changed files with 475 additions and 433 deletions

View file

@ -9,7 +9,6 @@ import {
} from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
import { useRunEvents } from "../lib/run-events";
export type StageStatus = "completed" | "running" | "pending" | "failed" | "cancelled";
@ -41,9 +40,6 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
const runningStartRef = useRef<Map<string, number>>(new Map());
const [, setTick] = useState(0);
// Subscribe to run-specific SSE for live stage updates
useRunEvents(runId);
// Track start times for running stages
useEffect(() => {
const running = new Set<string>(

View file

@ -43,10 +43,10 @@ describe("subscribeToBoardEvents", () => {
const firstCleanup = subscribeToBoardEvents(mutate, (url) => {
created.push(url);
return source;
});
}, { debounceMs: 0 });
const secondCleanup = subscribeToBoardEvents(mutate, () => {
throw new Error("source should be reused");
});
}, { debounceMs: 0 });
source.emit({ event: "run.running" });

View file

@ -1,20 +1,15 @@
import { useEffect } from "react";
import { useSWRConfig, type MutatorCallback } from "swr";
import { useSWRConfig } from "swr";
import { queryKeys } from "./query-keys";
type MutateFn = (key: string) => ReturnType<MutatorCallback>;
interface BoardEventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
close(): void;
}
interface BoardSubscription {
source: BoardEventSourceLike;
refcount: number;
mutators: Map<MutateFn, number>;
}
import {
createBrowserEventSource,
subscribeToSharedEventSource,
type EventPayload,
type EventSourceLike,
type MutateFn,
type SharedEventSubscription,
} from "./sse";
const BOARD_STATUS_EVENTS = new Set([
"run.submitted",
@ -36,11 +31,8 @@ const BOARD_STATUS_EVENTS = new Set([
"interview.interrupted",
]);
let subscription: BoardSubscription | null = null;
function createBrowserEventSource(url: string): BoardEventSourceLike {
return new EventSource(url);
}
const subscriptions = new Map<string, SharedEventSubscription>();
const BOARD_SUBSCRIPTION_KEY = "board";
export function shouldRefreshBoardForEvent(event: string) {
return BOARD_STATUS_EVENTS.has(event);
@ -48,48 +40,22 @@ export function shouldRefreshBoardForEvent(event: string) {
export function subscribeToBoardEvents(
mutate: MutateFn,
eventSourceFactory: (url: string) => BoardEventSourceLike = createBrowserEventSource,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 500 }: { debounceMs?: number } = {},
): () => void {
if (!subscription) {
const source = eventSourceFactory("/api/v1/attach");
subscription = {
source,
refcount: 0,
mutators: new Map(),
};
source.onmessage = (message) => {
try {
const payload = JSON.parse(message.data) as { event?: string };
if (!payload.event || !shouldRefreshBoardForEvent(payload.event)) return;
for (const mutator of subscription?.mutators.keys() ?? []) {
void mutator(queryKeys.boards.runs());
}
} catch {
// Ignore malformed events.
}
};
}
subscription.refcount += 1;
subscription.mutators.set(mutate, (subscription.mutators.get(mutate) ?? 0) + 1);
return () => {
if (!subscription) return;
const mutateCount = subscription.mutators.get(mutate) ?? 0;
if (mutateCount <= 1) {
subscription.mutators.delete(mutate);
} else {
subscription.mutators.set(mutate, mutateCount - 1);
}
subscription.refcount -= 1;
if (subscription.refcount <= 0) {
subscription.source.close();
subscription = null;
}
};
return subscribeToSharedEventSource<EventPayload>({
subscriptions,
subscriptionKey: BOARD_SUBSCRIPTION_KEY,
url: queryKeys.system.attach(),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => ({
keys: payload.event && shouldRefreshBoardForEvent(payload.event)
? [queryKeys.boards.runs()]
: [],
}),
});
}
export function useBoardEvents() {

View file

@ -1,17 +1,17 @@
import useSWRMutation from "swr/mutation";
import { useSWRConfig } from "swr";
import type {
ErrorResponseEntry,
PreviewUrlResponse,
RunStatusResponse,
} from "@qltysh/fabro-api-client";
import { apiJsonMutation, apiRequest } from "./api-client";
import { apiJsonMutation } from "./api-client";
import { queryKeys } from "./query-keys";
import type { LifecycleAction, LifecycleActionError } from "./run-actions";
import {
archiveRun,
cancelRun,
isLifecycleActionError,
unarchiveRun,
} from "./run-actions";
@ -78,7 +78,7 @@ function useLifecycleMutation(
return {
intent,
ok: false,
error: serializeLifecycleActionError(error),
error: isLifecycleActionError(error) ? error : null,
};
}
},
@ -98,18 +98,7 @@ export function useToggleDemoMode() {
return useSWRMutation(
queryKeys.demo.toggle(),
async (key: string, { arg }: { arg: { enabled: boolean } }) => {
const response = await apiRequest(key, {
init: {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
},
});
if (!response.ok) {
throw new Error(response.statusText || `HTTP ${response.status}`);
}
return response;
await apiJsonMutation<void, { enabled: boolean }>(key, { arg });
},
{
onSuccess: () => {
@ -136,26 +125,3 @@ export function useLoginDevToken() {
},
);
}
function serializeLifecycleActionError(error: unknown): LifecycleActionError | null {
if (!error || typeof error !== "object") return null;
const record = error as Record<string, unknown>;
if (typeof record.status !== "number" || !Array.isArray(record.errors)) {
return null;
}
return {
status: record.status,
errors: record.errors.filter(isErrorResponseEntry),
};
}
function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.status === "string" &&
typeof record.title === "string" &&
typeof record.detail === "string"
);
}

View file

@ -21,6 +21,7 @@ export const queryKeys = {
},
system: {
info: () => "/api/v1/system/info",
attach: () => "/api/v1/attach",
},
boards: {
runs: () => "/api/v1/boards/runs",
@ -46,6 +47,7 @@ export const queryKeys = {
cancel: (id: string) => `/api/v1/runs/${pathSegment(id)}/cancel`,
archive: (id: string) => `/api/v1/runs/${pathSegment(id)}/archive`,
unarchive: (id: string) => `/api/v1/runs/${pathSegment(id)}/unarchive`,
attach: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`,
},
workflows: {
list: () => "/api/v1/workflows",

View file

@ -1,6 +1,7 @@
import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client";
import { apiRequest } from "./api-client";
import { queryKeys } from "./query-keys";
import type { RunStatus } from "../data/runs";
export type LifecycleAction = "cancel" | "archive" | "unarchive";
@ -89,11 +90,11 @@ async function runLifecycleAction(
action: LifecycleAction,
request?: Request,
): Promise<RunStatusResponse> {
const response = await apiRequest(`/runs/${id}/${action}`, {
const response = await apiRequest(queryKeys.runs[action](id), {
init: {
method: "POST",
...(request?.signal ? { signal: request.signal } : {}),
},
request,
});
if (!response.ok) {
@ -128,7 +129,7 @@ async function parseLifecycleActionError(response: Response): Promise<LifecycleA
}
}
function isLifecycleActionError(value: unknown): value is LifecycleActionError {
export function isLifecycleActionError(value: unknown): value is LifecycleActionError {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return typeof record.status === "number" && Array.isArray(record.errors);

View file

@ -51,10 +51,10 @@ describe("subscribeToRunEvents", () => {
const firstCleanup = subscribeToRunEvents("run-refcount", mutate, (url) => {
created.push(url);
return source;
});
}, { debounceMs: 0 });
const secondCleanup = subscribeToRunEvents("run-refcount", mutate, () => {
throw new Error("source should be reused");
});
}, { debounceMs: 0 });
expect(created).toEqual(["/api/v1/runs/run-refcount/attach"]);
@ -78,6 +78,7 @@ describe("subscribeToRunEvents", () => {
return Promise.resolve();
},
() => source,
{ debounceMs: 0 },
);
source.emit({ event: "run.failed" });
@ -102,6 +103,7 @@ describe("subscribeToRunEvents", () => {
return Promise.resolve();
},
() => sources.shift()!,
{ debounceMs: 0 },
);
firstSource.emitRaw("{broken");
firstCleanup();
@ -113,6 +115,7 @@ describe("subscribeToRunEvents", () => {
return Promise.resolve();
},
() => sources.shift()!,
{ debounceMs: 0 },
);
secondCleanup();

View file

@ -1,28 +1,23 @@
import { useEffect } from "react";
import { useSWRConfig, type MutatorCallback } from "swr";
import { useSWRConfig } from "swr";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
subscribeToSharedEventSource,
type EventPayload,
type EventSourceLike,
type MutateFn,
type SharedEventSubscription,
} from "./sse";
type MutateFn = (key: string) => ReturnType<MutatorCallback>;
interface RunEventPayload {
interface RunEventPayload extends EventPayload {
event?: string;
node_id?: string;
properties?: Record<string, unknown>;
}
interface RunEventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
close(): void;
}
interface RunSubscription {
source: RunEventSourceLike;
refcount: number;
mutators: Map<MutateFn, number>;
}
const subscriptions = new Map<string, RunSubscription>();
const subscriptions = new Map<string, SharedEventSubscription>();
const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]);
const RUN_SUMMARY_EVENTS = new Set([
@ -40,10 +35,6 @@ const RUN_SUMMARY_EVENTS = new Set([
const STAGE_EVENTS = new Set(["stage.started", "stage.completed", "stage.failed"]);
const COMMAND_EVENTS = new Set(["command.started", "command.completed"]);
function createBrowserEventSource(url: string): RunEventSourceLike {
return new EventSource(url);
}
export function queryKeysForRunEvent(
runId: string,
event: string,
@ -99,65 +90,30 @@ export function queryKeysForRunEvent(
export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
eventSourceFactory: (url: string) => RunEventSourceLike = createBrowserEventSource,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 300 }: { debounceMs?: number } = {},
): () => void {
let subscription = subscriptions.get(runId);
if (!subscription) {
const source = eventSourceFactory(`/api/v1/runs/${runId}/attach`);
subscription = {
source,
refcount: 0,
mutators: new Map(),
};
subscriptions.set(runId, subscription);
source.onmessage = (message) => {
let payload: RunEventPayload;
try {
payload = JSON.parse(message.data) as RunEventPayload;
} catch {
return;
}
return subscribeToSharedEventSource<RunEventPayload>({
subscriptions,
subscriptionKey: runId,
url: queryKeys.runs.attach(runId),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => {
const event = payload.event;
if (!event) return;
if (!event) return { keys: [] };
const stageId = stageIdFromPayload(payload);
const keys = queryKeysForRunEvent(runId, event, stageId);
if (keys.length > 0) {
const current = subscriptions.get(runId);
for (const mutator of current?.mutators.keys() ?? []) {
for (const key of keys) {
void mutator(key);
}
}
}
if (TERMINAL_EVENTS.has(event)) {
closeRunSubscription(runId);
}
};
}
subscription.refcount += 1;
subscription.mutators.set(mutate, (subscription.mutators.get(mutate) ?? 0) + 1);
return () => {
const current = subscriptions.get(runId);
if (!current) return;
const mutateCount = current.mutators.get(mutate) ?? 0;
if (mutateCount <= 1) {
current.mutators.delete(mutate);
} else {
current.mutators.set(mutate, mutateCount - 1);
}
current.refcount -= 1;
if (current.refcount <= 0) {
closeRunSubscription(runId);
}
};
const terminal = TERMINAL_EVENTS.has(event);
return {
keys,
close: terminal,
immediate: terminal,
};
},
});
}
function stageIdFromPayload(payload: RunEventPayload): string | undefined {
@ -166,13 +122,6 @@ function stageIdFromPayload(payload: RunEventPayload): string | undefined {
return typeof nodeId === "string" ? nodeId : undefined;
}
function closeRunSubscription(runId: string) {
const subscription = subscriptions.get(runId);
if (!subscription) return;
subscription.source.close();
subscriptions.delete(runId);
}
export function useRunEvents(runId: string | undefined) {
const { mutate } = useSWRConfig();

View file

@ -0,0 +1,164 @@
import type { MutatorCallback } from "swr";
export type MutateFn = (key: string) => ReturnType<MutatorCallback>;
export interface EventPayload {
event?: string;
[key: string]: unknown;
}
export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
close(): void;
}
export interface EventInvalidation {
keys: string[];
close?: boolean;
immediate?: boolean;
}
export interface SharedEventSubscription {
source: EventSourceLike;
refcount: number;
mutators: Map<MutateFn, number>;
pendingKeys: Set<string>;
debounceTimer: ReturnType<typeof setTimeout> | null;
}
export function createBrowserEventSource(url: string): EventSourceLike {
return new EventSource(url);
}
export function subscribeToSharedEventSource<TPayload extends EventPayload>({
subscriptions,
subscriptionKey,
url,
mutate,
resolveInvalidation,
eventSourceFactory = createBrowserEventSource,
debounceMs = 300,
}: {
subscriptions: Map<string, SharedEventSubscription>;
subscriptionKey: string;
url: string;
mutate: MutateFn;
resolveInvalidation: (payload: TPayload) => EventInvalidation;
eventSourceFactory?: (url: string) => EventSourceLike;
debounceMs?: number;
}): () => void {
let subscription = subscriptions.get(subscriptionKey);
if (!subscription) {
const source = eventSourceFactory(url);
subscription = {
source,
refcount: 0,
mutators: new Map(),
pendingKeys: new Set(),
debounceTimer: null,
};
subscriptions.set(subscriptionKey, subscription);
source.onmessage = (message) => {
const current = subscriptions.get(subscriptionKey);
if (!current) return;
let payload: TPayload;
try {
payload = JSON.parse(message.data) as TPayload;
} catch {
return;
}
const invalidation = resolveInvalidation(payload);
queueInvalidations(current, invalidation.keys, {
debounceMs,
immediate: invalidation.immediate,
});
if (invalidation.close) {
closeSharedEventSource(subscriptions, subscriptionKey, { flushPending: true });
}
};
}
subscription.refcount += 1;
subscription.mutators.set(mutate, (subscription.mutators.get(mutate) ?? 0) + 1);
return () => {
const current = subscriptions.get(subscriptionKey);
if (!current) return;
const mutateCount = current.mutators.get(mutate) ?? 0;
if (mutateCount <= 1) {
current.mutators.delete(mutate);
} else {
current.mutators.set(mutate, mutateCount - 1);
}
current.refcount -= 1;
if (current.refcount <= 0) {
closeSharedEventSource(subscriptions, subscriptionKey);
}
};
}
function queueInvalidations(
subscription: SharedEventSubscription,
keys: string[],
{
debounceMs,
immediate,
}: {
debounceMs: number;
immediate?: boolean;
},
) {
if (keys.length === 0) return;
for (const key of keys) {
subscription.pendingKeys.add(key);
}
if (immediate || debounceMs <= 0) {
flushInvalidations(subscription);
return;
}
if (subscription.debounceTimer) {
clearTimeout(subscription.debounceTimer);
}
subscription.debounceTimer = setTimeout(() => {
subscription.debounceTimer = null;
flushInvalidations(subscription);
}, debounceMs);
}
function flushInvalidations(subscription: SharedEventSubscription) {
if (subscription.pendingKeys.size === 0) return;
const keys = [...subscription.pendingKeys];
subscription.pendingKeys.clear();
for (const mutator of subscription.mutators.keys()) {
for (const key of keys) {
void mutator(key);
}
}
}
function closeSharedEventSource(
subscriptions: Map<string, SharedEventSubscription>,
subscriptionKey: string,
{ flushPending = false }: { flushPending?: boolean } = {},
) {
const subscription = subscriptions.get(subscriptionKey);
if (!subscription) return;
if (flushPending) {
flushInvalidations(subscription);
}
if (subscription.debounceTimer) {
clearTimeout(subscription.debounceTimer);
}
subscription.source.close();
subscriptions.delete(subscriptionKey);
}

View file

@ -0,0 +1,21 @@
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type { Stage } from "../components/stage-sidebar";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "./format";
export function mapRunStagesToSidebarStages(
stagesResult: PaginatedRunStageList | null | undefined,
): Stage[] {
return (stagesResult?.data ?? [])
.filter((stage) => isVisibleStage(stage.id))
.map((stage) => ({
id: stage.id,
name: stage.name,
dotId: stage.dot_id ?? stage.id,
status: stage.status as Stage["status"],
duration: stage.duration_secs != null
? formatDurationSecs(stage.duration_secs)
: "--",
}));
}

View file

@ -28,7 +28,6 @@ import {
import { useFileKeyboardNav } from "./run-files/keyboard";
import { Toolbar, type DiffStyle } from "./run-files/toolbar";
import { ApiError, extractRequestId } from "../lib/api-client";
import { useRunEvents } from "../lib/run-events";
import { useRun, useRunFiles } from "../lib/queries";
export { extractRequestId };
@ -211,8 +210,6 @@ export default function RunFiles() {
const data: PaginatedRunFileList | null =
filesQuery.data ?? lastGoodDataRef.current;
useRunEvents(params.id);
const isInitialLoading = filesQuery.isLoading && !data;
const isRevalidating = filesQuery.isValidating;

View file

@ -1,4 +1,5 @@
import { isRouteErrorResponse, useRouteError } from "react-router";
import { extractRequestId } from "../../lib/api-client";
/**
* R4 empty-state taxonomy. See plan § Unit 11:
@ -187,7 +188,7 @@ export function RunFilesErrorBoundary() {
if (isRouteErrorResponse(error)) {
return renderStatusError({
status: error.status,
requestId: extractRequestIdFromUnknown(error.data),
requestId: extractRequestId(error.data),
onRetry: () => window.location.reload(),
});
}
@ -200,28 +201,3 @@ export function RunFilesErrorBoundary() {
</div>
);
}
/**
* Request-ID parser used only by the ErrorBoundary path. The loader path
* already extracts request_id into `RunFilesLoaderResult.error.requestId`
* via `run-files.tsx::extractRequestId` this is the body shape
* react-router hands us in `useRouteError().data` for non-Response errors.
*/
function extractRequestIdFromUnknown(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
if (typeof b.request_id === "string") return b.request_id;
const errors = b.errors;
if (Array.isArray(errors) && errors.length > 0) {
const first = errors[0];
if (first && typeof first === "object") {
const rec = first as Record<string, unknown>;
if (typeof rec.request_id === "string") return rec.request_id;
if (typeof rec.detail === "string") {
const match = rec.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i);
if (match) return match[1];
}
}
}
return null;
}

View file

@ -1,29 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { graphTheme } from "../lib/graph-theme";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import { useRunGraph, useRunStages } from "../lib/queries";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import {
GRAPH_DEFAULT_ZOOM_INDEX,
GRAPH_ZOOM_STEPS,
GraphToolbar,
} from "../components/graph-toolbar";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
function mapStages(stagesResult: ReturnType<typeof useRunStages>["data"]): Stage[] {
return (stagesResult?.data ?? []).filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
dotId: s.dot_id ?? s.id,
status: s.status as Stage["status"],
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
}));
}
type Direction = "LR" | "TB";
function buildDot(direction: Direction) {
@ -86,7 +74,10 @@ export default function RunGraph() {
const [direction, setDirection] = useState<Direction>("LR");
const stagesQuery = useRunStages(id);
const graphQuery = useRunGraph(id, direction);
const stages = mapStages(stagesQuery.data);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const graphSvg = graphQuery.data;
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
@ -98,10 +89,19 @@ export default function RunGraph() {
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
useEffect(() => {
if (graphSvg === undefined && !graphQuery.error) return;
let cancelled = false;
async function render() {
try {
setError(null);
if (graphQuery.error) {
setError("Failed to load graph");
return;
}
let svg: SVGSVGElement;
if (graphSvg) {
@ -135,7 +135,7 @@ export default function RunGraph() {
setPan({ x: 0, y: 0 });
render();
return () => { cancelled = true; };
}, [direction, graphSvg, id]);
}, [direction, graphQuery.error, graphSvg]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;

View file

@ -1,7 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import { graphTheme } from "../lib/graph-theme";
import { useRun, useRunGraph, useRunStages } from "../lib/queries";
import { StageSidebar } from "../components/stage-sidebar";
@ -12,19 +10,10 @@ import {
GraphToolbar,
} from "../components/graph-toolbar";
import { EmptyState } from "../components/state";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
function mapStages(stagesResult: ReturnType<typeof useRunStages>["data"]): Stage[] {
return (stagesResult?.data ?? []).filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
status: s.status as Stage["status"],
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
dotId: s.dot_id ?? s.id,
}));
}
type Direction = "LR" | "TB";
export default function RunOverview() {
@ -33,7 +22,10 @@ export default function RunOverview() {
const stagesQuery = useRunStages(id);
const graphQuery = useRunGraph(id, direction);
const runQuery = useRun(id);
const stages = mapStages(stagesQuery.data);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const graphSvg = graphQuery.data;
const runStatus = runQuery.data?.status?.kind ?? null;
const containerRef = useRef<HTMLDivElement>(null);
@ -193,7 +185,9 @@ export default function RunOverview() {
<StageSidebar stages={stages} runId={id!} />
<div className="min-w-0 flex-1">
{graphSvg ? (
{graphSvg === undefined && graphQuery.isLoading ? (
<div className="py-12" />
) : graphSvg ? (
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
<GraphToolbar
direction={direction}

View file

@ -1,29 +1,21 @@
import { useMemo } from "react";
import { useParams } from "react-router";
import { CollapsibleFile } from "../components/collapsible-file";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import { useRunSettings, useRunStages } from "../lib/queries";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
type WorkflowSettingsSnapshot = Record<string, unknown>;
function mapStages(stagesResult: PaginatedRunStageList | null | undefined): Stage[] {
return (stagesResult?.data ?? []).filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
status: s.status as Stage["status"],
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
}));
}
export default function RunSettingsPage() {
const { id } = useParams();
const stagesQuery = useRunStages(id);
const settingsQuery = useRunSettings<WorkflowSettingsSnapshot>(id);
const stages = mapStages(stagesQuery.data);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const settings = settingsQuery.data ?? {};
return (

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { Marked } from "marked";
@ -39,10 +39,10 @@ import { StageSidebar, statusConfig } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
import { CopyButton } from "../components/ui";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import { useRunEventsList, useRunStageTurns, useRunStages } from "../lib/queries";
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
import type { StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client";
export const handle = { wide: true };
@ -147,15 +147,6 @@ function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] {
return turns;
}
function mapStages(stagesResult: PaginatedRunStageList | null | undefined): Stage[] {
return (stagesResult?.data ?? []).filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
status: s.status as Stage["status"],
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
}));
}
function mapTurns(
turnsResult: PaginatedStageTurnList | null | undefined,
eventsResult: PaginatedEventList | null | undefined,
@ -365,35 +356,57 @@ function CommandBlock({ turn }: { turn: Extract<TurnType, { kind: "command" }> }
);
}
export default function RunStages() {
const { id, stageId } = useParams();
const stagesQuery = useRunStages(id);
const stages = mapStages(stagesQuery.data);
const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0];
const turnsQuery = useRunStageTurns(id, selectedStage?.id);
const eventsQuery = useRunEventsList(id, !!selectedStage?.id && !turnsQuery.data?.data?.length);
const turns = mapTurns(turnsQuery.data, eventsQuery.data, selectedStage?.id);
const isRunning = selectedStage?.status === "running";
// Ticking timer for running stage header
const runningStartRef = useRef(isRunning ? Date.now() : 0);
function RunningStageDuration({
isRunning,
duration,
}: {
isRunning: boolean;
duration: string;
}) {
const [startedAt, setStartedAt] = useState<number | null>(() =>
isRunning ? Date.now() : null,
);
const [, setTick] = useState(0);
useEffect(() => {
if (isRunning && runningStartRef.current === 0) {
runningStartRef.current = Date.now();
} else if (!isRunning) {
runningStartRef.current = 0;
}
setStartedAt((current) => {
if (!isRunning) return null;
return current ?? Date.now();
});
}, [isRunning]);
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
const interval = setInterval(() => setTick((tick) => tick + 1), 1000);
return () => clearInterval(interval);
}, [isRunning]);
if (isRunning && startedAt) {
return formatDurationSecs(Math.floor((Date.now() - startedAt) / 1000));
}
return duration;
}
export default function RunStages() {
const { id, stageId } = useParams();
const stagesQuery = useRunStages(id);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0];
const turnsQuery = useRunStageTurns(id, selectedStage?.id);
const hasStageTurns = (turnsQuery.data?.data.length ?? 0) > 0;
const shouldLoadEventFallback =
!!selectedStage?.id && !turnsQuery.isLoading && !turnsQuery.error && !hasStageTurns;
const eventsQuery = useRunEventsList(id, shouldLoadEventFallback);
const turns = useMemo(
() => mapTurns(turnsQuery.data, eventsQuery.data, selectedStage?.id),
[eventsQuery.data, selectedStage?.id, turnsQuery.data],
);
const isRunning = selectedStage?.status === "running";
if (!stages.length) {
return (
<div className="py-12">
@ -407,9 +420,6 @@ export default function RunStages() {
const selectedConfig = statusConfig[selectedStage.status];
const SelectedIcon = selectedConfig.icon;
const headerDuration = isRunning && runningStartRef.current
? formatDurationSecs(Math.floor((Date.now() - runningStartRef.current) / 1000))
: selectedStage.duration;
return (
<div className="flex gap-6">
@ -419,7 +429,12 @@ export default function RunStages() {
<div className="sticky top-0 z-10 -mx-2 flex items-center gap-2 bg-page/85 px-2 py-2 backdrop-blur">
<SelectedIcon className={`size-5 ${selectedConfig.color} ${isRunning ? "animate-spin" : ""}`} />
<h3 className="text-base font-semibold text-fg">{selectedStage.name}</h3>
<span className="font-mono text-xs tabular-nums text-fg-muted">{headerDuration}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">
<RunningStageDuration
isRunning={isRunning}
duration={selectedStage.duration}
/>
</span>
</div>
{turns.map((turn: TurnType, i: number) => {

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-zm3ds8ft.js"></script>
<script type="module" src="/assets/entry-axjk9fdf.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>