mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
commit
e9388f02c0
45 changed files with 4079 additions and 3960 deletions
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
134
apps/fabro-web/app/lib/api-client.test.ts
Normal file
134
apps/fabro-web/app/lib/api-client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
236
apps/fabro-web/app/lib/api-client.ts
Normal file
236
apps/fabro-web/app/lib/api-client.ts
Normal 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>;
|
||||
}
|
||||
61
apps/fabro-web/app/lib/board-events.test.tsx
Normal file
61
apps/fabro-web/app/lib/board-events.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
65
apps/fabro-web/app/lib/board-events.ts
Normal file
65
apps/fabro-web/app/lib/board-events.ts
Normal 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]);
|
||||
}
|
||||
127
apps/fabro-web/app/lib/mutations.ts
Normal file
127
apps/fabro-web/app/lib/mutations.ts
Normal 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 }>;
|
||||
},
|
||||
);
|
||||
}
|
||||
169
apps/fabro-web/app/lib/queries.ts
Normal file
169
apps/fabro-web/app/lib/queries.ts
Normal 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 };
|
||||
26
apps/fabro-web/app/lib/query-keys.test.ts
Normal file
26
apps/fabro-web/app/lib/query-keys.test.ts
Normal 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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
64
apps/fabro-web/app/lib/query-keys.ts
Normal file
64
apps/fabro-web/app/lib/query-keys.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
126
apps/fabro-web/app/lib/run-events.test.tsx
Normal file
126
apps/fabro-web/app/lib/run-events.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
132
apps/fabro-web/app/lib/run-events.ts
Normal file
132
apps/fabro-web/app/lib/run-events.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
21
apps/fabro-web/app/lib/stage-sidebar.ts
Normal file
21
apps/fabro-web/app/lib/stage-sidebar.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "./format";
|
||||
|
||||
export function mapRunStagesToSidebarStages(
|
||||
stagesResult: PaginatedRunStageList | null | undefined,
|
||||
): Stage[] {
|
||||
return (stagesResult?.data ?? [])
|
||||
.filter((stage) => isVisibleStage(stage.id))
|
||||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
dotId: stage.dot_id ?? stage.id,
|
||||
status: stage.status as Stage["status"],
|
||||
duration: stage.duration_secs != null
|
||||
? formatDurationSecs(stage.duration_secs)
|
||||
: "--",
|
||||
}));
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isRouteErrorResponse, useRouteError } from "react-router";
|
||||
import { extractRequestId } from "../../lib/api-client";
|
||||
|
||||
/**
|
||||
* R4 empty-state taxonomy. See plan § Unit 11:
|
||||
|
|
@ -187,7 +188,7 @@ export function RunFilesErrorBoundary() {
|
|||
if (isRouteErrorResponse(error)) {
|
||||
return renderStatusError({
|
||||
status: error.status,
|
||||
requestId: extractRequestIdFromUnknown(error.data),
|
||||
requestId: extractRequestId(error.data),
|
||||
onRetry: () => window.location.reload(),
|
||||
});
|
||||
}
|
||||
|
|
@ -200,28 +201,3 @@ export function RunFilesErrorBoundary() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-ID parser used only by the ErrorBoundary path. The loader path
|
||||
* already extracts request_id into `RunFilesLoaderResult.error.requestId`
|
||||
* via `run-files.tsx::extractRequestId` — this is the body shape
|
||||
* react-router hands us in `useRouteError().data` for non-Response errors.
|
||||
*/
|
||||
function extractRequestIdFromUnknown(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (typeof b.request_id === "string") return b.request_id;
|
||||
const errors = b.errors;
|
||||
if (Array.isArray(errors) && errors.length > 0) {
|
||||
const first = errors[0];
|
||||
if (first && typeof first === "object") {
|
||||
const rec = first as Record<string, unknown>;
|
||||
if (typeof rec.request_id === "string") return rec.request_id;
|
||||
if (typeof rec.detail === "string") {
|
||||
const match = rec.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -19,6 +19,7 @@
|
|||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "7.12.0",
|
||||
"swr": "^2.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.13",
|
||||
|
|
@ -1250,6 +1251,8 @@
|
|||
|
||||
"svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="],
|
||||
|
||||
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
|
|
|||
2402
lib/crates/fabro-spa/assets/assets/entry-5nzjj9ar.js
generated
2402
lib/crates/fabro-spa/assets/assets/entry-5nzjj9ar.js
generated
File diff suppressed because one or more lines are too long
2402
lib/crates/fabro-spa/assets/assets/entry-wjy29xg9.js
generated
Normal file
2402
lib/crates/fabro-spa/assets/assets/entry-wjy29xg9.js
generated
Normal file
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -58,7 +58,7 @@
|
|||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||
<script type="module" src="/assets/entry-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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue