mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
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:
parent
a1f032e166
commit
d4104be841
18 changed files with 475 additions and 433 deletions
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
164
apps/fabro-web/app/lib/sse.ts
Normal file
164
apps/fabro-web/app/lib/sse.ts
Normal 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);
|
||||
}
|
||||
21
apps/fabro-web/app/lib/stage-sidebar.ts
Normal file
21
apps/fabro-web/app/lib/stage-sidebar.ts
Normal 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)
|
||||
: "--",
|
||||
}));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue