Merge remote-tracking branch 'origin/main'

# Conflicts:
#	apps/fabro-web/app/routes/run-files.test.ts
#	apps/fabro-web/app/routes/run-files.tsx
#	lib/crates/fabro-spa/assets/assets/entry-5nzjj9ar.js
#	lib/crates/fabro-spa/assets/index.html
This commit is contained in:
Bryan Helmkamp 2026-04-25 07:51:59 -04:00
commit e9388f02c0
No known key found for this signature in database
45 changed files with 4079 additions and 3960 deletions

View file

@ -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;
}
});
});

View file

@ -1,178 +0,0 @@
export interface ApiOptions {
init?: RequestInit;
request?: Request;
}
export interface PaginatedEnvelope<T> {
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<Response> {
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<T>(path: string, options?: ApiOptions): Promise<T> {
const response = await apiFetch(path, options);
if (!response.ok) {
throw new Response(null, { status: response.status, statusText: response.statusText });
}
return response.json() as Promise<T>;
}
export async function apiPaginatedJson<TItem, TExtra extends object = {}>(
path: string,
options?: ApiOptions,
): Promise<PaginatedEnvelope<TItem> & 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<TItem> & TExtra;
if (extras == null) {
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
Record<string, unknown>;
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<T>(
path: string,
options?: ApiOptions,
): Promise<T | null> {
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<T>;
}
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");
}

View file

@ -9,7 +9,6 @@ import {
} from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
import { 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<Map<string, number>>(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<string>(

View file

@ -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(
<StrictMode>
<RouterProvider router={router} />
<SWRConfig
value={{
fetcher: apiFetcher,
revalidateOnFocus: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
}}
>
<RouterProvider router={router} />
</SWRConfig>
</StrictMode>,
);

View file

@ -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<string | false | null | undefined>) {
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 <div className="min-h-full bg-page" />;
}
if (error || !auth) {
return (
<div className="min-h-full bg-page py-12">
<ErrorState
title="Couldn't load your session"
description="Refresh the page or sign in again."
/>
</div>
);
}
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 (

View file

@ -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");
});
});

View file

@ -0,0 +1,236 @@
export interface ApiOptions {
init?: RequestInit;
request?: Request;
}
export interface PaginatedEnvelope<T> {
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<string, unknown>;
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<string, unknown>;
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<unknown> {
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<ApiError> {
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<Response> {
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<T>(key: string): Promise<T> {
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<T>;
}
export async function apiTextFetcher(key: string): Promise<string> {
const response = await apiRequest(key);
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return response.text();
}
export async function apiNullableFetcher<T>(key: string): Promise<T | null> {
const response = await apiRequest(key);
if (isNotAvailable(response.status)) return null;
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return response.json() as Promise<T>;
}
export async function apiNullableTextFetcher(key: string): Promise<string | null> {
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<TItem, TExtra extends object = {}>(
key: string,
): Promise<PaginatedEnvelope<TItem> & 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<TItem> & TExtra;
if (extras == null) {
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
Record<string, unknown>;
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<TResponse, TArg = unknown>(
key: string,
{ arg }: { arg: TArg },
): Promise<TResponse> {
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<TResponse>;
}

View file

@ -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);
});
});

View file

@ -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<string, SharedEventSubscription>();
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<EventPayload>({
subscriptions,
subscriptionKey: BOARD_SUBSCRIPTION_KEY,
url: queryKeys.system.attach(),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => ({
keys: payload.event && shouldRefreshBoardForEvent(payload.event)
? [queryKeys.boards.runs()]
: [],
}),
});
}
export function useBoardEvents() {
const { mutate } = useSWRConfig();
useEffect(() => subscribeToBoardEvents(mutate as MutateFn), [mutate]);
}

View file

@ -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<PreviewMutationResult> => {
const result = await apiJsonMutation<PreviewUrlResponse, PreviewRunArg>(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<RunStatusResponse>,
) {
const { mutate } = useSWRConfig();
const key = id ? queryKeys.runs[intent](id) : null;
return useSWRMutation(
key,
async (): Promise<LifecycleMutationResult> => {
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<void, { enabled: boolean }>(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 }>;
},
);
}

View file

@ -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<PaginatedBoardRunList["data"][number]> & {
columns: { id: string; name: string }[];
}
>(queryKeys.boards.runs(), apiPaginatedFetcher);
}
export function useRun(id: string | undefined) {
return useSWR<RunSummaryResponse | null>(
id ? queryKeys.runs.detail(id) : null,
apiNullableFetcher,
);
}
export function useRunFiles(id: string | undefined) {
return useSWR<PaginatedRunFileList | null>(
id ? queryKeys.runs.files(id) : null,
apiNullableFetcher,
{ keepPreviousData: true },
);
}
export function useRunStages(id: string | undefined) {
return useSWR<PaginatedRunStageList | null>(
id ? queryKeys.runs.stages(id) : null,
apiNullableFetcher,
);
}
export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") {
return useSWR<string | null>(
id ? queryKeys.runs.graph(id, direction) : null,
apiNullableTextFetcher,
);
}
export function useRunSettings<T = Record<string, unknown>>(id: string | undefined) {
return useSWR<T>(
id ? queryKeys.runs.settings(id) : null,
apiFetcher,
immutableOptions,
);
}
export function useRunBilling(id: string | undefined) {
return useSWR<RunBilling>(id ? queryKeys.runs.billing(id) : null, apiFetcher);
}
export function useRunQuestionText(id: string | undefined, enabled: boolean) {
return useSWR<string | null>(
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<PaginatedStageTurnList | null>(
id && stageId && enabled ? queryKeys.runs.stageTurns(id, stageId) : null,
apiNullableFetcher,
);
}
export function useRunEventsList(id: string | undefined, enabled = true) {
return useSWR<PaginatedEventList | null>(
id && enabled ? queryKeys.runs.events(id, 1000) : null,
apiNullableFetcher,
);
}
export function useWorkflows() {
return useSWR<PaginatedWorkflowListResponse | null>(
queryKeys.workflows.list(),
apiNullableFetcher,
immutableOptions,
);
}
export function useWorkflow(name: string | undefined) {
return useSWR<WorkflowDetailResponse | null>(
name ? queryKeys.workflows.detail(name) : null,
apiNullableFetcher,
immutableOptions,
);
}
export function useWorkflowRuns(name: string | undefined) {
return useSWR<PaginatedRunList | null>(
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<ServerSettings>(queryKeys.settings.server(), apiFetcher, immutableOptions);
}
export { apiTextFetcher };

View file

@ -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"),
]);
});
});

