diff --git a/apps/fabro-web/app/api.test.ts b/apps/fabro-web/app/api.test.ts deleted file mode 100644 index 52824a779..000000000 --- a/apps/fabro-web/app/api.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; -import { apiPaginatedJson, getAuthConfig, isNotAvailable, loginDevToken } from "./api"; - -afterEach(() => { - mock.restore(); -}); - -describe("isNotAvailable", () => { - test("returns true for 501 status", () => { - expect(isNotAvailable(501)).toBe(true); - }); - - test("returns true for 404 status", () => { - expect(isNotAvailable(404)).toBe(true); - }); - - test("returns false for 200 status", () => { - expect(isNotAvailable(200)).toBe(false); - }); -}); - -describe("auth helpers", () => { - test("getAuthConfig fetches auth methods without triggering auth redirect behavior", async () => { - const fetchMock = mock(() => - Promise.resolve( - new Response(JSON.stringify({ methods: ["dev-token"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), - ); - globalThis.fetch = fetchMock as typeof fetch; - - const result = await getAuthConfig(); - - expect(result).toEqual({ methods: ["dev-token"] }); - expect(fetchMock).toHaveBeenCalledWith("/api/v1/auth/config", { - credentials: "include", - }); - }); - - test("loginDevToken posts the token payload", async () => { - const fetchMock = mock(() => - Promise.resolve( - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), - ); - globalThis.fetch = fetchMock as typeof fetch; - - const result = await loginDevToken("fabro_dev_token"); - - expect(result).toEqual({ ok: true }); - expect(fetchMock).toHaveBeenCalledWith("/auth/login/dev-token", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: "fabro_dev_token" }), - }); - }); -}); - -describe("apiPaginatedJson", () => { - test("loads and concatenates all pages while preserving first-page extras", async () => { - const fetchMock = mock((input: string | URL | Request) => { - const url = String(input); - if (url.includes("page%5Boffset%5D=0")) { - return Promise.resolve( - new Response( - JSON.stringify({ - columns: [{ id: "running", name: "Running" }], - data: [{ id: "run-1" }, { id: "run-2" }], - meta: { has_more: true }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ), - ); - } - - return Promise.resolve( - new Response( - JSON.stringify({ - columns: [{ id: "ignored", name: "Ignored" }], - data: [{ id: "run-3" }], - meta: { has_more: false }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ), - ); - }); - globalThis.fetch = fetchMock as typeof fetch; - - const result = await apiPaginatedJson<{ id: string }, { columns: { id: string; name: string }[] }>( - "/boards/runs", - ); - - expect(result.columns).toEqual([{ id: "running", name: "Running" }]); - expect(result.data).toEqual([{ id: "run-1" }, { id: "run-2" }, { id: "run-3" }]); - expect(result.meta).toEqual({ has_more: false }); - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "/api/v1/boards/runs?page%5Blimit%5D=100&page%5Boffset%5D=0", - { - credentials: "include", - headers: undefined, - }, - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - "/api/v1/boards/runs?page%5Blimit%5D=100&page%5Boffset%5D=2", - { - credentials: "include", - headers: undefined, - }, - ); - }); - - test("stops after a bounded number of pages when the server keeps advertising more data", async () => { - const warnMock = mock(() => {}); - const originalWarn = console.warn; - console.warn = warnMock; - - let callCount = 0; - const fetchMock = mock(() => { - callCount += 1; - if (callCount > 50) { - throw new Error("apiPaginatedJson should have stopped at the page cap"); - } - - return Promise.resolve( - new Response( - JSON.stringify({ - data: [{ id: `run-${callCount}` }], - meta: { has_more: true }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ), - ); - }); - globalThis.fetch = fetchMock as typeof fetch; - - try { - const result = await apiPaginatedJson<{ id: string }>("/boards/runs"); - - expect(result.data).toHaveLength(50); - expect(result.meta).toEqual({ has_more: true }); - expect(warnMock).toHaveBeenCalledTimes(1); - } finally { - console.warn = originalWarn; - } - }); -}); diff --git a/apps/fabro-web/app/api.ts b/apps/fabro-web/app/api.ts deleted file mode 100644 index 96aa1d72c..000000000 --- a/apps/fabro-web/app/api.ts +++ /dev/null @@ -1,178 +0,0 @@ -export interface ApiOptions { - init?: RequestInit; - request?: Request; -} - -export interface PaginatedEnvelope { - data: T[]; - meta: { has_more: boolean }; -} - -const PAGINATED_API_MAX_PAGES = 50; -const PAGINATED_API_MAX_ITEMS = 5000; - -function buildApiPath(path: string): string { - return `/api/v1${path}`; -} - -function buildPaginatedApiPath(path: string, limit: number, offset: number): string { - const url = new URL(buildApiPath(path), "http://fabro.local"); - url.searchParams.set("page[limit]", String(limit)); - url.searchParams.set("page[offset]", String(offset)); - return `${url.pathname}${url.search}`; -} - -export async function apiFetch(path: string, options?: ApiOptions): Promise { - const { init } = options ?? {}; - const response = await fetch(buildApiPath(path), { - ...init, - credentials: "include", - headers: init?.headers, - }); - - if (response.status === 401) { - window.location.href = "/login"; - throw new Error("Unauthorized"); - } - - return response; -} - -export async function apiJson(path: string, options?: ApiOptions): Promise { - const response = await apiFetch(path, options); - if (!response.ok) { - throw new Response(null, { status: response.status, statusText: response.statusText }); - } - return response.json() as Promise; -} - -export async function apiPaginatedJson( - path: string, - options?: ApiOptions, -): Promise & TExtra> { - const limit = 100; - let offset = 0; - const data: TItem[] = []; - let extras: TExtra | null = null; - let pagesLoaded = 0; - - while (true) { - const response = await fetch(buildPaginatedApiPath(path, limit, offset), { - ...options?.init, - credentials: "include", - headers: options?.init?.headers, - }); - - if (response.status === 401) { - window.location.href = "/login"; - throw new Error("Unauthorized"); - } - if (!response.ok) { - throw new Response(null, { status: response.status, statusText: response.statusText }); - } - - const page = (await response.json()) as PaginatedEnvelope & TExtra; - if (extras == null) { - const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope & - Record; - extras = rest as TExtra; - } - - pagesLoaded += 1; - const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length; - const pageItems = remainingItemBudget > 0 ? page.data.slice(0, remainingItemBudget) : []; - data.push(...pageItems); - if (!page.meta.has_more || page.data.length === 0) { - return { - ...(extras ?? ({} as TExtra)), - data, - meta: { has_more: false }, - }; - } - if ( - pagesLoaded >= PAGINATED_API_MAX_PAGES || - pageItems.length < page.data.length || - data.length >= PAGINATED_API_MAX_ITEMS - ) { - console.warn( - `Stopped paginated API fetch for ${path} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, - ); - return { - ...(extras ?? ({} as TExtra)), - data, - meta: { has_more: true }, - }; - } - - offset += page.data.length; - } -} - -export function isNotAvailable(status: number): boolean { - return status === 404 || status === 501; -} - -export async function apiJsonOrNull( - path: string, - options?: ApiOptions, -): Promise { - const response = await apiFetch(path, options); - if (isNotAvailable(response.status)) { - return null; - } - if (!response.ok) { - throw new Response(null, { - status: response.status, - statusText: response.statusText, - }); - } - return response.json() as Promise; -} - -export async function getAuthConfig(): Promise<{ methods: string[] }> { - const response = await fetch(buildApiPath("/auth/config"), { credentials: "include" }); - if (!response.ok) { - throw new Response(null, { status: response.status, statusText: response.statusText }); - } - return response.json(); -} - -export async function loginDevToken(token: string): Promise<{ ok: boolean }> { - const response = await fetch("/auth/login/dev-token", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }), - }); - if (!response.ok) { - throw new Response(null, { status: response.status, statusText: response.statusText }); - } - return response.json(); -} - -export async function getAuthMe(): Promise<{ - user: { - login: string; - name: string; - email: string; - avatarUrl: string; - userUrl: string; - }; - provider: string; - demoMode: boolean; -}> { - const response = await fetch(buildApiPath("/auth/me"), { credentials: "include" }); - if (response.status === 401) { - throw new Response(null, { status: 401, statusText: "Unauthorized" }); - } - if (!response.ok) { - throw new Response(null, { status: response.status, statusText: response.statusText }); - } - return response.json(); -} - -export async function getSystemInfo(): Promise<{ - features: { session_sandboxes: boolean; retros: boolean }; -}> { - return apiJson("/system/info"); -} diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index f6cc55105..aba87efa1 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -9,7 +9,6 @@ import { } from "@heroicons/react/24/solid"; import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline"; import { formatDurationSecs } from "../lib/format"; -import { useRunEventSource } from "../lib/sse"; export type StageStatus = "completed" | "running" | "pending" | "failed" | "cancelled"; @@ -36,23 +35,11 @@ interface StageSidebarProps { activeLink?: "settings" | "graph"; } -const STAGE_EVENTS = new Set([ - "stage.started", "stage.completed", "stage.failed", - "run.completed", "run.failed", - "command.started", "command.completed", -]); - export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) { // Track when we first observed each running stage (for ticking timer) const runningStartRef = useRef>(new Map()); const [, setTick] = useState(0); - // Subscribe to run-specific SSE for live stage updates - useRunEventSource(runId, { - allowlist: STAGE_EVENTS, - debounceMs: 300, - }); - // Track start times for running stages useEffect(() => { const running = new Set( diff --git a/apps/fabro-web/app/entry.tsx b/apps/fabro-web/app/entry.tsx index d043cfc03..a02ce2845 100644 --- a/apps/fabro-web/app/entry.tsx +++ b/apps/fabro-web/app/entry.tsx @@ -1,7 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; +import { SWRConfig } from "swr"; import { installRoutes } from "./install-router"; +import { apiFetcher } from "./lib/api-client"; import { resolveFabroMode } from "./mode"; import { routes } from "./router"; @@ -22,6 +24,15 @@ if (!rootElement) { createRoot(rootElement).render( - + + + , ); diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index 4c2b81a71..7d2fa02ed 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -16,14 +16,12 @@ import { RectangleStackIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { Link, Outlet, useLocation, useMatches, useRevalidator } from "react-router"; -import { getAuthMe } from "../api"; +import { Link, Outlet, useLocation, useMatches } from "react-router"; +import { ErrorState } from "../components/state"; import { ToastProvider } from "../components/toast"; import { DemoModeProvider } from "../lib/demo-mode"; - -export async function loader() { - return getAuthMe(); -} +import { useToggleDemoMode } from "../lib/mutations"; +import { useAuthMe } from "../lib/queries"; const allNavigation = [ { name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true }, @@ -40,11 +38,28 @@ function classNames(...classes: Array) { return classes.filter(Boolean).join(" "); } -export default function AppShell({ loaderData }: any) { - const { user, provider, demoMode } = loaderData; +export default function AppShell() { + const { data: auth, error, isLoading } = useAuthMe(); const { pathname } = useLocation(); const matches = useMatches(); - const revalidator = useRevalidator(); + const toggleDemoModeMutation = useToggleDemoMode(); + + if (isLoading && !auth) { + return
; + } + + if (error || !auth) { + return ( +
+ +
+ ); + } + + const { user, provider, demoMode } = auth; const navigation = getVisibleNavigation(demoMode); const currentNav = navigation.find((item) => pathname.startsWith(item.href)); const title = currentNav?.name ?? ""; @@ -56,13 +71,7 @@ export default function AppShell({ loaderData }: any) { const maxWidth = wide ? "" : "max-w-5xl"; async function toggleDemoMode() { - await fetch("/api/v1/demo/toggle", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: !demoMode }), - }); - revalidator.revalidate(); + await toggleDemoModeMutation.trigger({ enabled: !demoMode }); } return ( diff --git a/apps/fabro-web/app/lib/api-client.test.ts b/apps/fabro-web/app/lib/api-client.test.ts new file mode 100644 index 000000000..a05eca622 --- /dev/null +++ b/apps/fabro-web/app/lib/api-client.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import { + ApiError, + apiFetcher, + apiNullableFetcher, + apiPaginatedFetcher, + apiRequest, + extractRequestId, +} from "./api-client"; + +afterEach(() => { + mock.restore(); + delete (globalThis as { window?: unknown }).window; +}); + +describe("apiRequest", () => { + test("includes credentials on API requests", async () => { + const fetchMock = mock(() => Promise.resolve(new Response("{}", { status: 200 }))); + globalThis.fetch = fetchMock as typeof fetch; + + await apiRequest("/api/v1/runs/run-1"); + + expect(fetchMock).toHaveBeenCalledWith("/api/v1/runs/run-1", { + credentials: "include", + headers: undefined, + }); + }); + + test("401 responses throw a typed ApiError", async () => { + const fetchMock = mock(() => + Promise.resolve( + new Response(JSON.stringify({ errors: [{ detail: "Request ID: req_401" }] }), { + status: 401, + statusText: "Unauthorized", + headers: { "Content-Type": "application/json" }, + }), + ), + ); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(apiRequest("/api/v1/auth/me")).rejects.toMatchObject({ + status: 401, + requestId: "req_401", + }); + }); +}); + +describe("apiFetcher", () => { + test("throws ApiError with status, body, and request id on non-2xx responses", async () => { + const body = { + errors: [{ status: "500", title: "Internal", request_id: "req_500" }], + }; + const fetchMock = mock(() => + Promise.resolve( + new Response(JSON.stringify(body), { + status: 500, + statusText: "Internal Server Error", + headers: { "Content-Type": "application/json" }, + }), + ), + ); + globalThis.fetch = fetchMock as typeof fetch; + + try { + await apiFetcher("/api/v1/runs/run-1/files"); + throw new Error("expected apiFetcher to reject"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + status: 500, + message: "Internal Server Error", + requestId: "req_500", + body, + }); + } + }); +}); + +describe("apiNullableFetcher", () => { + test("returns null only for explicit availability statuses", async () => { + const fetchMock = mock(() => Promise.resolve(new Response("", { status: 501 }))); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(apiNullableFetcher("/api/v1/runs/run-1/files")).resolves.toBeNull(); + }); +}); + +describe("apiPaginatedFetcher", () => { + test("preserves first-page extras and stops at the page cap", async () => { + const warnMock = mock(() => {}); + const originalWarn = console.warn; + console.warn = warnMock; + let calls = 0; + const fetchMock = mock(() => { + calls += 1; + return Promise.resolve( + new Response( + JSON.stringify({ + columns: [{ id: "running", name: "Running" }], + data: [{ id: `run-${calls}` }], + meta: { has_more: true }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + try { + const result = await apiPaginatedFetcher<{ id: string }, { columns: { id: string; name: string }[] }>( + "/api/v1/boards/runs", + ); + + expect(result.columns).toEqual([{ id: "running", name: "Running" }]); + expect(result.data).toHaveLength(50); + expect(result.meta.has_more).toBe(true); + expect(warnMock).toHaveBeenCalledTimes(1); + } finally { + console.warn = originalWarn; + } + }); +}); + +describe("extractRequestId", () => { + test("supports top-level, error-level, and detail-embedded request ids", () => { + expect(extractRequestId({ request_id: "top" })).toBe("top"); + expect(extractRequestId({ errors: [{ request_id: "nested" }] })).toBe("nested"); + expect(extractRequestId({ errors: [{ detail: "Request ID: req-detail" }] })).toBe("req-detail"); + }); +}); diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts new file mode 100644 index 000000000..bd990361b --- /dev/null +++ b/apps/fabro-web/app/lib/api-client.ts @@ -0,0 +1,236 @@ +export interface ApiOptions { + init?: RequestInit; + request?: Request; +} + +export interface PaginatedEnvelope { + data: T[]; + meta: { has_more: boolean }; +} + +export class ApiError extends Error { + readonly status: number; + readonly requestId: string | null; + readonly body: unknown; + + constructor({ + status, + message, + requestId, + body, + }: { + status: number; + message: string; + requestId: string | null; + body: unknown; + }) { + super(message); + this.name = "ApiError"; + this.status = status; + this.requestId = requestId; + this.body = body; + } +} + +const API_PREFIX = "/api/v1"; +const PAGINATED_API_MAX_PAGES = 50; +const PAGINATED_API_MAX_ITEMS = 5000; + +export function apiPath(path: string): string { + return path.startsWith(API_PREFIX) ? path : `${API_PREFIX}${path}`; +} + +export function isNotAvailable(status: number): boolean { + return status === 404 || status === 501; +} + +export function extractRequestId(body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const record = body as Record; + if (typeof record.request_id === "string") return record.request_id; + if (typeof record.requestId === "string") return record.requestId; + + const errors = record.errors; + if (!Array.isArray(errors) || errors.length === 0) return null; + + const first = errors[0]; + if (!first || typeof first !== "object") return null; + const error = first as Record; + if (typeof error.request_id === "string") return error.request_id; + if (typeof error.requestId === "string") return error.requestId; + if (typeof error.detail === "string") { + const match = error.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i); + if (match) return match[1]; + } + return null; +} + +function requestIdFromHeaders(headers: Headers): string | null { + return ( + headers.get("x-request-id") ?? + headers.get("x-fabro-request-id") ?? + headers.get("request-id") + ); +} + +async function parseResponseBody(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text().catch(() => ""); + if (!text) return null; + if (contentType.includes("json")) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function apiErrorFromResponse(response: Response): Promise { + const body = await parseResponseBody(response); + const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body); + return new ApiError({ + status: response.status, + message: response.statusText || `HTTP ${response.status}`, + requestId, + body, + }); +} + +export async function apiRequest(path: string, options?: ApiOptions): Promise { + const { init, request } = options ?? {}; + const response = await fetch(apiPath(path), { + ...init, + credentials: "include", + headers: init?.headers, + ...(request?.signal ? { signal: request.signal } : {}), + }); + + if (response.status === 401) { + if (typeof window !== "undefined") { + window.location.href = "/login"; + } + throw await apiErrorFromResponse(response); + } + + return response; +} + +export async function apiFetcher(key: string): Promise { + const response = await apiRequest(key); + if (!response.ok) { + throw await apiErrorFromResponse(response); + } + if (response.status === 204) return undefined as T; + return response.json() as Promise; +} + +export async function apiTextFetcher(key: string): Promise { + const response = await apiRequest(key); + if (!response.ok) { + throw await apiErrorFromResponse(response); + } + return response.text(); +} + +export async function apiNullableFetcher(key: string): Promise { + const response = await apiRequest(key); + if (isNotAvailable(response.status)) return null; + if (!response.ok) { + throw await apiErrorFromResponse(response); + } + return response.json() as Promise; +} + +export async function apiNullableTextFetcher(key: string): Promise { + const response = await apiRequest(key); + if (isNotAvailable(response.status)) return null; + if (!response.ok) { + throw await apiErrorFromResponse(response); + } + return response.text(); +} + +function paginatedApiPath(key: string, limit: number, offset: number): string { + const url = new URL(apiPath(key), "http://fabro.local"); + url.searchParams.set("page[limit]", String(limit)); + url.searchParams.set("page[offset]", String(offset)); + return `${url.pathname}${url.search}`; +} + +export async function apiPaginatedFetcher( + key: string, +): Promise & TExtra> { + const limit = 100; + let offset = 0; + const data: TItem[] = []; + let extras: TExtra | null = null; + let pagesLoaded = 0; + + while (true) { + const response = await apiRequest(paginatedApiPath(key, limit, offset)); + if (!response.ok) { + throw await apiErrorFromResponse(response); + } + + const page = (await response.json()) as PaginatedEnvelope & TExtra; + if (extras == null) { + const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope & + Record; + extras = rest as TExtra; + } + + pagesLoaded += 1; + const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length; + const pageItems = remainingItemBudget > 0 ? page.data.slice(0, remainingItemBudget) : []; + data.push(...pageItems); + + if (!page.meta.has_more || page.data.length === 0) { + return { + ...(extras ?? ({} as TExtra)), + data, + meta: { has_more: false }, + }; + } + + if ( + pagesLoaded >= PAGINATED_API_MAX_PAGES || + pageItems.length < page.data.length || + data.length >= PAGINATED_API_MAX_ITEMS + ) { + console.warn( + `Stopped paginated API fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, + ); + return { + ...(extras ?? ({} as TExtra)), + data, + meta: { has_more: true }, + }; + } + + offset += page.data.length; + } +} + +export async function apiJsonMutation( + key: string, + { arg }: { arg: TArg }, +): Promise { + const response = await apiRequest(key, { + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: arg === undefined ? undefined : JSON.stringify(arg), + }, + }); + if (!response.ok) { + throw await apiErrorFromResponse(response); + } + if (response.status === 204) return undefined as TResponse; + return response.json() as Promise; +} diff --git a/apps/fabro-web/app/lib/board-events.test.tsx b/apps/fabro-web/app/lib/board-events.test.tsx new file mode 100644 index 000000000..853d300b9 --- /dev/null +++ b/apps/fabro-web/app/lib/board-events.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { + shouldRefreshBoardForEvent, + subscribeToBoardEvents, +} from "./board-events"; +import { queryKeys } from "./query-keys"; + +type MessageHandler = ((event: { data: string }) => void) | null; + +class FakeEventSource { + onmessage: MessageHandler = null; + closed = false; + + emit(payload: unknown) { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + close() { + this.closed = true; + } +} + +describe("shouldRefreshBoardForEvent", () => { + test("refreshes board for run and interview status changes only", () => { + expect(shouldRefreshBoardForEvent("run.running")).toBe(true); + expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true); + expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true); + expect(shouldRefreshBoardForEvent("checkpoint.completed")).toBe(false); + }); +}); + +describe("subscribeToBoardEvents", () => { + test("shares one source and invalidates the board runs key", () => { + const source = new FakeEventSource(); + const created: string[] = []; + const keys: string[] = []; + const mutate = (key: string) => { + keys.push(key); + return Promise.resolve(); + }; + + 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" }); + + expect(created).toEqual(["/api/v1/attach"]); + expect(keys).toEqual([queryKeys.boards.runs()]); + + firstCleanup(); + expect(source.closed).toBe(false); + secondCleanup(); + expect(source.closed).toBe(true); + }); +}); diff --git a/apps/fabro-web/app/lib/board-events.ts b/apps/fabro-web/app/lib/board-events.ts new file mode 100644 index 000000000..ef30009b1 --- /dev/null +++ b/apps/fabro-web/app/lib/board-events.ts @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { useSWRConfig } from "swr"; + +import { queryKeys } from "./query-keys"; +import { + createBrowserEventSource, + subscribeToSharedEventSource, + type EventPayload, + type EventSourceLike, + type MutateFn, + type SharedEventSubscription, +} from "./sse"; + +const BOARD_STATUS_EVENTS = new Set([ + "run.submitted", + "run.queued", + "run.starting", + "run.running", + "run.removing", + "run.paused", + "run.unpaused", + "run.blocked", + "run.unblocked", + "run.completed", + "run.failed", + "run.archived", + "run.unarchived", + "interview.started", + "interview.completed", + "interview.timeout", + "interview.interrupted", +]); + +const subscriptions = new Map(); +const BOARD_SUBSCRIPTION_KEY = "board"; + +export function shouldRefreshBoardForEvent(event: string) { + return BOARD_STATUS_EVENTS.has(event); +} + +export function subscribeToBoardEvents( + mutate: MutateFn, + eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource, + { debounceMs = 500 }: { debounceMs?: number } = {}, +): () => void { + return subscribeToSharedEventSource({ + 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() { + const { mutate } = useSWRConfig(); + + useEffect(() => subscribeToBoardEvents(mutate as MutateFn), [mutate]); +} diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts new file mode 100644 index 000000000..249683d95 --- /dev/null +++ b/apps/fabro-web/app/lib/mutations.ts @@ -0,0 +1,127 @@ +import useSWRMutation from "swr/mutation"; +import { useSWRConfig } from "swr"; +import type { + PreviewUrlResponse, + RunStatusResponse, +} from "@qltysh/fabro-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"; + +export type PreviewRunArg = { + port: number; + expires_in_secs: number; +}; + +export type PreviewMutationResult = { + intent: "preview"; + url: string; +}; + +export type LifecycleMutationResult = + | { + intent: LifecycleAction; + ok: true; + run: RunStatusResponse; + } + | { + intent: LifecycleAction; + ok: false; + error: LifecycleActionError | null; + }; + +export function usePreviewRun(id: string | undefined) { + return useSWRMutation( + id ? queryKeys.runs.preview(id) : null, + async (key: string, { arg }: { arg: PreviewRunArg }): Promise => { + const result = await apiJsonMutation(key, { arg }); + return { intent: "preview", url: result.url }; + }, + ); +} + +export function useCancelRun(id: string | undefined) { + return useLifecycleMutation(id, "cancel", cancelRun); +} + +export function useArchiveRun(id: string | undefined) { + return useLifecycleMutation(id, "archive", archiveRun); +} + +export function useUnarchiveRun(id: string | undefined) { + return useLifecycleMutation(id, "unarchive", unarchiveRun); +} + +function useLifecycleMutation( + id: string | undefined, + intent: LifecycleAction, + action: (id: string) => Promise, +) { + const { mutate } = useSWRConfig(); + const key = id ? queryKeys.runs[intent](id) : null; + return useSWRMutation( + key, + async (): Promise => { + if (!id) { + return { intent, ok: false, error: null }; + } + try { + return { intent, ok: true, run: await action(id) }; + } catch (error) { + return { + intent, + ok: false, + error: isLifecycleActionError(error) ? error : null, + }; + } + }, + { + onSuccess: (result) => { + if (!id || !result.ok) return; + void mutate(queryKeys.runs.detail(id)); + void mutate(queryKeys.boards.runs()); + void mutate(queryKeys.runs.billing(id)); + }, + }, + ); +} + +export function useToggleDemoMode() { + const { mutate } = useSWRConfig(); + return useSWRMutation( + queryKeys.demo.toggle(), + async (key: string, { arg }: { arg: { enabled: boolean } }) => { + await apiJsonMutation(key, { arg }); + }, + { + onSuccess: () => { + void mutate(queryKeys.auth.me()); + }, + }, + ); +} + +export function useLoginDevToken() { + return useSWRMutation( + "/auth/login/dev-token", + async (key: string, { arg }: { arg: { token: string } }) => { + const response = await fetch(key, { + 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.json() as Promise<{ ok: boolean }>; + }, + ); +} diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts new file mode 100644 index 000000000..b278686ec --- /dev/null +++ b/apps/fabro-web/app/lib/queries.ts @@ -0,0 +1,169 @@ +import useSWR, { type SWRConfiguration } from "swr"; +import type { + PaginatedBoardRunList, + PaginatedEventList, + PaginatedRunFileList, + PaginatedRunList, + PaginatedRunStageList, + PaginatedStageTurnList, + RunBilling, + ServerSettings, +} from "@qltysh/fabro-api-client"; + +import type { PaginatedWorkflowListResponse, WorkflowDetailResponse } from "./workflow-api"; +import { + apiFetcher, + apiNullableFetcher, + apiNullableTextFetcher, + apiPaginatedFetcher, + apiTextFetcher, + type PaginatedEnvelope, +} from "./api-client"; +import { queryKeys } from "./query-keys"; +import type { RunSummaryResponse } from "../data/runs"; + +const immutableOptions: SWRConfiguration = { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +export function useAuthConfig() { + return useSWR<{ methods: string[] }>(queryKeys.auth.config(), apiFetcher, immutableOptions); +} + +export function useAuthMe() { + return useSWR<{ + user: { + login: string; + name: string; + email: string; + avatarUrl: string; + userUrl: string; + }; + provider: string; + demoMode: boolean; + }>(queryKeys.auth.me(), apiFetcher, { dedupingInterval: 10_000 }); +} + +export function useSystemInfo() { + return useSWR<{ features: { session_sandboxes: boolean; retros: boolean } }>( + queryKeys.system.info(), + apiFetcher, + immutableOptions, + ); +} + +export function useBoardsRuns() { + return useSWR< + PaginatedEnvelope & { + columns: { id: string; name: string }[]; + } + >(queryKeys.boards.runs(), apiPaginatedFetcher); +} + +export function useRun(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.detail(id) : null, + apiNullableFetcher, + ); +} + +export function useRunFiles(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.files(id) : null, + apiNullableFetcher, + { keepPreviousData: true }, + ); +} + +export function useRunStages(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.stages(id) : null, + apiNullableFetcher, + ); +} + +export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") { + return useSWR( + id ? queryKeys.runs.graph(id, direction) : null, + apiNullableTextFetcher, + ); +} + +export function useRunSettings>(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.settings(id) : null, + apiFetcher, + immutableOptions, + ); +} + +export function useRunBilling(id: string | undefined) { + return useSWR(id ? queryKeys.runs.billing(id) : null, apiFetcher); +} + +export function useRunQuestionText(id: string | undefined, enabled: boolean) { + return useSWR( + id && enabled ? queryKeys.runs.questions(id, 1, 0) : null, + async (key) => { + const payload = await apiNullableFetcher<{ data: { text?: string | null }[] }>(key); + return payload?.data[0]?.text ?? null; + }, + ); +} + +export function useRunStageTurns( + id: string | undefined, + stageId: string | undefined, + enabled = true, +) { + return useSWR( + id && stageId && enabled ? queryKeys.runs.stageTurns(id, stageId) : null, + apiNullableFetcher, + ); +} + +export function useRunEventsList(id: string | undefined, enabled = true) { + return useSWR( + id && enabled ? queryKeys.runs.events(id, 1000) : null, + apiNullableFetcher, + ); +} + +export function useWorkflows() { + return useSWR( + queryKeys.workflows.list(), + apiNullableFetcher, + immutableOptions, + ); +} + +export function useWorkflow(name: string | undefined) { + return useSWR( + name ? queryKeys.workflows.detail(name) : null, + apiNullableFetcher, + immutableOptions, + ); +} + +export function useWorkflowRuns(name: string | undefined) { + return useSWR( + name ? queryKeys.workflows.runs(name) : null, + apiNullableFetcher, + ); +} + +export function useInsightsQueries() { + return useSWR(queryKeys.insights.queries(), apiFetcher, immutableOptions); +} + +export function useInsightsHistory() { + return useSWR(queryKeys.insights.history(), apiFetcher, immutableOptions); +} + +export function useServerSettings() { + return useSWR(queryKeys.settings.server(), apiFetcher, immutableOptions); +} + +export { apiTextFetcher }; diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts new file mode 100644 index 000000000..2b44658dd --- /dev/null +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; + +import { queryKeys } from "./query-keys"; +import { queryKeysForRunEvent } from "./run-events"; + +describe("queryKeys", () => { + test("uses API path strings as stable SWR keys", () => { + expect(queryKeys.auth.me()).toBe("/api/v1/auth/me"); + expect(queryKeys.runs.files("run 1")).toBe("/api/v1/runs/run%201/files"); + expect(queryKeys.runs.graph("run-1", "TB")).toBe("/api/v1/runs/run-1/graph?direction=TB"); + }); + + test("event-mapped keys match query hook resources", () => { + expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual([ + queryKeys.runs.files("run-1"), + ]); + expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([ + queryKeys.runs.stages("run-1"), + queryKeys.runs.events("run-1", 1000), + queryKeys.runs.graph("run-1", "LR"), + queryKeys.runs.graph("run-1", "TB"), + queryKeys.runs.detail("run-1"), + queryKeys.runs.stageTurns("run-1", "stage-1"), + ]); + }); +}); diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts new file mode 100644 index 000000000..c97a3d7e8 --- /dev/null +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -0,0 +1,64 @@ +function pathSegment(value: string): string { + return encodeURIComponent(value); +} + +function withQuery(path: string, params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value != null) search.set(key, String(value)); + } + const query = search.toString(); + return query ? `${path}?${query}` : path; +} + +export const queryKeys = { + auth: { + config: () => "/api/v1/auth/config", + me: () => "/api/v1/auth/me", + }, + demo: { + toggle: () => "/api/v1/demo/toggle", + }, + system: { + info: () => "/api/v1/system/info", + attach: () => "/api/v1/attach", + }, + boards: { + runs: () => "/api/v1/boards/runs", + }, + runs: { + detail: (id: string) => `/api/v1/runs/${pathSegment(id)}`, + files: (id: string) => `/api/v1/runs/${pathSegment(id)}/files`, + stages: (id: string) => `/api/v1/runs/${pathSegment(id)}/stages`, + graph: (id: string, direction?: "LR" | "TB") => + withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }), + settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`, + billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`, + questions: (id: string, limit = 1, offset = 0) => + withQuery(`/api/v1/runs/${pathSegment(id)}/questions`, { + "page[limit]": limit, + "page[offset]": offset, + }), + events: (id: string, limit = 1000) => + withQuery(`/api/v1/runs/${pathSegment(id)}/events`, { limit }), + stageTurns: (id: string, stageId: string) => + `/api/v1/runs/${pathSegment(id)}/stages/${pathSegment(stageId)}/turns`, + preview: (id: string) => `/api/v1/runs/${pathSegment(id)}/preview`, + 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", + detail: (name: string) => `/api/v1/workflows/${pathSegment(name)}`, + runs: (name: string) => `/api/v1/workflows/${pathSegment(name)}/runs`, + }, + insights: { + queries: () => "/api/v1/insights/queries", + history: () => "/api/v1/insights/history", + }, + settings: { + server: () => "/api/v1/settings", + }, +}; diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index d679574a3..ff7fb9f10 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -1,6 +1,7 @@ import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client"; -import { apiFetch } from "../api"; +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 { - const response = await apiFetch(`/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; return typeof record.status === "number" && Array.isArray(record.errors); diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx new file mode 100644 index 000000000..14acf4f23 --- /dev/null +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; + +import { + queryKeysForRunEvent, + subscribeToRunEvents, +} from "./run-events"; +import { queryKeys } from "./query-keys"; + +type MessageHandler = ((event: { data: string }) => void) | null; + +class FakeEventSource { + onmessage: MessageHandler = null; + closed = false; + + emit(payload: unknown) { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + emitRaw(data: string) { + this.onmessage?.({ data }); + } + + close() { + this.closed = true; + } +} + +describe("queryKeysForRunEvent", () => { + test("terminal events invalidate run-scoped resources", () => { + expect(queryKeysForRunEvent("run-1", "run.completed")).toEqual([ + queryKeys.runs.detail("run-1"), + queryKeys.runs.files("run-1"), + queryKeys.runs.billing("run-1"), + queryKeys.runs.stages("run-1"), + queryKeys.runs.graph("run-1", "LR"), + queryKeys.runs.graph("run-1", "TB"), + ]); + }); +}); + +describe("subscribeToRunEvents", () => { + test("refcounts shared sources and keeps mutators active until final unsubscribe", () => { + const source = new FakeEventSource(); + const created: string[] = []; + const keys: string[] = []; + const mutate = (key: string) => { + keys.push(key); + return Promise.resolve(); + }; + + 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"]); + + firstCleanup(); + source.emit({ event: "checkpoint.completed" }); + + expect(source.closed).toBe(false); + expect(keys).toEqual([queryKeys.runs.files("run-refcount")]); + + secondCleanup(); + expect(source.closed).toBe(true); + }); + + test("terminal events close the source after invalidating keys", () => { + const source = new FakeEventSource(); + const keys: string[] = []; + const cleanup = subscribeToRunEvents( + "run-terminal", + (key) => { + keys.push(key); + return Promise.resolve(); + }, + () => source, + { debounceMs: 0 }, + ); + + source.emit({ event: "run.failed" }); + + expect(source.closed).toBe(true); + expect(keys).toContain(queryKeys.runs.files("run-terminal")); + expect(keys).toContain(queryKeys.runs.billing("run-terminal")); + + cleanup(); + }); + + test("malformed events are ignored and StrictMode-style cleanup does not underflow", () => { + const firstSource = new FakeEventSource(); + const secondSource = new FakeEventSource(); + const sources = [firstSource, secondSource]; + const keys: string[] = []; + + const firstCleanup = subscribeToRunEvents( + "run-strict", + (key) => { + keys.push(key); + return Promise.resolve(); + }, + () => sources.shift()!, + { debounceMs: 0 }, + ); + firstSource.emitRaw("{broken"); + firstCleanup(); + + const secondCleanup = subscribeToRunEvents( + "run-strict", + (key) => { + keys.push(key); + return Promise.resolve(); + }, + () => sources.shift()!, + { debounceMs: 0 }, + ); + secondCleanup(); + + expect(keys).toEqual([]); + expect(firstSource.closed).toBe(true); + expect(secondSource.closed).toBe(true); + }); +}); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts new file mode 100644 index 000000000..8b143690b --- /dev/null +++ b/apps/fabro-web/app/lib/run-events.ts @@ -0,0 +1,132 @@ +import { useEffect } from "react"; +import { useSWRConfig } from "swr"; + +import { queryKeys } from "./query-keys"; +import { + createBrowserEventSource, + subscribeToSharedEventSource, + type EventPayload, + type EventSourceLike, + type MutateFn, + type SharedEventSubscription, +} from "./sse"; + +interface RunEventPayload extends EventPayload { + event?: string; + node_id?: string; + properties?: Record; +} + +const subscriptions = new Map(); + +const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]); +const RUN_SUMMARY_EVENTS = new Set([ + "run.submitted", + "run.queued", + "run.starting", + "run.running", + "run.paused", + "run.unpaused", + "run.blocked", + "run.unblocked", + "run.archived", + "run.unarchived", +]); +const STAGE_EVENTS = new Set(["stage.started", "stage.completed", "stage.failed"]); +const COMMAND_EVENTS = new Set(["command.started", "command.completed"]); + +export function queryKeysForRunEvent( + runId: string, + event: string, + stageId?: string, +): string[] { + if (event === "checkpoint.completed") { + return [queryKeys.runs.files(runId)]; + } + + if (TERMINAL_EVENTS.has(event)) { + return [ + queryKeys.runs.detail(runId), + queryKeys.runs.files(runId), + queryKeys.runs.billing(runId), + queryKeys.runs.stages(runId), + queryKeys.runs.graph(runId, "LR"), + queryKeys.runs.graph(runId, "TB"), + ]; + } + + if (RUN_SUMMARY_EVENTS.has(event)) { + return [queryKeys.runs.detail(runId)]; + } + + if (STAGE_EVENTS.has(event)) { + const keys = [ + queryKeys.runs.stages(runId), + queryKeys.runs.events(runId, 1000), + queryKeys.runs.graph(runId, "LR"), + queryKeys.runs.graph(runId, "TB"), + queryKeys.runs.detail(runId), + ]; + if (stageId) { + keys.push(queryKeys.runs.stageTurns(runId, stageId)); + } + return keys; + } + + if (COMMAND_EVENTS.has(event)) { + const keys = [ + queryKeys.runs.stages(runId), + queryKeys.runs.events(runId, 1000), + ]; + if (stageId) { + keys.push(queryKeys.runs.stageTurns(runId, stageId)); + } + return keys; + } + + return []; +} + +export function subscribeToRunEvents( + runId: string, + mutate: MutateFn, + eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource, + { debounceMs = 300 }: { debounceMs?: number } = {}, +): () => void { + return subscribeToSharedEventSource({ + subscriptions, + subscriptionKey: runId, + url: queryKeys.runs.attach(runId), + mutate, + eventSourceFactory, + debounceMs, + resolveInvalidation: (payload) => { + const event = payload.event; + if (!event) return { keys: [] }; + + const stageId = stageIdFromPayload(payload); + const keys = queryKeysForRunEvent(runId, event, stageId); + const terminal = TERMINAL_EVENTS.has(event); + return { + keys, + close: terminal, + immediate: terminal, + }; + }, + }); +} + +function stageIdFromPayload(payload: RunEventPayload): string | undefined { + 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) { + const { mutate } = useSWRConfig(); + + useEffect(() => { + if (!runId) return; + return subscribeToRunEvents(runId, mutate as MutateFn); + }, [mutate, runId]); +} diff --git a/apps/fabro-web/app/lib/sse.test.ts b/apps/fabro-web/app/lib/sse.test.ts deleted file mode 100644 index 4d7fce2f5..000000000 --- a/apps/fabro-web/app/lib/sse.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { subscribeToRunEventSource } from "./sse"; - -type MessageHandler = ((event: { data: string }) => void) | null; - -class FakeEventSource { - onmessage: MessageHandler = null; - closed = false; - - emit(payload: unknown) { - this.onmessage?.({ data: JSON.stringify(payload) }); - } - - emitRaw(data: string) { - this.onmessage?.({ data }); - } - - close() { - this.closed = true; - } -} - -describe("subscribeToRunEventSource", () => { - test("allowlisted events trigger debounced revalidation and onEvent", async () => { - const source = new FakeEventSource(); - let revalidations = 0; - const events: Array<{ event?: string }> = []; - - const cleanup = subscribeToRunEventSource("run-1", { - allowlist: new Set(["run.completed"]), - debounceMs: 5, - revalidate: () => { - revalidations += 1; - }, - onEvent: (payload) => { - events.push(payload); - }, - eventSourceFactory: () => source, - }); - - source.emit({ event: "run.completed", seq: 42 }); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(revalidations).toBe(1); - expect(events).toEqual([{ event: "run.completed", seq: 42 }]); - - cleanup(); - }); - - test("non-allowlisted and malformed events are ignored", async () => { - const source = new FakeEventSource(); - let revalidations = 0; - let calls = 0; - - const cleanup = subscribeToRunEventSource("run-1", { - allowlist: new Set(["checkpoint.completed"]), - debounceMs: 5, - revalidate: () => { - revalidations += 1; - }, - onEvent: () => { - calls += 1; - }, - eventSourceFactory: () => source, - }); - - source.emit({ event: "run.completed" }); - source.emitRaw("{broken"); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(revalidations).toBe(0); - expect(calls).toBe(0); - - cleanup(); - }); - - test("cleanup closes the source and clears a pending debounce", async () => { - const source = new FakeEventSource(); - let revalidations = 0; - - const cleanup = subscribeToRunEventSource("run-1", { - allowlist: new Set(["run.completed"]), - debounceMs: 20, - revalidate: () => { - revalidations += 1; - }, - eventSourceFactory: () => source, - }); - - source.emit({ event: "run.completed" }); - cleanup(); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(source.closed).toBe(true); - expect(revalidations).toBe(0); - }); -}); diff --git a/apps/fabro-web/app/lib/sse.ts b/apps/fabro-web/app/lib/sse.ts index f13c020e4..408ff3c99 100644 --- a/apps/fabro-web/app/lib/sse.ts +++ b/apps/fabro-web/app/lib/sse.ts @@ -1,81 +1,164 @@ -import { useEffect } from "react"; -import { useRevalidator } from "react-router"; +import type { MutatorCallback } from "swr"; -export interface RunEventPayload { +export type MutateFn = (key: string) => ReturnType; + +export interface EventPayload { event?: string; [key: string]: unknown; } -export interface RunEventSourceLike { +export interface EventSourceLike { onmessage: ((event: { data: string }) => void) | null; - close: () => void; + close(): void; } -interface SubscribeOptions { - allowlist: ReadonlySet; - debounceMs?: number; - onEvent?: (payload: RunEventPayload) => void; - revalidate: () => void; - eventSourceFactory?: (url: string) => RunEventSourceLike; +export interface EventInvalidation { + keys: string[]; + close?: boolean; + immediate?: boolean; } -function createBrowserEventSource(url: string): RunEventSourceLike { +export interface SharedEventSubscription { + source: EventSourceLike; + refcount: number; + mutators: Map; + pendingKeys: Set; + debounceTimer: ReturnType | null; +} + +export function createBrowserEventSource(url: string): EventSourceLike { return new EventSource(url); } -export function subscribeToRunEventSource(runId: string, options: SubscribeOptions): () => void { - const { - allowlist, - debounceMs = 300, - onEvent, - revalidate, - eventSourceFactory = createBrowserEventSource, - } = options; +export function subscribeToSharedEventSource({ + subscriptions, + subscriptionKey, + url, + mutate, + resolveInvalidation, + eventSourceFactory = createBrowserEventSource, + debounceMs = 300, +}: { + subscriptions: Map; + 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); - const source = eventSourceFactory(`/api/v1/runs/${runId}/attach?since_seq=1`); - let debounceTimer: ReturnType | undefined; + source.onmessage = (message) => { + const current = subscriptions.get(subscriptionKey); + if (!current) return; - source.onmessage = (message) => { - try { - const payload = JSON.parse(message.data) as RunEventPayload; - if (!payload.event || !allowlist.has(payload.event)) { + let payload: TPayload; + try { + payload = JSON.parse(message.data) as TPayload; + } catch { return; } - onEvent?.(payload); - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => revalidate(), debounceMs); - } catch { - // ignore malformed events - } - }; + + 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 () => { - clearTimeout(debounceTimer); - source.close(); + 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); + } }; } -export function useRunEventSource( - runId: string | undefined, +function queueInvalidations( + subscription: SharedEventSubscription, + keys: string[], { - allowlist, - debounceMs = 300, - onEvent, + debounceMs, + immediate, }: { - allowlist: ReadonlySet; - debounceMs?: number; - onEvent?: (payload: RunEventPayload) => void; + debounceMs: number; + immediate?: boolean; }, ) { - const revalidator = useRevalidator(); + if (keys.length === 0) return; + for (const key of keys) { + subscription.pendingKeys.add(key); + } - useEffect(() => { - if (!runId) return; - return subscribeToRunEventSource(runId, { - allowlist, - debounceMs, - onEvent, - revalidate: () => revalidator.revalidate(), - }); - }, [allowlist, debounceMs, onEvent, revalidator, runId]); + 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, + 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); } diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts new file mode 100644 index 000000000..dea3c8f12 --- /dev/null +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -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) + : "--", + })); +} diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 9a00dacd3..00a436297 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -1,10 +1,5 @@ import { createElement } from "react"; -import { - type RouteObject, - useActionData, - useLoaderData, - useParams, -} from "react-router"; +import { type RouteObject, useParams } from "react-router"; import Root, { ErrorBoundary as RootErrorBoundary } from "./root"; import * as RedirectHome from "./routes/redirect-home"; @@ -29,34 +24,27 @@ import * as InsightsEditor from "./routes/insights-editor"; import * as InsightsNew from "./routes/insights-new"; import * as Settings from "./routes/settings"; import AppShellModule from "./layouts/app-shell"; -import { loader as appShellLoader } from "./layouts/app-shell"; type RouteModule = { default: React.ComponentType; - loader?: RouteObject["loader"]; - action?: RouteObject["action"]; handle?: RouteObject["handle"]; ErrorBoundary?: React.ComponentType; }; function withRouteModule(module: RouteModule) { return function WrappedRouteComponent() { - const loaderData = useLoaderData(); - const actionData = useActionData(); const params = useParams(); - return createElement(module.default, { loaderData, actionData, params }); + return createElement(module.default, { params }); }; } function route( path: string, module: RouteModule, - extra: Omit = {}, + extra: Omit = {}, ): RouteObject { return { path, - loader: module.loader, - action: module.action, handle: module.handle, Component: withRouteModule(module), ErrorBoundary: module.ErrorBoundary, @@ -67,8 +55,6 @@ function route( function indexRoute(module: RouteModule): RouteObject { return { index: true, - loader: module.loader, - action: module.action, handle: module.handle, Component: withRouteModule(module), ErrorBoundary: module.ErrorBoundary, @@ -85,7 +71,6 @@ export const routes: RouteObject[] = [ route("setup", Setup), route("login", AuthLogin), { - loader: appShellLoader, Component: withRouteModule({ default: AppShellModule, }), diff --git a/apps/fabro-web/app/routes/auth-login.tsx b/apps/fabro-web/app/routes/auth-login.tsx index be4d4d3c4..517cb49f5 100644 --- a/apps/fabro-web/app/routes/auth-login.tsx +++ b/apps/fabro-web/app/routes/auth-login.tsx @@ -6,14 +6,13 @@ import { INPUT_CLASS, PRIMARY_BUTTON_CLASS, } from "../components/ui"; -import { getAuthConfig, loginDevToken } from "../api"; +import { useLoginDevToken } from "../lib/mutations"; +import { useAuthConfig } from "../lib/queries"; -export async function loader() { - return getAuthConfig(); -} - -export default function AuthLogin({ loaderData }: any) { - const methods = loaderData?.methods ?? []; +export default function AuthLogin() { + const { data: authConfig } = useAuthConfig(); + const loginDevToken = useLoginDevToken(); + const methods = authConfig?.methods ?? []; const hasDevToken = methods.includes("dev-token"); const hasGitHub = methods.includes("github"); const navigate = useNavigate(); @@ -25,7 +24,7 @@ export default function AuthLogin({ loaderData }: any) { setError(null); try { - await loginDevToken(token); + await loginDevToken.trigger({ token }); navigate("/runs"); } catch { setError("Invalid dev token."); diff --git a/apps/fabro-web/app/routes/insights.tsx b/apps/fabro-web/app/routes/insights.tsx index 52f90a069..685e6afc0 100644 --- a/apps/fabro-web/app/routes/insights.tsx +++ b/apps/fabro-web/app/routes/insights.tsx @@ -1,6 +1,6 @@ import { Link, Outlet, useNavigate } from "react-router"; import { PlusIcon } from "@heroicons/react/24/outline"; -import { apiJson } from "../api"; +import { useInsightsHistory, useInsightsQueries } from "../lib/queries"; import { timeAgo } from "../lib/time"; import type { PaginatedSavedQueryList, PaginatedHistoryEntryList } from "@qltysh/fabro-api-client"; @@ -28,28 +28,29 @@ export interface HistoryEntry { rowsReturned: number; } -export async function loader({ request }: any) { - const [{ data: apiQueries }, { data: apiHistory }] = await Promise.all([ - apiJson("/insights/queries", { request }), - apiJson("/insights/history", { request }), - ]); - const savedQueries: SavedQuery[] = apiQueries.map((q) => ({ +function mapSavedQueries(result: PaginatedSavedQueryList | undefined): SavedQuery[] { + return (result?.data ?? []).map((q) => ({ id: q.id, name: q.name, sql: q.sql, })); - const historyEntries: HistoryEntry[] = apiHistory.map((h) => ({ +} + +function mapHistoryEntries(result: PaginatedHistoryEntryList | undefined): HistoryEntry[] { + return (result?.data ?? []).map((h) => ({ id: h.id, sql: h.sql, timestamp: h.timestamp, elapsed: h.elapsed, rowsReturned: h.row_count, })); - return { savedQueries, historyEntries }; } -export default function InsightsLayout({ loaderData }: any) { - const { savedQueries, historyEntries } = loaderData; +export default function InsightsLayout() { + const savedQueriesQuery = useInsightsQueries(); + const historyQuery = useInsightsHistory(); + const savedQueries = mapSavedQueries(savedQueriesQuery.data as PaginatedSavedQueryList | undefined); + const historyEntries = mapHistoryEntries(historyQuery.data as PaginatedHistoryEntryList | undefined); const navigate = useNavigate(); return ( diff --git a/apps/fabro-web/app/routes/redirect-home.tsx b/apps/fabro-web/app/routes/redirect-home.tsx index 3604b6d87..a38e48044 100644 --- a/apps/fabro-web/app/routes/redirect-home.tsx +++ b/apps/fabro-web/app/routes/redirect-home.tsx @@ -1,19 +1,22 @@ -import { redirect } from "react-router"; -import { getAuthMe } from "../api"; - -export async function loader() { - try { - await getAuthMe(); - } catch (error) { - if (error instanceof Response && error.status === 401) { - return redirect("/login"); - } - throw error; - } - - return redirect("/runs"); -} +import { useEffect } from "react"; +import { useNavigate } from "react-router"; +import { ApiError } from "../lib/api-client"; +import { useAuthMe } from "../lib/queries"; export default function RedirectHome() { + const navigate = useNavigate(); + const { data, error } = useAuthMe(); + + useEffect(() => { + if (data) { + navigate("/runs", { replace: true }); + return; + } + + if (error instanceof ApiError && error.status === 401) { + navigate("/login", { replace: true }); + } + }, [data, error, navigate]); + return null; } diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 0593c6604..ca4f4b386 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -1,6 +1,6 @@ -import { apiJson } from "../api"; import { EmptyState } from "../components/state"; import { formatDurationSecs } from "../lib/format"; +import { useRunBilling } from "../lib/queries"; import type { RunBilling } from "@qltysh/fabro-api-client"; function formatTokens(n: number) { @@ -11,8 +11,18 @@ function formatUsdMicros(usdMicros?: number) { return usdMicros == null ? "-" : `$${(usdMicros / 1_000_000).toFixed(2)}`; } -export async function loader({ request, params }: any) { - const billing = await apiJson(`/runs/${params.id}/billing`, { request }); +function mapBilling(billing: RunBilling | undefined) { + if (!billing) { + return { + stages: [], + totalRuntime: formatDurationSecs(0), + totalUsdMicros: undefined, + totalInput: 0, + totalOutput: 0, + modelBreakdown: [], + }; + } + const stages = billing.stages.map((stage) => ({ stage: stage.stage.name, model: stage.model.id, @@ -37,7 +47,9 @@ export async function loader({ request, params }: any) { return { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown }; } -export default function RunBilling({ loaderData }: any) { +export default function RunBilling({ params }: { params: { id: string } }) { + const billingQuery = useRunBilling(params.id); + const loaderData = mapBilling(billingQuery.data); const { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown } = loaderData; diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index e0b9b9868..5ec95ba1c 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -1,182 +1,12 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { - action, handleLifecycleToastResult, lifecycleActionVisibility, - loader, type LifecycleToastState, type RunDetailActionResult, } from "./run-detail"; -type StubFetchEntry = { - status: number; - body?: unknown; -}; - -function stubFetchSequence(entries: StubFetchEntry[]) { - const originalFetch = globalThis.fetch; - let index = 0; - - globalThis.fetch = ((input: RequestInfo | URL) => { - const next = entries[index++]; - if (!next) { - throw new Error(`unexpected fetch for ${String(input)}`); - } - return Promise.resolve( - new Response(next.body == null ? "" : JSON.stringify(next.body), { - status: next.status, - headers: { "Content-Type": "application/json" }, - }), - ); - }) as typeof fetch; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -function buildActionRequest(data: Record) { - const formData = new FormData(); - for (const [key, value] of Object.entries(data)) { - formData.set(key, value); - } - return new Request("http://fabro.test/runs/run-1", { - method: "POST", - body: formData, - }); -} - -describe("run-detail loader", () => { - let restoreFetch: (() => void) | undefined; - - afterEach(() => { - restoreFetch?.(); - restoreFetch = undefined; - delete (globalThis as { window?: unknown }).window; - }); - - test("loads the first blocked question when the run is blocked", async () => { - restoreFetch = stubFetchSequence([ - { - status: 200, - body: { - run_id: "run-1", - title: "Blocked run", - repository: { name: "repo" }, - status: { kind: "blocked", blocked_reason: "human_input_required" }, - workflow_name: "review", - }, - }, - { - status: 200, - body: { - data: [{ id: "q-1", text: "Ship this change?", stage: "review", question_type: "single_select", options: [], allow_freeform: false }], - meta: { has_more: false }, - }, - }, - ]); - - const result = await loader({ - request: new Request("http://fabro.test/runs/run-1"), - params: { id: "run-1" }, - }); - - expect(result.blockedQuestionText).toBe("Ship this change?"); - expect(result.run?.lifecycleStatus).toBe("blocked"); - }); - - test("falls back to null blockedQuestionText when no question is available", async () => { - restoreFetch = stubFetchSequence([ - { - status: 200, - body: { - run_id: "run-1", - title: "Blocked run", - repository: { name: "repo" }, - status: { kind: "blocked", blocked_reason: "human_input_required" }, - workflow_name: "review", - }, - }, - { - status: 200, - body: { - data: [], - meta: { has_more: false }, - }, - }, - ]); - - const result = await loader({ - request: new Request("http://fabro.test/runs/run-1"), - params: { id: "run-1" }, - }); - - expect(result.blockedQuestionText).toBeNull(); - }); -}); - -describe("run-detail action", () => { - let restoreFetch: (() => void) | undefined; - - afterEach(() => { - restoreFetch?.(); - restoreFetch = undefined; - delete (globalThis as { window?: unknown }).window; - }); - - test("preview still dispatches through intent=preview", async () => { - restoreFetch = stubFetchSequence([ - { - status: 200, - body: { url: "https://preview.example.com" }, - }, - ]); - - const result = await action({ - params: { id: "run-1" }, - request: buildActionRequest({ - intent: "preview", - port: "3000", - expires_in_secs: "3600", - }), - }); - - expect(result).toEqual({ - intent: "preview", - url: "https://preview.example.com", - }); - }); - - test("cancel dispatches through the lifecycle helper path", async () => { - restoreFetch = stubFetchSequence([ - { - status: 200, - body: { - id: "run-1", - status: { kind: "failed", reason: "cancelled" }, - created_at: "2026-04-20T12:00:00Z", - }, - }, - ]); - - const result = await action({ - params: { id: "run-1" }, - request: buildActionRequest({ intent: "cancel" }), - }); - - expect(result).toEqual({ - intent: "cancel", - ok: true, - run: { - id: "run-1", - status: { kind: "failed", reason: "cancelled" }, - created_at: "2026-04-20T12:00:00Z", - }, - }); - }); -}); - describe("lifecycleActionVisibility", () => { test("shows cancel for active cancellable states and hides it elsewhere", () => { expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true); diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index d8512d768..a584ec16d 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -1,14 +1,7 @@ import { useEffect, useRef } from "react"; import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; -import { Link, Outlet, useFetcher, useLocation } from "react-router"; -import type { - ErrorResponseEntry, - PaginatedApiQuestionList, - PreviewUrlResponse, - RunStatusResponse, -} from "@qltysh/fabro-api-client"; +import { Link, Outlet, useLocation } from "react-router"; -import { apiJson } from "../api"; import { BlockedRunNotice } from "../components/blocked-run-notice"; import { ErrorState } from "../components/state"; import { useToast } from "../components/toast"; @@ -20,18 +13,24 @@ import { type RunSummaryResponse, } from "../data/runs"; import { useDemoMode } from "../lib/demo-mode"; -import { useRunEventSource } from "../lib/sse"; import { - archiveRun, + useArchiveRun, + useCancelRun, + usePreviewRun, + useUnarchiveRun, + type LifecycleMutationResult, + type PreviewMutationResult, +} from "../lib/mutations"; +import { useRunEvents } from "../lib/run-events"; +import { useRun, useRunQuestionText } from "../lib/queries"; +import { canArchive, canCancel, canUnarchive, - cancelRun, isTerminalCancelledRun, mapError, type LifecycleAction, type LifecycleActionError, - unarchiveRun, } from "../lib/run-actions"; const allTabs = [ @@ -44,21 +43,6 @@ const allTabs = [ export const handle = { hideHeader: true }; -const RUN_DETAIL_EVENTS = new Set([ - "run.submitted", - "run.queued", - "run.starting", - "run.running", - "run.paused", - "run.unpaused", - "run.blocked", - "run.unblocked", - "run.completed", - "run.failed", - "run.archived", - "run.unarchived", -]); - const CANCEL_BUTTON_CLASS = "inline-flex items-center justify-center gap-2 rounded-lg border border-coral/30 bg-coral/10 px-4 py-2 text-sm font-medium text-coral transition-colors hover:bg-coral/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral/10"; @@ -71,29 +55,7 @@ type RunDetailRun = ReturnType & { statusText: string; }; -export interface RunDetailLoaderData { - run: RunDetailRun | null; - blockedQuestionText: string | null; -} - -type PreviewActionResult = { - intent: "preview"; - url: string; -}; - -type LifecycleActionResult = - | { - intent: LifecycleAction; - ok: true; - run: RunStatusResponse; - } - | { - intent: LifecycleAction; - ok: false; - error: LifecycleActionError | null; - }; - -export type RunDetailActionResult = PreviewActionResult | LifecycleActionResult; +export type RunDetailActionResult = PreviewMutationResult | LifecycleMutationResult; export interface LifecycleToastState { activeArchiveToastId: string | null; @@ -116,16 +78,7 @@ export function lifecycleActionVisibility(status: string | null | undefined) { }; } -export async function loader({ request, params }: any): Promise { - const response = await fetch(`/api/v1/runs/${params.id}`, { - credentials: "include", - ...(request?.signal ? { signal: request.signal } : {}), - }); - if (!response.ok) { - return { run: null, blockedQuestionText: null }; - } - - const summary: RunSummaryResponse = await response.json(); +function buildRunDetailRun(summary: RunSummaryResponse): RunDetailRun { const item = mapRunSummaryToRunItem(summary); const rawStatus = summary.status; const statusKind = rawStatus.kind; @@ -134,103 +87,73 @@ export async function loader({ request, params }: any): Promise { - const formData = await request.formData(); - const intent = String(formData.get("intent") ?? "preview"); - - if (intent === "preview") { - const port = formData.get("port"); - const expiresInSecs = formData.get("expires_in_secs"); - const result = await apiJson(`/runs/${params.id}/preview`, { - request, - init: { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }), - }, - }); - return { - intent: "preview", - url: result.url, - }; - } - - if (intent === "cancel" || intent === "archive" || intent === "unarchive") { - return runLifecycleIntent(params.id, intent, request); - } - - throw new Response(null, { status: 400, statusText: `Unsupported intent: ${intent}` }); -} - export function meta({ data }: any) { const run = data?.run; return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }]; } -export default function RunDetail({ loaderData, params }: { loaderData: RunDetailLoaderData; params: { id: string } }) { - const { run, blockedQuestionText } = loaderData; +export default function RunDetail({ params }: { params: { id: string } }) { + const runQuery = useRun(params.id); + const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null; + const statusKind = runQuery.data?.status?.kind; + const blockedQuestion = useRunQuestionText(params.id, statusKind === "blocked"); const { pathname } = useLocation(); const basePath = `/runs/${params.id}`; - const previewFetcher = useFetcher(); - const cancelFetcher = useFetcher(); - const archiveFetcher = useFetcher(); - const unarchiveFetcher = useFetcher(); + const previewMutation = usePreviewRun(params.id); + const cancelMutation = useCancelRun(params.id); + const archiveMutation = useArchiveRun(params.id); + const unarchiveMutation = useUnarchiveRun(params.id); const { push, dismiss } = useToast(); const demoMode = useDemoMode(); const tabs = allTabs.filter((t) => !t.demoOnly || demoMode); const lifecycleToastStateRef = useRef(INITIAL_LIFECYCLE_TOAST_STATE); - useRunEventSource(run?.id ?? undefined, { - allowlist: RUN_DETAIL_EVENTS, - debounceMs: 300, - }); + useRunEvents(params.id); useEffect(() => { - if (previewFetcher.data?.intent === "preview") { - window.open(previewFetcher.data.url, "_blank"); + if (previewMutation.data?.intent === "preview") { + window.open(previewMutation.data.url, "_blank"); } - }, [previewFetcher.data]); + }, [previewMutation.data]); useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "cancel", - cancelFetcher.data, + cancelMutation.data, lifecycleToastStateRef.current, { push, dismiss }, ); - }, [cancelFetcher.data, dismiss, push]); + }, [cancelMutation.data, dismiss, push]); useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "archive", - archiveFetcher.data, + archiveMutation.data, lifecycleToastStateRef.current, { push, dismiss }, - () => submitIntent(unarchiveFetcher, "unarchive"), + () => void unarchiveMutation.trigger(), ); - }, [archiveFetcher.data, dismiss, push, unarchiveFetcher]); + }, [archiveMutation.data, dismiss, push, unarchiveMutation]); useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "unarchive", - unarchiveFetcher.data, + unarchiveMutation.data, lifecycleToastStateRef.current, { push, dismiss }, ); - }, [dismiss, push, unarchiveFetcher.data]); + }, [dismiss, push, unarchiveMutation.data]); + + if (runQuery.isLoading && !run) { + return
; + } if (!run) { return ( @@ -244,10 +167,10 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai } const visibility = lifecycleActionVisibility(run.lifecycleStatus); - const previewPending = previewFetcher.state !== "idle"; - const cancelPending = cancelFetcher.state !== "idle"; - const archivePending = archiveFetcher.state !== "idle"; - const unarchivePending = unarchiveFetcher.state !== "idle"; + const previewPending = previewMutation.isMutating; + const cancelPending = cancelMutation.isMutating; + const archivePending = archiveMutation.isMutating; + const unarchivePending = unarchiveMutation.isMutating; return (
@@ -282,70 +205,71 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai
{visibility.showPrimaryCancel && ( - - +
- +
)} {visibility.showArchive && ( - - +
- +
)} {visibility.showUnarchive && ( - - +
- +
)} {run.sandboxId && ( - - - - +
- +
)}
{visibility.showBlockedNotice && ( submitIntent(cancelFetcher, "cancel")} + onCancel={() => void cancelMutation.trigger()} /> )} @@ -387,77 +311,10 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai ); } -async function loadBlockedQuestionText(id: string, signal?: AbortSignal): Promise { - try { - const url = new URL(`/api/v1/runs/${id}/questions`, "http://fabro.local"); - url.searchParams.set("page[limit]", "1"); - url.searchParams.set("page[offset]", "0"); - - const response = await fetch(`${url.pathname}${url.search}`, { - credentials: "include", - ...(signal ? { signal } : {}), - }); - if (!response.ok) { - return null; - } - - const payload = await response.json() as PaginatedApiQuestionList; - return payload.data[0]?.text ?? null; - } catch { - return null; - } -} - -async function runLifecycleIntent( - id: string, - intent: LifecycleAction, - request: Request, -): Promise { - try { - switch (intent) { - case "cancel": - return { intent, ok: true, run: await cancelRun(id, request) }; - case "archive": - return { intent, ok: true, run: await archiveRun(id, request) }; - case "unarchive": - return { intent, ok: true, run: await unarchiveRun(id, request) }; - } - } catch (error) { - return { - intent, - ok: false, - error: serializeLifecycleActionError(error), - }; - } -} - -function serializeLifecycleActionError(error: unknown): LifecycleActionError | null { - if (!error || typeof error !== "object") return null; - const record = error as Record; - 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; - return ( - typeof record.status === "string" - && typeof record.title === "string" - && typeof record.detail === "string" - ); -} - function isLifecycleActionFailure( - value: LifecycleActionResult, -): value is Extract { - return value.ok === false; + value: RunDetailActionResult, +): value is Extract { + return "ok" in value && value.ok === false; } export function handleLifecycleToastResult( @@ -492,24 +349,18 @@ export function handleLifecycleToastResult( } if (intent === "archive") { + const archiveToast: Parameters[0] = { + message: "Run archived.", + }; + if (onUnarchive) { + archiveToast.action = { label: "Unarchive", onClick: onUnarchive }; + } return { ...nextState, - activeArchiveToastId: toastApi.push({ - message: "Run archived.", - action: onUnarchive ? { label: "Unarchive", onClick: onUnarchive } : undefined, - }), + activeArchiveToastId: toastApi.push(archiveToast), }; } toastApi.push({ message: "Run restored." }); return { ...nextState, activeArchiveToastId: null }; } - -function submitIntent( - fetcher: { submit: (target: FormData, options: { method: "post" }) => void }, - intent: LifecycleAction, -) { - const formData = new FormData(); - formData.set("intent", intent); - fetcher.submit(formData, { method: "post" }); -} diff --git a/apps/fabro-web/app/routes/run-files.test.ts b/apps/fabro-web/app/routes/run-files.test.ts index 026a96d89..fbbbd4e09 100644 --- a/apps/fabro-web/app/routes/run-files.test.ts +++ b/apps/fabro-web/app/routes/run-files.test.ts @@ -1,18 +1,11 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { deepLinkToastMessage, emptyTransitionToastMessage, extractRequestId, - loader, } from "./run-files"; -type StubResponseInit = { - status: number; - body?: string; - headers?: Record; -}; - function buildRunFilesPayload({ files = [], degraded = false, @@ -38,24 +31,6 @@ function buildRunFilesPayload({ } as any; } -function stubFetchOnce(init: StubResponseInit) { - const original = globalThis.fetch; - globalThis.fetch = (() => { - const response = new Response(init.body ?? "", { - status: init.status, - headers: init.headers, - // `Response` constructor derives statusText from status for known - // codes; providing it explicitly keeps tests deterministic across - // engines that disagree on the default message. - statusText: init.status === 500 ? "Internal Server Error" : "", - }); - return Promise.resolve(response); - }) as typeof fetch; - return () => { - globalThis.fetch = original; - }; -} - describe("extractRequestId", () => { test("reads `request_id` from the top level of the error body", () => { expect(extractRequestId({ request_id: "abc-123" })).toBe("abc-123"); @@ -149,108 +124,3 @@ describe("deepLinkToastMessage", () => { ).toBeNull(); }); }); - -describe("loader", () => { - let restoreFetch: (() => void) | undefined; - - afterEach(() => { - restoreFetch?.(); - restoreFetch = undefined; - }); - - // Bun's Request constructor needs a URL; tests use a relative path - // because the loader only reads `request?.signal`. - const dummyRequest = { signal: undefined } as any; - const dummyParams = { id: "01ARZ3NDEKTSV4RRFFQ69G5FAV" }; - - test("200 OK returns { data, error: null } with parsed envelope", async () => { - const envelope = { - data: [], - meta: { - truncated: false, - total_changed: 0, - stats: { additions: 0, deletions: 0 }, - }, - }; - restoreFetch = stubFetchOnce({ - status: 200, - body: JSON.stringify(envelope), - }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.error).toBeNull(); - expect(result.data).toEqual(envelope); - }); - - test("404 returns empty-envelope signal { data: null, error: null }", async () => { - restoreFetch = stubFetchOnce({ status: 404 }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.data).toBeNull(); - expect(result.error).toBeNull(); - }); - - test("501 returns empty-envelope signal { data: null, error: null }", async () => { - restoreFetch = stubFetchOnce({ status: 501 }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.data).toBeNull(); - expect(result.error).toBeNull(); - }); - - test("500 populates error.requestId from the uniform error envelope", async () => { - restoreFetch = stubFetchOnce({ - status: 500, - body: JSON.stringify({ - errors: [ - { - status: "500", - title: "Internal Server Error", - detail: "Run files materialization panicked.", - request_id: "req_deadbeef", - }, - ], - }), - }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.data).toBeNull(); - expect(result.error).not.toBeNull(); - expect(result.error!.status).toBe(500); - expect(result.error!.requestId).toBe("req_deadbeef"); - }); - - test("500 with no request_id leaves error.requestId as null", async () => { - restoreFetch = stubFetchOnce({ - status: 500, - body: JSON.stringify({ - errors: [{ status: "500", title: "Internal", detail: "whoops" }], - }), - }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.error).not.toBeNull(); - expect(result.error!.status).toBe(500); - expect(result.error!.requestId).toBeNull(); - }); - - test("500 with non-JSON body still surfaces the status", async () => { - restoreFetch = stubFetchOnce({ status: 500, body: "oops" }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.error).not.toBeNull(); - expect(result.error!.status).toBe(500); - expect(result.error!.requestId).toBeNull(); - }); - - test("503 populates error without requestId", async () => { - restoreFetch = stubFetchOnce({ - status: 503, - body: JSON.stringify({ errors: [{ detail: "rate limited" }] }), - }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.error).not.toBeNull(); - expect(result.error!.status).toBe(503); - }); - - test("401 still surfaces as an error (no in-loader redirect)", async () => { - restoreFetch = stubFetchOnce({ status: 401 }); - const result = await loader({ request: dummyRequest, params: dummyParams }); - expect(result.error).not.toBeNull(); - expect(result.error!.status).toBe(401); - }); -}); diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index 2eeca489d..aa0ae24d4 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -6,12 +6,7 @@ import { type ReactElement, type ReactNode, } from "react"; -import { - useMatches, - useNavigation, - useParams, - useRevalidator, -} from "react-router"; +import { useParams } from "react-router"; import * as PierreDiffs from "@pierre/diffs/react"; import { useToast } from "../components/toast"; import type { @@ -32,7 +27,10 @@ import { } from "./run-files/states"; import { useFileKeyboardNav } from "./run-files/keyboard"; import { Toolbar, type DiffStyle } from "./run-files/toolbar"; -import { useRunEventSource } from "../lib/sse"; +import { ApiError, extractRequestId } from "../lib/api-client"; +import { useRun, useRunFiles } from "../lib/queries"; + +export { extractRequestId }; const { MultiFileDiff, PatchDiff } = PierreDiffs; const maybeVirtualizer = (PierreDiffs as Record).Virtualizer; @@ -44,109 +42,6 @@ const Virtualizer = typeof maybeVirtualizer === "function" export const handle = { wide: true }; -/** - * Loader return type. Both initial loads and revalidations flow through the - * same discriminated union so a revalidation failure does NOT unmount to - * the route ErrorBoundary — it stays in-band as `{ data: null, error }` - * and the component keeps showing the last-good data with an inline - * banner. - * - * `error.requestId` is extracted from the 500-response body so the UI can - * surface it verbatim ("Request ID: xyz. Contact support.") rather than - * just the bare status code. - */ -export type RunFilesLoaderResult = { - data: PaginatedRunFileList | null; - error: { - status: number; - message: string; - requestId: string | null; - } | null; -}; - -export async function loader({ - request, - params, -}: any): Promise { - // Avoid apiJsonOrNull's `throw new Response(null, ...)` pattern — it - // strips the response body, and we need the body to parse request_id - // out of 500s per R5. - const response = await fetch(`/api/v1/runs/${params.id}/files`, { - credentials: "include", - ...(request?.signal ? { signal: request.signal } : {}), - }); - - if (response.status === 404 || response.status === 501) { - return { data: null, error: null }; - } - if (response.ok) { - const data = (await response.json()) as PaginatedRunFileList; - return { data, error: null }; - } - - // Parse the body once. 500 responses carry request_id per the server's - // uniform error envelope; other statuses may not. - let bodyText = ""; - try { - bodyText = await response.text(); - } catch { - // Body read failed — fall through with an empty string; the error - // surface still reports the status. - } - let bodyJson: unknown = null; - if (bodyText) { - try { - bodyJson = JSON.parse(bodyText); - } catch { - // non-JSON body is fine; we still got the status - } - } - - return { - data: null, - error: { - status: response.status, - message: response.statusText || `HTTP ${response.status}`, - requestId: extractRequestId(bodyJson), - }, - }; -} - -/** - * Pull request_id out of the server's uniform error envelope: - * { "errors": [{ "status": "500", "title": "...", "detail": "..." }] } - * Some deployments tag request_id at top level or within errors[].detail. - * - * Exported for unit testing; callers should prefer the already-extracted - * value on `RunFilesLoaderResult.error.requestId`. - */ -export function extractRequestId(body: unknown): string | null { - if (!body || typeof body !== "object") return null; - const b = body as Record; - 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; - if (typeof rec.request_id === "string") return rec.request_id; - if (typeof rec.detail === "string") { - const m = rec.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i); - if (m) return m[1]; - } - } - } - return null; -} - -// Events that should trigger a revalidation. CheckpointCompleted is the -// canonical signal; terminal events cover the final-state transitions too. -const REFRESH_EVENTS = new Set([ - "checkpoint.completed", - "run.completed", - "run.failed", -]); - const MD_BREAKPOINT_PX = 768; const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style"; @@ -165,13 +60,6 @@ function useNarrowViewport(): boolean { return narrow; } -function useSseRevalidation(runId: string | undefined) { - useRunEventSource(runId, { - allowlist: REFRESH_EVENTS, - debounceMs: 500, - }); -} - function useFreshness( meta: PaginatedRunFileList["meta"] | null, lastFetchedAt: number | null, @@ -298,38 +186,13 @@ export function deepLinkToastMessage( return resolveDeepLinkToast(hashFile, data)?.message ?? null; } -/** - * Extract the lifecycle status from whichever ancestor match carries it. - * The Run Detail loader (apps/fabro-web/app/routes/run-detail.tsx) returns - * `{ run: { lifecycleStatus: string | null, ... } }` where - * `lifecycleStatus` is the raw workflow status — "submitted", "running", - * "succeeded", "failed", etc. `run.status` on the same object is the - * ColumnStatus derived from checks and is NOT the right field to drive the - * empty-state taxonomy from. - */ -function resolveRunStatus(matches: ReturnType): string | undefined { - for (const match of matches) { - const data = match.data as any; - if (!data) continue; - if (typeof data?.run?.lifecycleStatus === "string") { - return data.run.lifecycleStatus as string; - } - if (typeof data?.lifecycleStatus === "string") { - return data.lifecycleStatus as string; - } - } - return undefined; -} - -export default function RunFiles({ loaderData }: any) { +export default function RunFiles() { const params = useParams(); - const navigation = useNavigation(); - const revalidator = useRevalidator(); - const matches = useMatches(); + const filesQuery = useRunFiles(params.id); + const runQuery = useRun(params.id); const { push } = useToast(); - const result = loaderData as RunFilesLoaderResult | null; const narrow = useNarrowViewport(); - const runStatus = resolveRunStatus(matches); + const runStatus = runQuery.data?.status?.kind; // Preserve the last successful payload so a failed revalidation can keep // rendering the previous files while surfacing an inline banner. @@ -337,36 +200,35 @@ export default function RunFiles({ loaderData }: any) { const lastFetchedAtRef = useRef(null); useEffect(() => { - if (!result?.data) return; + if (!filesQuery.data) return; const message = emptyTransitionToastMessage( lastGoodDataRef.current?.data.length ?? null, - result.data.data.length, + filesQuery.data.data.length, ); if (message) { push({ message }); } - lastGoodDataRef.current = result.data; + lastGoodDataRef.current = filesQuery.data; lastFetchedAtRef.current = Date.now(); - }, [push, result?.data]); + }, [push, filesQuery.data]); const data: PaginatedRunFileList | null = - result?.data ?? lastGoodDataRef.current; + filesQuery.data ?? lastGoodDataRef.current; - useSseRevalidation(params.id); - - const isInitialLoading = navigation.state === "loading" && !loaderData; - const isRevalidating = revalidator.state === "loading"; + const isInitialLoading = filesQuery.isLoading && !data; + const isRevalidating = filesQuery.isValidating; // Revalidation error is whatever the most recent loader call returned; // the inline banner renders when we still have prior data to show. When // there's no prior data AND this is the initial load, we render a // full-panel error state instead (the Toolbar would have nothing to act // on with no data). + const apiError = filesQuery.error instanceof ApiError ? filesQuery.error : null; const revalidationError = - result?.error && lastGoodDataRef.current - ? `Couldn't refresh (${result.error.status}).` + apiError && lastGoodDataRef.current + ? `Couldn't refresh (${apiError.status}).` : null; - const initialError = result?.error && !lastGoodDataRef.current ? result.error : null; + const initialError = apiError && !lastGoodDataRef.current ? apiError : null; const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current); @@ -498,7 +360,7 @@ export default function RunFiles({ loaderData }: any) { return renderStatusError({ status: initialError.status, requestId: initialError.requestId, - onRetry: () => revalidator.revalidate(), + onRetry: () => void filesQuery.mutate(), }); } @@ -531,7 +393,7 @@ export default function RunFiles({ loaderData }: any) { additions: meta.stats.additions, deletions: meta.stats.deletions, }} - onRefresh={() => revalidator.revalidate()} + onRefresh={() => void filesQuery.mutate()} refreshing={isRevalidating} refreshDisabled={refreshDisabled} freshness={freshness} @@ -550,7 +412,7 @@ export default function RunFiles({ loaderData }: any) { {revalidationError ? ( revalidator.revalidate()} + onRetry={() => void filesQuery.mutate()} /> ) : null} @@ -595,7 +457,7 @@ export default function RunFiles({ loaderData }: any) { {revalidationError ? ( revalidator.revalidate()} + onRetry={() => void filesQuery.mutate()} /> ) : null} {body} diff --git a/apps/fabro-web/app/routes/run-files/states.tsx b/apps/fabro-web/app/routes/run-files/states.tsx index fc1966292..02080d939 100644 --- a/apps/fabro-web/app/routes/run-files/states.tsx +++ b/apps/fabro-web/app/routes/run-files/states.tsx @@ -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() {
); } - -/** - * 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; - 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; - 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; -} diff --git a/apps/fabro-web/app/routes/run-graph.tsx b/apps/fabro-web/app/routes/run-graph.tsx index 1596c26a4..ef0c13f11 100644 --- a/apps/fabro-web/app/routes/run-graph.tsx +++ b/apps/fabro-web/app/routes/run-graph.tsx @@ -1,36 +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 { apiFetch, apiJsonOrNull } from "../api"; -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 type { PaginatedRunStageList } from "@qltysh/fabro-api-client"; +import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true }; -export async function loader({ request, params }: any) { - const [stagesResult, graphRes] = await Promise.all([ - apiJsonOrNull(`/runs/${params.id}/stages`, { request }), - apiFetch(`/runs/${params.id}/graph`, { request }), - ]); - const stages: Stage[] = (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) : "--", - })); - const graphSvg = graphRes.ok ? await graphRes.text() : null; - return { stages, graphSvg }; -} - type Direction = "LR" | "TB"; function buildDot(direction: Direction) { @@ -88,34 +69,44 @@ function stripGraphTitle(svg: SVGSVGElement) { title.remove(); } -export default function RunGraph({ loaderData }: any) { +export default function RunGraph() { const { id } = useParams(); - const { stages, graphSvg } = loaderData; + const [direction, setDirection] = useState("LR"); + const stagesQuery = useRunStages(id); + const graphQuery = useRunGraph(id, direction); + const stages = useMemo( + () => mapRunStagesToSidebarStages(stagesQuery.data), + [stagesQuery.data], + ); + const graphSvg = graphQuery.data; const containerRef = useRef(null); const innerRef = useRef(null); const svgRef = useRef(null); const [error, setError] = useState(null); const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX); - const [direction, setDirection] = useState("LR"); const [pan, setPan] = useState({ x: 0, y: 0 }); const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); 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) { - // Re-fetch from server with direction param. - const res = await fetch(`/api/v1/runs/${id}/graph?direction=${direction}`, { credentials: "include" }); - if (cancelled) return; - if (!res.ok) { setError("Failed to load graph"); return; } - const svgText = await res.text(); const parser = new DOMParser(); - const doc = parser.parseFromString(svgText, "image/svg+xml"); + const doc = parser.parseFromString(graphSvg, "image/svg+xml"); const parsed = doc.documentElement; if (!(parsed instanceof SVGSVGElement)) { setError("Invalid SVG from server"); @@ -144,7 +135,7 @@ export default function RunGraph({ loaderData }: any) { 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; diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index ae1736117..5331242b9 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -1,9 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router"; -import { apiFetch, apiJsonOrNull } from "../api"; -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"; import type { Stage } from "../components/stage-sidebar"; import { @@ -12,42 +10,29 @@ import { GraphToolbar, } from "../components/graph-toolbar"; import { EmptyState } from "../components/state"; -import type { PaginatedRunStageList } from "@qltysh/fabro-api-client"; +import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true }; -export async function loader({ request, params }: any) { - const stagesResult = await apiJsonOrNull( - `/runs/${params.id}/stages`, - { request }, - ); - const stages: Stage[] = (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, - })); - const [graphRes, runRes] = await Promise.all([ - apiFetch(`/runs/${params.id}/graph`, { request }), - apiJsonOrNull<{ status: string | null }>(`/runs/${params.id}`, { request }), - ]); - const graphSvg = graphRes.ok ? await graphRes.text() : null; - const runStatus = runRes?.status ?? null; - return { stages, graphSvg, runStatus }; -} - type Direction = "LR" | "TB"; -export default function RunOverview({ loaderData }: any) { +export default function RunOverview() { const { id } = useParams(); - const { stages, graphSvg, runStatus } = loaderData; + const [direction, setDirection] = useState("LR"); + const stagesQuery = useRunStages(id); + const graphQuery = useRunGraph(id, direction); + const runQuery = useRun(id); + const stages = useMemo( + () => mapRunStagesToSidebarStages(stagesQuery.data), + [stagesQuery.data], + ); + const graphSvg = graphQuery.data; + const runStatus = runQuery.data?.status?.kind ?? null; const containerRef = useRef(null); const innerRef = useRef(null); const svgRef = useRef(null); const navigate = useNavigate(); const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX); - const [direction, setDirection] = useState("LR"); const [pan, setPan] = useState({ x: 0, y: 0 }); const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); const zoom = GRAPH_ZOOM_STEPS[zoomIndex]; @@ -59,10 +44,8 @@ export default function RunOverview({ loaderData }: any) { let cancelled = false; (async () => { - const res = await fetch(`/api/v1/runs/${id}/graph?direction=${direction}`, { credentials: "include" }); - if (cancelled || !res.ok) return; - const svgText = await res.text(); - inner.innerHTML = svgText; + if (cancelled) return; + inner.innerHTML = graphSvg; const svg = inner.querySelector("svg"); if (!svg) return; svgRef.current = svg; @@ -155,7 +138,7 @@ export default function RunOverview({ loaderData }: any) { } })(); return () => { cancelled = true; }; - }, [stages, graphSvg, direction, id]); + }, [stages, graphSvg, id, navigate, runStatus]); const onPointerDown = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("button")) return; @@ -202,7 +185,9 @@ export default function RunOverview({ loaderData }: any) {
- {graphSvg ? ( + {graphSvg === undefined && graphQuery.isLoading ? ( +
+ ) : graphSvg ? (
; -export async function loader({ request, params }: any) { - const [{ data: apiStages }, settings] = await Promise.all([ - apiJson(`/runs/${params.id}/stages`, { request }), - apiJson(`/runs/${params.id}/settings`, { request }), - ]); - const stages: Stage[] = apiStages.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) : "--", - })); - return { stages, settings }; -} - -export default function RunSettingsPage({ loaderData }: any) { +export default function RunSettingsPage() { const { id } = useParams(); - const { stages, settings } = loaderData; + const stagesQuery = useRunStages(id); + const settingsQuery = useRunSettings(id); + const stages = useMemo( + () => mapRunStagesToSidebarStages(stagesQuery.data), + [stagesQuery.data], + ); + const settings = settingsQuery.data ?? {}; return (
diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 5ceb146cf..bdd4bd1c0 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -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"; @@ -38,11 +38,11 @@ import type { ToolUse } from "../components/tool-use"; import { StageSidebar, statusConfig } from "../components/stage-sidebar"; import type { Stage } from "../components/stage-sidebar"; import { EmptyState } from "../components/state"; -import { apiJson, apiJsonOrNull } from "../api"; import { CopyButton } from "../components/ui"; -import { isVisibleStage } from "../data/runs"; import { formatDurationSecs } from "../lib/format"; -import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client"; +import { useRunEventsList, useRunStageTurns, useRunStages } from "../lib/queries"; +import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import type { StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client"; export const handle = { wide: true }; @@ -147,26 +147,14 @@ function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] { return turns; } -export async function loader({ request, params }: any) { - const stagesResult = await apiJsonOrNull(`/runs/${params.id}/stages`, { request }); - const stages: Stage[] = (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) : "--", - })); - - const selectedStageId = params.stageId ?? stages[0]?.id; - - // Try demo turns endpoint first, fall back to events. - let turns: TurnType[] = []; - if (selectedStageId) { - const turnsResult = await apiJsonOrNull( - `/runs/${params.id}/stages/${selectedStageId}/turns`, - { request }, - ); - if (turnsResult?.data?.length) { - turns = turnsResult.data.map((t: ApiStageTurn): TurnType => { +function mapTurns( + turnsResult: PaginatedStageTurnList | null | undefined, + eventsResult: PaginatedEventList | null | undefined, + selectedStageId: string | undefined, +): TurnType[] { + if (!selectedStageId) return []; + if (turnsResult?.data?.length) { + return turnsResult.data.map((t: ApiStageTurn): TurnType => { if (t.kind === "tool" && t.tools) { return { kind: "tool", @@ -182,19 +170,11 @@ export async function loader({ request, params }: any) { } return { kind: t.kind as "system" | "assistant", content: t.content ?? "" }; }); - } else { - // Fetch events and build turns from them. - const eventsResult = await apiJsonOrNull( - `/runs/${params.id}/events?limit=1000`, - { request }, - ); - if (eventsResult?.data) { - turns = turnsFromEvents(eventsResult.data as unknown as RawEvent[], selectedStageId); - } - } } - - return { stages, turns }; + if (eventsResult?.data) { + return turnsFromEvents(eventsResult.data as unknown as RawEvent[], selectedStageId); + } + return []; } function Markdown({ content }: { content: string }) { @@ -376,31 +356,57 @@ function CommandBlock({ turn }: { turn: Extract } ); } -export default function RunStages({ loaderData }: any) { - const { id, stageId } = useParams(); - const { stages, turns } = loaderData; - - const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0]; - 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(() => + 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 (
@@ -414,9 +420,6 @@ export default function RunStages({ loaderData }: any) { 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 (
@@ -426,7 +429,12 @@ export default function RunStages({ loaderData }: any) {

{selectedStage.name}

- {headerDuration} + + +
{turns.map((turn: TurnType, i: number) => { diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 01559f73f..630f87725 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect, useRef } from "react"; -import { Link, useRevalidator } from "react-router"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Link } from "react-router"; import { ChevronDownIcon, ChevronRightIcon, CommandLineIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { DndContext, @@ -20,10 +20,13 @@ import { import { CSS } from "@dnd-kit/utilities"; import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs"; import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; -import { apiPaginatedJson, getAuthConfig } from "../api"; import { EmptyState } from "../components/state"; +import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events"; +import { useAuthConfig, useBoardsRuns } from "../lib/queries"; import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; +export { shouldRefreshBoardForEvent }; + export function meta({}: any) { return [{ title: "Runs — Fabro" }]; } @@ -60,30 +63,6 @@ type Column = { items: RunItem[]; }; -const BOARD_STATUS_EVENTS = new Set([ - "run.submitted", - "run.queued", - "run.starting", - "run.running", - "run.removing", - "run.paused", - "run.unpaused", - "run.blocked", - "run.unblocked", - "run.completed", - "run.failed", - "run.archived", - "run.unarchived", - "interview.started", - "interview.completed", - "interview.timeout", - "interview.interrupted", -]); - -export function shouldRefreshBoardForEvent(event: string) { - return BOARD_STATUS_EVENTS.has(event); -} - export function buildBoardColumns(response: BoardRunsResponse): Column[] { const grouped = new Map(); for (const col of response.columns) { @@ -109,22 +88,6 @@ export function buildBoardColumns(response: BoardRunsResponse): Column[] { }); } -export async function loader({ request }: any) { - const [response, authConfig] = await Promise.all([ - apiPaginatedJson< - PaginatedBoardRunList["data"][number], - { columns: BoardRunsResponse["columns"] } - >("/boards/runs", { request }), - // Failure here only affects the blank-slate quick-start hint. - // Default to no auth step rather than blocking the page. - getAuthConfig().catch(() => ({ methods: [] as string[] })), - ]); - return { - columns: buildBoardColumns(response), - hasGitHubAuth: authConfig.methods.includes("github"), - }; -} - function boardLifecycleStatusLabel(run: Pick): string | null { if (run.lifecycleStatusLabel == null) return null; if (run.column != null && columnStatusDisplay[run.column]?.label === run.lifecycleStatusLabel) { @@ -682,9 +645,14 @@ function RunsLandingEmpty({ hasGitHubAuth }: { hasGitHubAuth: boolean }) { ); } -export default function Runs({ loaderData }: any) { - const initialColumns = loaderData.columns; - const hasGitHubAuth: boolean = loaderData.hasGitHubAuth === true; +export default function Runs() { + const boardRuns = useBoardsRuns(); + const authConfig = useAuthConfig(); + const initialColumns = useMemo( + () => boardRuns.data ? buildBoardColumns(boardRuns.data) : [], + [boardRuns.data], + ); + const hasGitHubAuth = authConfig.data?.methods.includes("github") === true; const allRepos = [ ...new Set( initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))), @@ -696,29 +664,7 @@ export default function Runs({ loaderData }: any) { const [collapsed, setCollapsed] = useState>(new Set()); const [columns, setColumns] = useState(initialColumns); const lowerQuery = query.toLowerCase(); - const revalidator = useRevalidator(); - - useEffect(() => { - const source = new EventSource("/api/v1/attach"); - let debounceTimer: ReturnType | undefined; - - source.onmessage = (msg) => { - try { - const payload = JSON.parse(msg.data); - if (shouldRefreshBoardForEvent(payload.event)) { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => revalidator.revalidate(), 500); - } - } catch { - // ignore malformed events - } - }; - - return () => { - clearTimeout(debounceTimer); - source.close(); - }; - }, []); + useBoardEvents(); useEffect(() => { setColumns(initialColumns); diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index dc5cb53ce..d9df32c68 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -1,18 +1,14 @@ import type { ServerSettings } from "@qltysh/fabro-api-client"; -import { apiJson } from "../api"; import { CollapsibleFile } from "../components/collapsible-file"; +import { useServerSettings } from "../lib/queries"; export function meta({}: any) { return [{ title: "Settings — Fabro" }]; } -export async function loader({ request }: any) { - const settings = await apiJson("/settings", { request }); - return { settings }; -} - -export default function Settings({ loaderData }: any) { - const { settings } = loaderData; +export default function Settings() { + const settingsQuery = useServerSettings(); + const settings = (settingsQuery.data ?? {}) as ServerSettings; return ( <> diff --git a/apps/fabro-web/app/routes/start.tsx b/apps/fabro-web/app/routes/start.tsx index a4832ee02..0c1bd8bbd 100644 --- a/apps/fabro-web/app/routes/start.tsx +++ b/apps/fabro-web/app/routes/start.tsx @@ -16,7 +16,7 @@ import { MagnifyingGlassIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { getSystemInfo } from "../api"; +import { useSystemInfo } from "../lib/queries"; export const handle = { hideHeader: true, wide: true }; @@ -24,11 +24,6 @@ export function meta({}: any) { return [{ title: "Start — Fabro" }]; } -export async function loader({ request }: any) { - const { features } = await getSystemInfo(); - return { features }; -} - const projects = [ { id: "fabro-web", name: "fabro-web" }, { id: "fabro-workflows", name: "fabro-workflows" }, @@ -49,8 +44,12 @@ function BranchIcon({ className }: { className?: string }) { ); } -export default function Start({ loaderData }: any) { - const { features } = loaderData; +export default function Start() { + const systemInfo = useSystemInfo(); + const features = systemInfo.data?.features ?? { + session_sandboxes: false, + retros: false, + }; const [prompt, setPrompt] = useState(""); const [project, setProject] = useState(projects[0]); const [branch, setBranch] = useState(branches[0]); diff --git a/apps/fabro-web/app/routes/workflow-definition.tsx b/apps/fabro-web/app/routes/workflow-definition.tsx index 2c5dd8899..a7959508c 100644 --- a/apps/fabro-web/app/routes/workflow-definition.tsx +++ b/apps/fabro-web/app/routes/workflow-definition.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router"; +import { useOutletContext, useParams } from "react-router"; import type { BundledLanguage } from "@pierre/diffs"; import { registerDotLanguage } from "../data/register-dot-language"; -import { workflowData } from "./workflow-detail"; +import { workflowData, type WorkflowEntry } from "./workflow-detail"; import { CollapsibleFile } from "../components/collapsible-file"; export default function WorkflowDefinition() { const { name } = useParams(); - const workflow = workflowData[name ?? ""]; + const context = useOutletContext<{ workflow?: WorkflowEntry } | null>(); + const workflow = context?.workflow ?? workflowData[name ?? ""]; const [dotReady, setDotReady] = useState(false); useEffect(() => { diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx index c1697bd41..cc5fc6f26 100644 --- a/apps/fabro-web/app/routes/workflow-detail.tsx +++ b/apps/fabro-web/app/routes/workflow-detail.tsx @@ -1,6 +1,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { Link, Outlet, useLocation, useParams } from "react-router"; -import { apiJsonOrNull } from "../api"; +import { useWorkflow } from "../lib/queries"; import type { WorkflowSettingsSnapshot, WorkflowDetailResponse as ApiWorkflowDetail, @@ -224,9 +224,9 @@ const tabs = [ export const handle = { hideHeader: true }; -export async function loader({ request, params }: any) { - const apiWorkflow = await apiJsonOrNull(`/workflows/${params.name}`, { request }); - const workflow: WorkflowEntry = apiWorkflow +function resolveWorkflow(name: string | undefined, apiWorkflow: ApiWorkflowDetail | null | undefined): WorkflowEntry { + const workflowName = name ?? ""; + return apiWorkflow ? { name: apiWorkflow.name, slug: apiWorkflow.slug, @@ -235,15 +235,14 @@ export async function loader({ request, params }: any) { settings: apiWorkflow.settings, graph: apiWorkflow.graph, } - : workflowData[params.name] ?? { - name: params.name, - slug: params.name, + : workflowData[workflowName] ?? { + name: workflowName, + slug: workflowName, description: "", - filename: `${params.name}.fabro`, + filename: `${workflowName}.fabro`, settings: {}, graph: "", }; - return { workflow }; } export function meta({ data }: any) { @@ -251,10 +250,11 @@ export function meta({ data }: any) { return [{ title: `${title} — Fabro` }]; } -export default function WorkflowDetail({ loaderData }: any) { +export default function WorkflowDetail() { const { name } = useParams(); + const workflowQuery = useWorkflow(name); const { pathname } = useLocation(); - const workflow = loaderData.workflow; + const workflow = resolveWorkflow(name, workflowQuery.data); const basePath = `/workflows/${name}`; return ( @@ -308,7 +308,7 @@ export default function WorkflowDetail({ loaderData }: any) {
- +
); diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx index 88640b9c8..7964a2c0c 100644 --- a/apps/fabro-web/app/routes/workflow-runs.tsx +++ b/apps/fabro-web/app/routes/workflow-runs.tsx @@ -3,13 +3,12 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin import { Link, useParams } from "react-router"; import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs"; import type { ColumnStatus, RunWithStatus } from "../data/runs"; -import { apiJsonOrNull } from "../api"; +import { useWorkflowRuns } from "../lib/queries"; import type { PaginatedRunList } from "@qltysh/fabro-api-client"; -export async function loader({ request, params }: any) { - const result = await apiJsonOrNull(`/workflows/${params.name}/runs`, { request }); +function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithStatus[] { const apiRuns = result?.data ?? []; - const runs: RunWithStatus[] = apiRuns + return apiRuns .map((r) => { const column = columnForStatus(r.status); if (column == null) return null; @@ -20,7 +19,6 @@ export async function loader({ request, params }: any) { }; }) .filter((run): run is RunWithStatus => run != null); - return { runs }; } function GitPullRequestIcon({ className }: { className?: string }) { @@ -74,8 +72,10 @@ function RunRow({ run }: { run: RunWithStatus }) { ); } -export default function WorkflowRuns({ loaderData }: any) { - const { runs } = loaderData; +export default function WorkflowRuns() { + const { name } = useParams(); + const runsQuery = useWorkflowRuns(name); + const runs = mapWorkflowRuns(runsQuery.data); const [query, setQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const filtered = runs.filter( diff --git a/apps/fabro-web/app/routes/workflows.tsx b/apps/fabro-web/app/routes/workflows.tsx index 6e897f2cd..a40f7657f 100644 --- a/apps/fabro-web/app/routes/workflows.tsx +++ b/apps/fabro-web/app/routes/workflows.tsx @@ -13,7 +13,7 @@ import { WrenchIcon, } from "@heroicons/react/24/outline"; import { Link } from "react-router"; -import { apiJsonOrNull } from "../api"; +import { useWorkflows } from "../lib/queries"; import { timeAgo, timeUntil } from "../lib/time"; import type { PaginatedWorkflowListResponse } from "../lib/workflow-api"; @@ -104,8 +104,7 @@ interface WorkflowData { nextRun?: string; } -export async function loader({ request }: any) { - const result = await apiJsonOrNull("/workflows", { request }); +function mapWorkflows(result: PaginatedWorkflowListResponse | null | undefined) { const apiWorkflows = result?.data ?? []; const workflows: WorkflowData[] = apiWorkflows.map((w) => ({ name: w.name, @@ -116,7 +115,7 @@ export async function loader({ request }: any) { schedule: w.schedule?.expression, nextRun: w.schedule?.next_run ? timeUntil(w.schedule.next_run) : undefined, })); - return { workflows }; + return workflows; } function enrichWorkflows(data: WorkflowData[]): Workflow[] { @@ -202,8 +201,9 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) { type TriggerFilter = "all" | "scheduled" | "manual"; -export default function Workflows({ loaderData }: any) { - const workflows = enrichWorkflows(loaderData.workflows); +export default function Workflows() { + const workflowsQuery = useWorkflows(); + const workflows = enrichWorkflows(mapWorkflows(workflowsQuery.data)); const [query, setQuery] = useState(""); const [triggerFilter, setTriggerFilter] = useState("all"); const filtered = workflows.filter( diff --git a/apps/fabro-web/package.json b/apps/fabro-web/package.json index 3c2753096..c986a6472 100644 --- a/apps/fabro-web/package.json +++ b/apps/fabro-web/package.json @@ -21,7 +21,8 @@ "marked": "^18.0.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router": "7.12.0" + "react-router": "7.12.0", + "swr": "^2.4.1" }, "devDependencies": { "@tailwindcss/cli": "^4.1.13", diff --git a/bun.lock b/bun.lock index 7f22749bc..ff4124cba 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router": "7.12.0", + "swr": "^2.4.1", }, "devDependencies": { "@tailwindcss/cli": "^4.1.13", @@ -1250,6 +1251,8 @@ "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], + "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], diff --git a/lib/crates/fabro-spa/assets/assets/entry-5nzjj9ar.js b/lib/crates/fabro-spa/assets/assets/entry-5nzjj9ar.js deleted file mode 100644 index d891a98d1..000000000 --- a/lib/crates/fabro-spa/assets/assets/entry-5nzjj9ar.js +++ /dev/null @@ -1,2402 +0,0 @@ -import{X as h,Y as E3,Z as p5,_ as x}from"./chunk-q07bg6gn.js";var Y0=E3((va,YU)=>{(function(){function Z(j,Z0){Object.defineProperty(z.prototype,j,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",Z0[0],Z0[1])}})}function Y(j){if(j===null||typeof j!=="object")return null;return j=B1&&j[B1]||j["@@iterator"],typeof j==="function"?j:null}function Q(j,Z0){j=(j=j.constructor)&&(j.displayName||j.name)||"ReactClass";var F0=j+"."+Z0;R0[F0]||(console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",Z0,j),R0[F0]=!0)}function z(j,Z0,F0){this.props=j,this.context=Z0,this.refs=n5,this.updater=F0||C1}function q(){}function B(j,Z0,F0){this.props=j,this.context=Z0,this.refs=n5,this.updater=F0||C1}function W(){}function $(j){return""+j}function U(j){try{$(j);var Z0=!1}catch(x0){Z0=!0}if(Z0){Z0=console;var F0=Z0.error,T0=typeof Symbol==="function"&&Symbol.toStringTag&&j[Symbol.toStringTag]||j.constructor.name||"Object";return F0.call(Z0,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",T0),$(j)}}function M(j){if(j==null)return null;if(typeof j==="function")return j.$$typeof===e6?null:j.displayName||j.name||null;if(typeof j==="string")return j;switch(j){case q0:return"Fragment";case z0:return"Profiler";case f:return"StrictMode";case y0:return"Suspense";case U0:return"SuspenseList";case _1:return"Activity"}if(typeof j==="object")switch(typeof j.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),j.$$typeof){case e:return"Portal";case s:return j.displayName||"Context";case N0:return(j._context.displayName||"Context")+".Consumer";case H0:var Z0=j.render;return j=j.displayName,j||(j=Z0.displayName||Z0.name||"",j=j!==""?"ForwardRef("+j+")":"ForwardRef"),j;case u0:return Z0=j.displayName||null,Z0!==null?Z0:M(j.type)||"Memo";case a0:Z0=j._payload,j=j._init;try{return M(j(Z0))}catch(F0){}}return null}function H(j){if(j===q0)return"<>";if(typeof j==="object"&&j!==null&&j.$$typeof===a0)return"<...>";try{var Z0=M(j);return Z0?"<"+Z0+">":"<...>"}catch(F0){return"<...>"}}function O(){var j=W1.A;return j===null?null:j.getOwner()}function _(){return Error("react-stack-top-frame")}function A(j){if(O4.call(j,"key")){var Z0=Object.getOwnPropertyDescriptor(j,"key").get;if(Z0&&Z0.isReactWarning)return!1}return j.key!==void 0}function P(j,Z0){function F0(){R6||(R6=!0,console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",Z0))}F0.isReactWarning=!0,Object.defineProperty(j,"key",{get:F0,configurable:!0})}function L(){var j=M(this.type);return l4[j]||(l4[j]=!0,console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.")),j=this.props.ref,j!==void 0?j:null}function v(j,Z0,F0,T0,x0,Y1){var f0=F0.ref;return j={$$typeof:$0,type:j,key:Z0,props:F0,_owner:T0},(f0!==void 0?f0:null)!==null?Object.defineProperty(j,"ref",{enumerable:!1,get:L}):Object.defineProperty(j,"ref",{enumerable:!1,value:null}),j._store={},Object.defineProperty(j._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(j,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(j,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:x0}),Object.defineProperty(j,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:Y1}),Object.freeze&&(Object.freeze(j.props),Object.freeze(j)),j}function R(j,Z0){return Z0=v(j.type,Z0,j.props,j._owner,j._debugStack,j._debugTask),j._store&&(Z0._store.validated=j._store.validated),Z0}function T(j){C(j)?j._store&&(j._store.validated=1):typeof j==="object"&&j!==null&&j.$$typeof===a0&&(j._payload.status==="fulfilled"?C(j._payload.value)&&j._payload.value._store&&(j._payload.value._store.validated=1):j._store&&(j._store.validated=1))}function C(j){return typeof j==="object"&&j!==null&&j.$$typeof===$0}function y(j){var Z0={"=":"=0",":":"=2"};return"$"+j.replace(/[=:]/g,function(F0){return Z0[F0]})}function b(j,Z0){return typeof j==="object"&&j!==null&&j.key!=null?(U(j.key),y(""+j.key)):Z0.toString(36)}function S(j){switch(j.status){case"fulfilled":return j.value;case"rejected":throw j.reason;default:switch(typeof j.status==="string"?j.then(W,W):(j.status="pending",j.then(function(Z0){j.status==="pending"&&(j.status="fulfilled",j.value=Z0)},function(Z0){j.status==="pending"&&(j.status="rejected",j.reason=Z0)})),j.status){case"fulfilled":return j.value;case"rejected":throw j.reason}}throw j}function I(j,Z0,F0,T0,x0){var Y1=typeof j;if(Y1==="undefined"||Y1==="boolean")j=null;var f0=!1;if(j===null)f0=!0;else switch(Y1){case"bigint":case"string":case"number":f0=!0;break;case"object":switch(j.$$typeof){case $0:case e:f0=!0;break;case a0:return f0=j._init,I(f0(j._payload),Z0,F0,T0,x0)}}if(f0){f0=j,x0=x0(f0);var Q1=T0===""?"."+b(f0,0):T0;return H1(x0)?(F0="",Q1!=null&&(F0=Q1.replace(Y7,"$&/")+"/"),I(x0,Z0,F0,"",function(_5){return _5})):x0!=null&&(C(x0)&&(x0.key!=null&&(f0&&f0.key===x0.key||U(x0.key)),F0=R(x0,F0+(x0.key==null||f0&&f0.key===x0.key?"":(""+x0.key).replace(Y7,"$&/")+"/")+Q1),T0!==""&&f0!=null&&C(f0)&&f0.key==null&&f0._store&&!f0._store.validated&&(F0._store.validated=2),x0=F0),Z0.push(x0)),1}if(f0=0,Q1=T0===""?".":T0+":",H1(j))for(var j0=0;j0 import('./MyComponent')) - -Did you accidentally put curly braces around the import?`,Z0),"default"in Z0||console.error(`lazy: Expected the result of a dynamic import() call. Instead received: %s - -Your code should look like: - const MyComponent = lazy(() => import('./MyComponent'))`,Z0),Z0.default;throw j._result}function k(){var j=W1.H;return j===null&&console.error(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: -1. You might have mismatching versions of React and the renderer (such as React DOM) -2. You might be breaking the Rules of Hooks -3. You might have more than one copy of React in the same app -See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),j}function i(){W1.asyncTransitions--}function X0(j){if(_4===null)try{var Z0=("require"+Math.random()).slice(0,7);_4=(YU&&YU[Z0]).call(YU,"timers").setImmediate}catch(F0){_4=function(T0){$2===!1&&($2=!0,typeof MessageChannel>"u"&&console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var x0=new MessageChannel;x0.port1.onmessage=T0,x0.port2.postMessage(void 0)}}return _4(j)}function W0(j){return 1 ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),{then:function(j0,_5){x0=!0,f0.then(function(K5){if(w0(Z0,F0),F0===0){try{Q0(T0),X0(function(){return n(K5,j0,_5)})}catch(e5){W1.thrownErrors.push(e5)}if(0 ...)"))}),W1.actQueue=null),0W1.recentlyCreatedOwnerStacks++;return v(j,x0,T0,O(),j0?Error("react-stack-top-frame"):f2,j0?S1(H(j)):T6)},va.createRef=function(){var j={current:null};return Object.seal(j),j},va.forwardRef=function(j){j!=null&&j.$$typeof===u0?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof j!=="function"?console.error("forwardRef requires a render function but was given %s.",j===null?"null":typeof j):j.length!==0&&j.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",j.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),j!=null&&j.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var Z0={$$typeof:H0,render:j},F0;return Object.defineProperty(Z0,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(T0){F0=T0,j.name||j.displayName||(Object.defineProperty(j,"name",{value:T0}),j.displayName=T0)}}),Z0},va.isValidElement=C,va.lazy=function(j){j={_status:-1,_result:j};var Z0={$$typeof:a0,_payload:j,_init:l},F0={name:"lazy",start:-1,end:-1,value:null,owner:null,debugStack:Error("react-stack-top-frame"),debugTask:console.createTask?console.createTask("lazy()"):null};return j._ioInfo=F0,Z0._debugInfo=[{awaited:F0}],Z0},va.memo=function(j,Z0){j==null&&console.error("memo: The first argument must be a component. Instead received: %s",j===null?"null":typeof j),Z0={$$typeof:u0,type:j,compare:Z0===void 0?null:Z0};var F0;return Object.defineProperty(Z0,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(T0){F0=T0,j.name||j.displayName||(Object.defineProperty(j,"name",{value:T0}),j.displayName=T0)}}),Z0},va.startTransition=function(j){var Z0=W1.T,F0={};F0._updatedFibers=new Set,W1.T=F0;try{var T0=j(),x0=W1.S;x0!==null&&x0(F0,T0),typeof T0==="object"&&T0!==null&&typeof T0.then==="function"&&(W1.asyncTransitions++,T0.then(i,i),T0.then(W,C5))}catch(Y1){C5(Y1)}finally{Z0===null&&F0._updatedFibers&&(j=F0._updatedFibers.size,F0._updatedFibers.clear(),10{(function(){function Z(){if(y=!1,g){var n=Ca.unstable_now();i=n;var Q0=!0;try{Z:{T=!1,C&&(C=!1,S(l),l=-1),R=!0;var $0=v;try{Y:{B(n);for(L=Q(_);L!==null&&!(L.expirationTime>n&&$());){var e=L.callback;if(typeof e==="function"){L.callback=null,v=L.priorityLevel;var q0=e(L.expirationTime<=n);if(n=Ca.unstable_now(),typeof q0==="function"){L.callback=q0,B(n),Q0=!0;break Y}L===Q(_)&&z(_),B(n)}else z(_);L=Q(_)}if(L!==null)Q0=!0;else{var f=Q(A);f!==null&&U(W,f.startTime-n),Q0=!1}}break Z}finally{L=null,v=$0,R=!1}Q0=void 0}}finally{Q0?X0():g=!1}}}function Y(n,Q0){var $0=n.length;n.push(Q0);Z:for(;0<$0;){var e=$0-1>>>1,q0=n[e];if(0>>1;eq(N0,$0))sq(H0,N0)?(n[e]=H0,n[s]=$0,e=s):(n[e]=N0,n[z0]=$0,e=z0);else if(sq(H0,$0))n[e]=H0,n[s]=$0,e=s;else break Z}}return Q0}function q(n,Q0){var $0=n.sortIndex-Q0.sortIndex;return $0!==0?$0:n.id-Q0.id}function B(n){for(var Q0=Q(A);Q0!==null;){if(Q0.callback===null)z(A);else if(Q0.startTime<=n)z(A),Q0.sortIndex=Q0.expirationTime,Y(_,Q0);else break;Q0=Q(A)}}function W(n){if(C=!1,B(n),!T)if(Q(_)!==null)T=!0,g||(g=!0,X0());else{var Q0=Q(A);Q0!==null&&U(W,Q0.startTime-n)}}function $(){return y?!0:Ca.unstable_now()-in||125e?(n.sortIndex=$0,Y(A,n),Q(_)===null&&n===Q(A)&&(C?(S(l),l=-1):C=!0,U(W,$0-e))):(n.sortIndex=q0,Y(_,n),T||R||(T=!0,g||(g=!0,X0()))),n},Ca.unstable_shouldYield=$,Ca.unstable_wrapCallback=function(n){var Q0=v;return function(){var $0=v;v=Q0;try{return n.apply(this,arguments)}finally{v=$0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var dI=E3((Da)=>{var dA=h(Y0());(function(){function Z(){}function Y(H){return""+H}function Q(H,O,_){var A=3` tag.%s',_),typeof H==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=z(_,O.crossOrigin);$.d.L(H,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Da.preloadModule=function(H,O){var _="";typeof H==="string"&&H||(_+=" The `href` argument encountered was "+q(H)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+q(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+q(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof H==="string"&&(O?(_=z(O.as,O.crossOrigin),$.d.m(H,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(H))},Da.requestFormReset=function(H){$.d.r(H)},Da.unstable_batchedUpdates=function(H,O){return H(O)},Da.useFormState=function(H,O,_){return W().useFormState(H,O,_)},Da.useFormStatus=function(){return W().useHostTransitionStatus()},Da.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var I3=E3((hQ0,lI)=>{var ba=h(dI());lI.exports=ba});var rI=E3((Ea)=>{var a1=h(cI()),iQ=h(Y0()),lA=h(I3());(function(){function Z(J,X){for(J=J.memoizedState;J!==null&&0=X.length)return G;var w=X[K],N=T2(J)?J.slice():E1({},J);return N[w]=Y(J[w],X,K+1,G),N}function Q(J,X,K){if(X.length!==K.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GA8?console.error("Unexpected pop."):(X!==V_[A8]&&console.error("Unexpected Fiber popped."),J.current=P_[A8],P_[A8]=null,V_[A8]=null,A8--)}function W0(J,X,K){A8++,P_[A8]=J.current,V_[A8]=K,J.current=X}function w0(J){return J===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),J}function n(J,X){W0(j9,X,J),W0(sq,J,J),W0(y9,null,J);var K=X.nodeType;switch(K){case 9:case 11:K=K===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?vD(X):j8:j8;break;default:if(K=X.tagName,X=X.namespaceURI)X=vD(X),X=CD(X,K);else switch(K){case"svg":X=sQ;break;case"math":X=sG;break;default:X=j8}}K=K.toLowerCase(),K=FT(null,K),K={context:X,ancestorInfo:K},X0(y9,J),W0(y9,K,J)}function Q0(J){X0(y9,J),X0(sq,J),X0(j9,J)}function $0(){return w0(y9.current)}function e(J){J.memoizedState!==null&&W0(t$,J,J);var X=w0(y9.current),K=J.type,G=CD(X.context,K);K=FT(X.ancestorInfo,K),G={context:G,ancestorInfo:K},X!==G&&(W0(sq,J,J),W0(y9,G,J))}function q0(J){sq.current===J&&(X0(y9,J),X0(sq,J)),t$.current===J&&(X0(t$,J),gK._currentValue=yY)}function f(){}function z0(){if(oq===0){Qb=console.log,Xb=console.info,zb=console.warn,qb=console.error,Kb=console.group,Bb=console.groupCollapsed,Wb=console.groupEnd;var J={configurable:!0,enumerable:!0,value:f,writable:!0};Object.defineProperties(console,{info:J,log:J,warn:J,error:J,group:J,groupCollapsed:J,groupEnd:J})}oq++}function N0(){if(oq--,oq===0){var J={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:E1({},J,{value:Qb}),info:E1({},J,{value:Xb}),warn:E1({},J,{value:zb}),error:E1({},J,{value:qb}),group:E1({},J,{value:Kb}),groupCollapsed:E1({},J,{value:Bb}),groupEnd:E1({},J,{value:Wb})})}0>oq&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function s(J){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,J=J.stack,Error.prepareStackTrace=X,J.startsWith(`Error: react-stack-top-frame -`)&&(J=J.slice(29)),X=J.indexOf(` -`),X!==-1&&(J=J.slice(X+1)),X=J.indexOf("react_stack_bottom_frame"),X!==-1&&(X=J.lastIndexOf(` -`,X)),X!==-1)J=J.slice(0,X);else return"";return J}function H0(J){if(L_===void 0)try{throw Error()}catch(K){var X=K.stack.trim().match(/\n( *(at )?)/);L_=X&&X[1]||"",$b=-1)":-1F||E[N]!==a[F]){var o=` -`+E[N].replace(" at new "," at ");return J.displayName&&o.includes("")&&(o=o.replace("",J.displayName)),typeof J==="function"&&T_.set(J,o),o}while(1<=N&&0<=F);break}}}finally{R_=!1,M0.H=G,N0(),Error.prepareStackTrace=K}return E=(E=J?J.displayName||J.name:"")?H0(E):"",typeof J==="function"&&T_.set(J,E),E}function U0(J,X){switch(J.tag){case 26:case 27:case 5:return H0(J.type);case 16:return H0("Lazy");case 13:return J.child!==X&&X!==null?H0("Suspense Fallback"):H0("Suspense");case 19:return H0("SuspenseList");case 0:case 15:return y0(J.type,!1);case 11:return y0(J.type.render,!1);case 1:return y0(J.type,!0);case 31:return H0("Activity");default:return""}}function u0(J){try{var X="",K=null;do{X+=U0(J,K);var G=J._debugInfo;if(G)for(var w=G.length-1;0<=w;w--){var N=G[w];if(typeof N.name==="string"){var F=X;Z:{var{name:V,env:D,debugLocation:E}=N;if(E!=null){var a=s(E),o=a.lastIndexOf(` -`),c=o===-1?a:a.slice(o+1);if(c.indexOf(V)!==-1){var K0=` -`+c;break Z}}K0=H0(V+(D?" ["+D+"]":""))}X=F+K0}}K=J,J=J.return}while(J);return X}catch(v0){return` -Error generating stack: `+v0.message+` -`+v0.stack}}function a0(J){return(J=J?J.displayName||J.name:"")?H0(J):""}function _1(){if(C4===null)return null;var J=C4._debugOwner;return J!=null?l(J):null}function B1(){if(C4===null)return"";var J=C4;try{var X="";switch(J.tag===6&&(J=J.return),J.tag){case 26:case 27:case 5:X+=H0(J.type);break;case 13:X+=H0("Suspense");break;case 19:X+=H0("SuspenseList");break;case 31:X+=H0("Activity");break;case 30:case 0:case 15:case 1:J._debugOwner||X!==""||(X+=a0(J.type));break;case 11:J._debugOwner||X!==""||(X+=a0(J.type.render))}for(;J;)if(typeof J.tag==="number"){var K=J;J=K._debugOwner;var G=K._debugStack;if(J&&G){var w=s(G);w!==""&&(X+=` -`+w)}}else if(J.debugStack!=null){var N=J.debugStack;(J=J.owner)&&N&&(X+=` -`+s(N))}else break;var F=X}catch(V){F=` -Error generating stack: `+V.message+` -`+V.stack}return F}function R0(J,X,K,G,w,N,F){var V=C4;C1(J);try{return J!==null&&J._debugTask?J._debugTask.run(X.bind(null,K,G,w,N,F)):X(K,G,w,N,F)}finally{C1(V)}throw Error("runWithFiberInDEV should never be called in production. This is a bug in React.")}function C1(J){M0.getCurrentStack=J===null?null:B1,F3=!1,C4=J}function X5(J){return typeof Symbol==="function"&&Symbol.toStringTag&&J[Symbol.toStringTag]||J.constructor.name||"Object"}function n5(J){try{return O5(J),!1}catch(X){return!0}}function O5(J){return""+J}function H1(J,X){if(n5(J))return console.error("The provided `%s` attribute is an unsupported type %s. This value must be coerced to a string before using it here.",X,X5(J)),O5(J)}function e6(J,X){if(n5(J))return console.error("The provided `%s` CSS property is an unsupported type %s. This value must be coerced to a string before using it here.",X,X5(J)),O5(J)}function W1(J){if(n5(J))return console.error("Form field values (value, checked, defaultValue, or defaultChecked props) must be strings, not %s. This value must be coerced to a string before using it here.",X5(J)),O5(J)}function O4(J){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{NQ=X.inject(J),P7=X}catch(K){console.error("React instrumentation encountered an error: %o.",K)}return X.checkDCE?!0:!1}function S1(J){if(typeof cl==="function"&&dl(J),P7&&typeof P7.setStrictMode==="function")try{P7.setStrictMode(NQ,J)}catch(X){P3||(P3=!0,console.error("React instrumentation encountered an error: %o",X))}}function R6(J){return J>>>=0,J===0?32:31-(ll(J)/rl|0)|0}function Z7(J){var X=J&42;if(X!==0)return X;switch(J&-J){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return J&261888;case 262144:case 524288:case 1048576:case 2097152:return J&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return J&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),J}}function l4(J,X,K){var G=J.pendingLanes;if(G===0)return 0;var w=0,N=J.suspendedLanes,F=J.pingedLanes;J=J.warmLanes;var V=G&134217727;return V!==0?(G=V&~N,G!==0?w=Z7(G):(F&=V,F!==0?w=Z7(F):K||(K=V&~J,K!==0&&(w=Z7(K))))):(V=G&~N,V!==0?w=Z7(V):F!==0?w=Z7(F):K||(K=G&~J,K!==0&&(w=Z7(K)))),w===0?0:X!==0&&X!==w&&(X&N)===0&&(N=w&-w,K=X&-X,N>=K||N===32&&(K&4194048)!==0)?X:w}function f2(J,X){return(J.pendingLanes&~(J.suspendedLanes&~J.pingedLanes)&X)===0}function T6(J,X){switch(J){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function v6(){var J=ZG;return ZG<<=1,(ZG&62914560)===0&&(ZG=4194304),J}function Y7(J){for(var X=[],K=0;31>K;K++)X.push(J);return X}function C5(J,X){J.pendingLanes|=X,X!==268435456&&(J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0)}function $2(J,X,K,G,w,N){var F=J.pendingLanes;J.pendingLanes=K,J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0,J.expiredLanes&=K,J.entangledLanes&=K,J.errorRecoveryDisabledLanes&=K,J.shellSuspendCounter=0;var{entanglements:V,expirationTimes:D,hiddenUpdates:E}=J;for(K=F&~K;0"u")return null;try{return J.activeElement||J.body}catch(X){return J.body}}function k0(J){return J.replace(tl,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function l0(J,X){X.checked===void 0||X.defaultChecked===void 0||Nb||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",_1()||"A component",X.type),Nb=!0),X.value===void 0||X.defaultValue===void 0||Hb||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",_1()||"A component",X.type),Hb=!0)}function s0(J,X,K,G,w,N,F,V){if(J.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(H1(F,"type"),J.type=F):J.removeAttribute("type"),X!=null)if(F==="number"){if(X===0&&J.value===""||J.value!=X)J.value=""+t(X)}else J.value!==""+t(X)&&(J.value=""+t(X));else F!=="submit"&&F!=="reset"||J.removeAttribute("value");X!=null?r0(J,F,t(X)):K!=null?r0(J,F,t(K)):G!=null&&J.removeAttribute("value"),w==null&&N!=null&&(J.defaultChecked=!!N),w!=null&&(J.checked=w&&typeof w!=="function"&&typeof w!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(H1(V,"name"),J.name=""+t(V)):J.removeAttribute("name")}function J1(J,X,K,G,w,N,F,V){if(N!=null&&typeof N!=="function"&&typeof N!=="symbol"&&typeof N!=="boolean"&&(H1(N,"type"),J.type=N),X!=null||K!=null){if(!(N!=="submit"&&N!=="reset"||X!==void 0&&X!==null)){V0(J);return}K=K!=null?""+t(K):"",X=X!=null?""+t(X):K,V||X===J.value||(J.value=X),J.defaultValue=X}G=G!=null?G:w,G=typeof G!=="function"&&typeof G!=="symbol"&&!!G,J.checked=V?J.checked:!!G,J.defaultChecked=!!G,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(H1(F,"name"),J.name=F),V0(J)}function r0(J,X,K){X==="number"&&S0(J.ownerDocument)===J||J.defaultValue===""+K||(J.defaultValue=""+K)}function p1(J,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?iQ.Children.forEach(X.children,function(K){K==null||typeof K==="string"||typeof K==="number"||typeof K==="bigint"||_b||(_b=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to