From f73a1bb8f7044cb37e3f6aa1c1cf0a77dbbede96 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 9 May 2026 21:48:31 -0400 Subject: [PATCH] feat(web): add Sandbox tab and route to run detail Adds /runs/:id/sandbox between Files Changed and Terminal, gated by the same sandbox-presence check as Terminal. The route fetches SandboxDetails through a new useRunSandboxDetails SWR hook and renders provider-neutral panels for Overview, Resources, Labels, and Timestamps. Null fields render as muted em dashes. --- apps/fabro-web/app/lib/queries.ts | 8 + apps/fabro-web/app/lib/query-keys.test.ts | 1 + apps/fabro-web/app/lib/query-keys.ts | 1 + apps/fabro-web/app/router.tsx | 2 + apps/fabro-web/app/routes/run-detail.test.ts | 53 +++++ apps/fabro-web/app/routes/run-detail.tsx | 1 + .../fabro-web/app/routes/run-sandbox.test.tsx | 144 ++++++++++++ apps/fabro-web/app/routes/run-sandbox.tsx | 214 ++++++++++++++++++ 8 files changed, 424 insertions(+) create mode 100644 apps/fabro-web/app/routes/run-sandbox.test.tsx create mode 100644 apps/fabro-web/app/routes/run-sandbox.tsx diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 510dd0c23..c86007112 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -15,6 +15,7 @@ import type { RunBilling, RunProjection, RunSummary, + SandboxDetails, ServerSettings, SystemInfoResponse, WorkflowDetailResponse, @@ -188,6 +189,13 @@ export function useRunBilling(id: string | undefined) { ); } +export function useRunSandboxDetails(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.sandbox(id) : null, + () => apiNullableData(() => humanInTheLoopApi.retrieveRunSandbox(id!)), + ); +} + export function useRunQuestions(id: string | undefined, enabled: boolean) { return useSWR( id && enabled ? queryKeys.runs.questions(id, 25, 0) : null, diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts index ffc857f38..3ad519dc7 100644 --- a/apps/fabro-web/app/lib/query-keys.test.ts +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -43,6 +43,7 @@ describe("queryKeys", () => { "run 1", "build step", ]); + expect(queryKeys.runs.sandbox("run 1")).toEqual(["runs", "sandbox", "run 1"]); expect(queryKeys.system.attachUrl()).toBe("/api/v1/attach"); expect(queryKeys.runs.attachUrl("run 1")).toBe("/api/v1/runs/run%201/attach"); }); diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 0b5a7f0ad..491b2b5c5 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -64,6 +64,7 @@ export const queryKeys = { ["runs", "stage-events", id, stageId] as const, stageLog: (id: string, stageId: string, offset = 0, limit = 65_536) => ["runs", "stage-log", id, stageId, offset, limit] as const, + sandbox: (id: string) => ["runs", "sandbox", id] as const, preview: (id: string) => ["runs", "preview", id] as const, cancel: (id: string) => ["runs", "cancel", id] as const, archive: (id: string) => ["runs", "archive", id] as const, diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 9811309e4..69df41a02 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -21,6 +21,7 @@ import * as RunLogs from "./routes/run-logs"; import * as RunEvents from "./routes/run-events"; import * as RunArtifacts from "./routes/run-artifacts"; import * as RunFiles from "./routes/run-files"; +import * as RunSandbox from "./routes/run-sandbox"; import * as RunTerminal from "./routes/run-terminal"; import * as RunBilling from "./routes/run-billing"; import * as Insights from "./routes/insights"; @@ -101,6 +102,7 @@ export const routes: RouteObject[] = [ route("events", RunEvents), route("artifacts", RunArtifacts), route("files", RunFiles), + route("sandbox", RunSandbox), route("terminal", RunTerminal), route("billing", RunBilling), ], diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 93ad4cf3a..07df08d0d 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -393,6 +393,59 @@ describe("RunDetail full-height child routes", () => { expect(terminalLinks).toHaveLength(1); }); + test("shows the Sandbox tab when the run has a sandbox", async () => { + currentRunState = { sandbox: { provider: "docker", id: "container-1" } }; + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + }); + + const sandboxLinks = renderer.root.findAll( + (node) => + node.type === "a" && + node.props.href === "/runs/run_1/sandbox" && + node.children.includes("Sandbox"), + ); + expect(sandboxLinks).toHaveLength(1); + }); + + test("hides the Sandbox tab when the run has no sandbox", async () => { + currentRunState = {}; + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + }); + + const sandboxLinks = renderer.root.findAll( + (node) => + node.type === "a" && + node.props.href === "/runs/run_1/sandbox", + ); + expect(sandboxLinks).toHaveLength(0); + }); + + test("places the Sandbox tab immediately before Terminal", async () => { + currentRunState = { sandbox: { provider: "docker", id: "container-1" } }; + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + }); + + const tabLabels = renderer.root + .findAll( + (node) => + node.type === "a" && + typeof node.props.href === "string" && + node.props.href.startsWith("/runs/run_1"), + ) + .map((node) => + node.children.find((child) => typeof child === "string"), + ) + .filter((label): label is string => typeof label === "string"); + + const sandboxIndex = tabLabels.indexOf("Sandbox"); + const terminalIndex = tabLabels.indexOf("Terminal"); + expect(sandboxIndex).toBeGreaterThanOrEqual(0); + expect(terminalIndex).toBeGreaterThan(sandboxIndex); + }); + test("defers steer bar focus until after the Actions menu item click settles", async () => { const focusCalls: string[] = []; diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 9b56574bf..76d522ecd 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -62,6 +62,7 @@ const allTabs = [ { name: "Overview", path: "", count: null, demoOnly: false }, { name: "Stages", path: "/stages", count: null, demoOnly: false }, { name: "Files Changed", path: "/files", count: null, demoOnly: false }, + { name: "Sandbox", path: "/sandbox", count: null, demoOnly: false, requiresSandbox: true }, { name: "Terminal", path: "/terminal", count: null, demoOnly: false, requiresSandbox: true }, { name: "Billing", path: "/billing", count: null, demoOnly: false }, ]; diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx new file mode 100644 index 000000000..f80b17e7b --- /dev/null +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { MemoryRouter, Route, Routes } from "react-router"; + +import type { SandboxDetails } from "@qltysh/fabro-api-client"; + +let currentDetails: SandboxDetails | null = null; +let currentLoading = false; +let currentError: Error | null = null; + +mock.module("../lib/queries", () => ({ + useRunSandboxDetails: () => ({ + data: currentDetails, + error: currentError, + isLoading: currentLoading, + isValidating: false, + mutate: mock(() => Promise.resolve(currentDetails)), + }), +})); + +const { default: RunSandbox, formatBytesAsMemory } = await import("./run-sandbox"); +mock.restore(); + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderRoute() { + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + + } /> + + , + ); + }); + mountedRenderers.push(renderer); + return renderer; +} + +afterEach(() => { + for (const renderer of mountedRenderers.splice(0)) { + act(() => renderer.unmount()); + } + currentDetails = null; + currentLoading = false; + currentError = null; +}); + +describe("formatBytesAsMemory", () => { + test("renders gibibytes for round values", () => { + expect(formatBytesAsMemory(2 * 1024 * 1024 * 1024)).toBe("2 GiB"); + }); + + test("renders fractional gibibytes with one decimal", () => { + expect(formatBytesAsMemory(2.5 * 1024 * 1024 * 1024)).toBe("2.5 GiB"); + }); + + test("falls back to mebibytes when below a gibibyte", () => { + expect(formatBytesAsMemory(512 * 1024 * 1024)).toBe("512 MiB"); + }); +}); + +describe("RunSandbox route", () => { + test("renders panels for a fully populated sandbox", () => { + currentDetails = { + provider: "docker", + name: "fabro-run-abc", + id: "abcdef123456", + state: "running", + native_state: "running", + region: null, + image: "ghcr.io/fabro/sandbox:latest", + resources: { + cpu_cores: 2, + memory_bytes: 4 * 1024 * 1024 * 1024, + disk_bytes: null, + }, + labels: { run: "abc" }, + timestamps: { + created_at: "2026-05-09T12:00:00Z", + last_activity_at: null, + }, + }; + const renderer = renderRoute(); + + const panelHeadings = renderer.root + .findAll((node) => node.type === "h3") + .map((node) => node.children.find((child) => typeof child === "string")) + .filter((text): text is string => typeof text === "string"); + expect(panelHeadings).toEqual(["Overview", "Resources", "Labels", "Timestamps"]); + }); + + test("renders without crashing when most fields are null", () => { + currentDetails = { + provider: "local", + name: null, + id: null, + state: "unknown", + native_state: null, + region: null, + image: null, + resources: { + cpu_cores: null, + memory_bytes: null, + disk_bytes: null, + }, + labels: {}, + timestamps: { + created_at: null, + last_activity_at: null, + }, + }; + const renderer = renderRoute(); + + const labelsHeading = renderer.root.findAll( + (node) => + node.type === "h3" && + node.children.find((child) => typeof child === "string") === "Labels", + ); + expect(labelsHeading).toHaveLength(1); + + const noLabelsCopy = renderer.root.findAll( + (node) => + node.type === "div" && + Array.isArray(node.children) && + node.children.includes("No labels"), + ); + expect(noLabelsCopy).toHaveLength(1); + }); + + test("shows the empty state when no sandbox is reported", () => { + currentDetails = null; + const renderer = renderRoute(); + + const titles = renderer.root.findAll( + (node) => + node.type === "p" && + Array.isArray(node.children) && + node.children.includes("No sandbox"), + ); + expect(titles).toHaveLength(1); + }); +}); diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx new file mode 100644 index 000000000..4fe3c7e80 --- /dev/null +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -0,0 +1,214 @@ +import { EmptyState, ErrorState } from "../components/state"; +import { formatAbsoluteTs } from "../lib/format"; +import { useRunSandboxDetails } from "../lib/queries"; +import type { SandboxDetails, SandboxResources, SandboxState } from "@qltysh/fabro-api-client"; + +const EMPTY_VALUE = "—"; + +const STATE_DISPLAY: Record = { + unknown: { label: "Unknown", dot: "bg-fg-muted", text: "text-fg-muted" }, + provisioning: { label: "Provisioning", dot: "bg-amber", text: "text-amber" }, + starting: { label: "Starting", dot: "bg-amber", text: "text-amber" }, + running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, + stopping: { label: "Stopping", dot: "bg-amber", text: "text-amber" }, + stopped: { label: "Stopped", dot: "bg-fg-muted", text: "text-fg-muted" }, + paused: { label: "Paused", dot: "bg-amber", text: "text-amber" }, + deleting: { label: "Deleting", dot: "bg-amber", text: "text-amber" }, + deleted: { label: "Deleted", dot: "bg-coral", text: "text-coral" }, + archived: { label: "Archived", dot: "bg-fg-muted", text: "text-fg-muted" }, + restoring: { label: "Restoring", dot: "bg-amber", text: "text-amber" }, + resizing: { label: "Resizing", dot: "bg-amber", text: "text-amber" }, + error: { label: "Error", dot: "bg-coral", text: "text-coral" }, +}; + +const BYTES_PER_GIB = 1024 * 1024 * 1024; +const BYTES_PER_MIB = 1024 * 1024; + +export function formatBytesAsMemory(bytes: number): string { + if (bytes >= BYTES_PER_GIB) { + const gib = bytes / BYTES_PER_GIB; + return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`; + } + if (bytes >= BYTES_PER_MIB) { + const mib = bytes / BYTES_PER_MIB; + return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`; + } + return `${bytes} B`; +} + +function formatCpuCores(cores: number): string { + return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2); +} + +function nullable(value: string | null | undefined): string { + return value && value.length > 0 ? value : EMPTY_VALUE; +} + +function nullableTimestamp(value: string | null | undefined): string { + return value ? formatAbsoluteTs(value) : EMPTY_VALUE; +} + +function nullableMemory(bytes: number | null | undefined): string { + return bytes != null ? formatBytesAsMemory(bytes) : EMPTY_VALUE; +} + +function nullableCpu(cores: number | null | undefined): string { + return cores != null ? formatCpuCores(cores) : EMPTY_VALUE; +} + +interface RowProps { + label: string; + value: string; + valueClassName?: string; +} + +function Row({ label, value, valueClassName }: RowProps) { + return ( +
+ {label} + + {value} + +
+ ); +} + +interface PanelProps { + title: string; + children: React.ReactNode; +} + +function Panel({ title, children }: PanelProps) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function StatusStrip({ details }: { details: SandboxDetails }) { + const display = STATE_DISPLAY[details.state] ?? STATE_DISPLAY.unknown; + const showNative = + details.native_state && + details.native_state.toLowerCase() !== details.state.toLowerCase(); + return ( +
+ + {details.provider} + + + + {display.label} + + {showNative && ( + + native: {details.native_state} + + )} +
+ ); +} + +function OverviewPanel({ details }: { details: SandboxDetails }) { + return ( + + + + + + + ); +} + +function ResourcesPanel({ resources }: { resources: SandboxResources }) { + return ( + + + + + + ); +} + +function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | undefined }) { + const entries = labels ? Object.entries(labels) : []; + return ( + + {entries.length === 0 ? ( +
No labels
+ ) : ( + entries + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => ) + )} +
+ ); +} + +function TimestampsPanel({ details }: { details: SandboxDetails }) { + return ( + + + + + ); +} + +export default function RunSandbox({ params }: { params: { id: string } }) { + const sandboxQuery = useRunSandboxDetails(params.id); + + if (sandboxQuery.error) { + return ( +
+ +
+ ); + } + + if (sandboxQuery.isLoading && !sandboxQuery.data) { + return
; + } + + if (!sandboxQuery.data) { + return ( +
+ +
+ ); + } + + const details = sandboxQuery.data; + + return ( +
+ + + + + +
+ ); +}