View file

@ -0,0 +1,64 @@
function pathSegment(value: string): string {
return encodeURIComponent(value);
}
function withQuery(path: string, params: Record<string, string | number | null | undefined>): 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",
},
};

View file

@ -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<RunStatusResponse> {
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<LifecycleA
}
}
function isLifecycleActionError(value: unknown): value is LifecycleActionError {
export function isLifecycleActionError(value: unknown): value is LifecycleActionError {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return typeof record.status === "number" && Array.isArray(record.errors);

View file

@ -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);
});
});

View file

@ -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<string, unknown>;
}
const subscriptions = new Map<string, SharedEventSubscription>();
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<RunEventPayload>({
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]);
}

View file

@ -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);
});
});

View file

@ -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<MutatorCallback>;
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<string>;
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<MutateFn, number>;
pendingKeys: Set<string>;
debounceTimer: ReturnType<typeof setTimeout> | 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<TPayload extends EventPayload>({
subscriptions,
subscriptionKey,
url,
mutate,
resolveInvalidation,
eventSourceFactory = createBrowserEventSource,
debounceMs = 300,
}: {
subscriptions: Map<string, SharedEventSubscription>;
subscriptionKey: string;
url: string;
mutate: MutateFn;
resolveInvalidation: (payload: TPayload) => EventInvalidation;
eventSourceFactory?: (url: string) => EventSourceLike;
debounceMs?: number;
}): () => void {
let subscription = subscriptions.get(subscriptionKey);
if (!subscription) {
const source = eventSourceFactory(url);
subscription = {
source,
refcount: 0,
mutators: new Map(),
pendingKeys: new Set(),
debounceTimer: null,
};
subscriptions.set(subscriptionKey, subscription);
const source = eventSourceFactory(`/api/v1/runs/${runId}/attach?since_seq=1`);
let debounceTimer: ReturnType<typeof setTimeout> | 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<string>;
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<string, SharedEventSubscription>,
subscriptionKey: string,
{ flushPending = false }: { flushPending?: boolean } = {},
) {
const subscription = subscriptions.get(subscriptionKey);
if (!subscription) return;
if (flushPending) {
flushInvalidations(subscription);
}
if (subscription.debounceTimer) {
clearTimeout(subscription.debounceTimer);
}
subscription.source.close();
subscriptions.delete(subscriptionKey);
}

View file

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

View file

@ -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<any>;
loader?: RouteObject["loader"];
action?: RouteObject["action"];
handle?: RouteObject["handle"];
ErrorBoundary?: React.ComponentType<any>;
};
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<RouteObject, "path" | "Component" | "loader" | "action" | "index"> = {},
extra: Omit<RouteObject, "path" | "Component" | "index"> = {},
): 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,
}),

View file

@ -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.");

View file

@ -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<PaginatedSavedQueryList>("/insights/queries", { request }),
apiJson<PaginatedHistoryEntryList>("/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 (

View file

@ -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;
}

View file

@ -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<RunBilling>(`/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;

View file

@ -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<string, string>) {
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);

View file

@ -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<typeof mapRunSummaryToRunItem> & {
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<RunDetailLoaderData> {
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<RunDetailLoaderD
: { label: statusKind, dot: "bg-fg-muted", text: "text-fg-muted" };
return {
run: {
...item,
statusLabel: display.label,
statusDot: display.dot,
statusText: display.text,
},
blockedQuestionText:
statusKind === "blocked"
? await loadBlockedQuestionText(params.id, request?.signal)
: null,
...item,
statusLabel: display.label,
statusDot: display.dot,
statusText: display.text,
};
}
export async function action({ params, request }: any): Promise<RunDetailActionResult> {
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<PreviewUrlResponse>(`/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<RunDetailActionResult>();
const cancelFetcher = useFetcher<RunDetailActionResult>();
const archiveFetcher = useFetcher<RunDetailActionResult>();
const unarchiveFetcher = useFetcher<RunDetailActionResult>();
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<LifecycleToastState>(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 <div className="py-12" />;
}
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 (
<div>
@ -282,70 +205,71 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{visibility.showPrimaryCancel && (
<cancelFetcher.Form method="post">
<input type="hidden" name="intent" value="cancel" />
<div>
<button
type="submit"
type="button"
onClick={() => void cancelMutation.trigger()}
disabled={cancelPending}
className={CANCEL_BUTTON_CLASS}
>
{cancelPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{cancelPending ? "Cancelling…" : "Cancel"}
</button>
</cancelFetcher.Form>
</div>
)}
{visibility.showArchive && (
<archiveFetcher.Form method="post">
<input type="hidden" name="intent" value="archive" />
<div>
<button
type="submit"
type="button"
onClick={() => void archiveMutation.trigger()}
disabled={archivePending}
className={MUTATION_BUTTON_CLASS}
>
{archivePending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{archivePending ? "Archiving…" : "Archive"}
</button>
</archiveFetcher.Form>
</div>
)}
{visibility.showUnarchive && (
<unarchiveFetcher.Form method="post">
<input type="hidden" name="intent" value="unarchive" />
<div>
<button
type="submit"
type="button"
onClick={() => void unarchiveMutation.trigger()}
disabled={unarchivePending}
className={MUTATION_BUTTON_CLASS}
>
{unarchivePending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{unarchivePending ? "Restoring…" : "Unarchive"}
</button>
</unarchiveFetcher.Form>
</div>
)}
{run.sandboxId && (
<previewFetcher.Form method="post">
<input type="hidden" name="intent" value="preview" />
<input type="hidden" name="port" value="3000" />
<input type="hidden" name="expires_in_secs" value="3600" />
<div>
<button
type="submit"
type="button"
onClick={() => void previewMutation.trigger({
port: 3000,
expires_in_secs: 3600,
})}
disabled={previewPending}
className={PRIMARY_BUTTON_CLASS}
>
{previewPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{previewPending ? "Opening…" : "Preview"}
</button>
</previewFetcher.Form>
</div>
)}
</div>
</div>
{visibility.showBlockedNotice && (
<BlockedRunNotice
questionText={blockedQuestionText}
questionText={blockedQuestion.data ?? null}
cancelling={cancelPending}
onCancel={() => 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<string | null> {
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<LifecycleActionResult> {
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<string, unknown>;
if (typeof record.status !== "number" || !Array.isArray(record.errors)) {
return null;
}
return {
status: record.status,
errors: record.errors.filter(isErrorResponseEntry),
};
}
function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.status === "string"
&& typeof record.title === "string"
&& typeof record.detail === "string"
);
}
function isLifecycleActionFailure(
value: LifecycleActionResult,
): value is Extract<LifecycleActionResult, { ok: false }> {
return value.ok === false;
value: RunDetailActionResult,
): value is Extract<LifecycleMutationResult, { ok: false }> {
return "ok" in value && value.ok === false;
}
export function handleLifecycleToastResult(
@ -492,24 +349,18 @@ export function handleLifecycleToastResult(
}
if (intent === "archive") {
const archiveToast: Parameters<ToastApi["push"]>[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" });
}

View file

@ -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<string, string>;
};
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: "<html>oops</html>" });
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);
});
});

View file

@ -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<string, unknown>).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<RunFilesLoaderResult> {
// 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<string, unknown>;
if (typeof b.request_id === "string") return b.request_id;
const errors = b.errors;
if (Array.isArray(errors) && errors.length > 0) {
const first = errors[0];
if (first && typeof first === "object") {
const rec = first as Record<string, unknown>;
if (typeof rec.request_id === "string") return rec.request_id;
if (typeof rec.detail === "string") {
const 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<typeof useMatches>): 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<number | null>(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 ? (
<InlineErrorBanner
message={revalidationError}
onRetry={() => revalidator.revalidate()}
onRetry={() => void filesQuery.mutate()}
/>
) : null}
<DegradedBanner reason={meta.degraded_reason} />
@ -595,7 +457,7 @@ export default function RunFiles({ loaderData }: any) {
{revalidationError ? (
<InlineErrorBanner
message={revalidationError}
onRetry={() => revalidator.revalidate()}
onRetry={() => void filesQuery.mutate()}
/>
) : null}
{body}

View file

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

View file

@ -1,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<PaginatedRunStageList>(`/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<Direction>("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<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
const [direction, setDirection] = useState<Direction>("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;

View file

@ -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<PaginatedRunStageList>(
`/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<Direction>("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<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const navigate = useNavigate();
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
const [direction, setDirection] = useState<Direction>("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) {
<StageSidebar stages={stages} runId={id!} />
<div className="min-w-0 flex-1">
{graphSvg ? (
{graphSvg === undefined && graphQuery.isLoading ? (
<div className="py-12" />
) : graphSvg ? (
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
<GraphToolbar
direction={direction}

View file

@ -1,32 +1,22 @@
import { useMemo } from "react";
import { useParams } from "react-router";
import { CollapsibleFile } from "../components/collapsible-file";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { apiJson } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import { useRunSettings, useRunStages } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
type WorkflowSettingsSnapshot = Record<string, unknown>;
export async function loader({ request, params }: any) {
const [{ data: apiStages }, settings] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiJson<WorkflowSettingsSnapshot>(`/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<WorkflowSettingsSnapshot>(id);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const settings = settingsQuery.data ?? {};
return (
<div className="flex gap-6">

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { Marked } from "marked";
@ -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<PaginatedRunStageList>(`/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<PaginatedStageTurnList>(
`/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<PaginatedEventList>(
`/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<TurnType, { kind: "command" }> }
);
}
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<number | null>(() =>
isRunning ? Date.now() : null,
);
const [, setTick] = useState(0);
useEffect(() => {
if (isRunning && runningStartRef.current === 0) {
runningStartRef.current = Date.now();
} else if (!isRunning) {
runningStartRef.current = 0;
}
setStartedAt((current) => {
if (!isRunning) return null;
return current ?? Date.now();
});
}, [isRunning]);
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
const interval = setInterval(() => setTick((tick) => tick + 1), 1000);
return () => clearInterval(interval);
}, [isRunning]);
if (isRunning && startedAt) {
return formatDurationSecs(Math.floor((Date.now() - startedAt) / 1000));
}
return duration;
}
export default function RunStages() {
const { id, stageId } = useParams();
const stagesQuery = useRunStages(id);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0];
const turnsQuery = useRunStageTurns(id, selectedStage?.id);
const hasStageTurns = (turnsQuery.data?.data.length ?? 0) > 0;
const shouldLoadEventFallback =
!!selectedStage?.id && !turnsQuery.isLoading && !turnsQuery.error && !hasStageTurns;
const eventsQuery = useRunEventsList(id, shouldLoadEventFallback);
const turns = useMemo(
() => mapTurns(turnsQuery.data, eventsQuery.data, selectedStage?.id),
[eventsQuery.data, selectedStage?.id, turnsQuery.data],
);
const isRunning = selectedStage?.status === "running";
if (!stages.length) {
return (
<div className="py-12">
@ -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 (
<div className="flex gap-6">
@ -426,7 +429,12 @@ export default function RunStages({ loaderData }: any) {
<div className="sticky top-0 z-10 -mx-2 flex items-center gap-2 bg-page/85 px-2 py-2 backdrop-blur">
<SelectedIcon className={`size-5 ${selectedConfig.color} ${isRunning ? "animate-spin" : ""}`} />
<h3 className="text-base font-semibold text-fg">{selectedStage.name}</h3>
<span className="font-mono text-xs tabular-nums text-fg-muted">{headerDuration}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">
<RunningStageDuration
isRunning={isRunning}
duration={selectedStage.duration}
/>
</span>
</div>
{turns.map((turn: TurnType, i: number) => {

View file

@ -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<string, RunItem[]>();
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<RunItem, "column" | "lifecycleStatusLabel">): 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<Set<string>>(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<typeof setTimeout> | 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);

View file

@ -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<ServerSettings>("/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 (
<>

View file

@ -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]);

View file

@ -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(() => {

View file

@ -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<ApiWorkflowDetail>(`/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) {
</div>
<div className="mt-6">
<Outlet />
<Outlet context={{ workflow }} />
</div>
</div>
);

View file

@ -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<PaginatedRunList>(`/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<ColumnStatus | "all">("all");
const filtered = runs.filter(

View file

@ -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<PaginatedWorkflowListResponse>("/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<TriggerFilter>("all");
const filtered = workflows.filter(

View file

@ -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",

View file

@ -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=="],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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