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.
This commit is contained in:
Bryan Helmkamp 2026-05-09 21:48:31 -04:00
parent 85ca7d5310
commit f73a1bb8f7
No known key found for this signature in database
8 changed files with 424 additions and 0 deletions

View file

@ -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<SandboxDetails | null>(
id ? queryKeys.runs.sandbox(id) : null,
() => apiNullableData(() => humanInTheLoopApi.retrieveRunSandbox(id!)),
);
}
export function useRunQuestions(id: string | undefined, enabled: boolean) {
return useSWR<ApiQuestion[]>(
id && enabled ? queryKeys.runs.questions(id, 25, 0) : null,

View file

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

View file

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

View file

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

View file

@ -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[] = [];

View file

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

View file

@ -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(
<MemoryRouter initialEntries={["/runs/run_1/sandbox"]}>
<Routes>
<Route path="/runs/:id/sandbox" element={<RunSandbox params={{ id: "run_1" }} />} />
</Routes>
</MemoryRouter>,
);
});
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);
});
});

View file

@ -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<SandboxState, { label: string; dot: string; text: string }> = {
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 (
<div className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm">
<span className="text-fg-3">{label}</span>
<span
className={`text-right font-mono text-xs text-fg-2 ${
valueClassName ?? ""
} ${value === EMPTY_VALUE ? "text-fg-muted" : ""}`}
>
{value}
</span>
</div>
);
}
interface PanelProps {
title: string;
children: React.ReactNode;
}
function Panel({ title, children }: PanelProps) {
return (
<div className="overflow-hidden rounded-md border border-line">
<h3 className="border-b border-line bg-panel/60 px-4 py-2.5 text-xs font-medium text-fg-3">
{title}
</h3>
<div className="divide-y divide-line">{children}</div>
</div>
);
}
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 (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-md border border-line bg-panel/60 px-4 py-3 text-sm">
<span className="font-mono text-xs text-fg-muted uppercase tracking-wide">
{details.provider}
</span>
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${display.dot}`} />
<span className={`font-medium ${display.text}`}>{display.label}</span>
</span>
{showNative && (
<span className="font-mono text-xs text-fg-muted">
native: {details.native_state}
</span>
)}
</div>
);
}
function OverviewPanel({ details }: { details: SandboxDetails }) {
return (
<Panel title="Overview">
<Row label="Name" value={nullable(details.name)} />
<Row label="ID" value={nullable(details.id)} />
<Row
label="Region"
value={details.region ? details.region : details.provider === "docker" ? "local" : EMPTY_VALUE}
/>
<Row label="Image" value={nullable(details.image)} />
</Panel>
);
}
function ResourcesPanel({ resources }: { resources: SandboxResources }) {
return (
<Panel title="Resources">
<Row label="CPU" value={nullableCpu(resources.cpu_cores)} />
<Row label="Memory" value={nullableMemory(resources.memory_bytes)} />
<Row label="Disk" value={nullableMemory(resources.disk_bytes)} />
</Panel>
);
}
function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | undefined }) {
const entries = labels ? Object.entries(labels) : [];
return (
<Panel title="Labels">
{entries.length === 0 ? (
<div className="px-4 py-3 text-sm text-fg-muted">No labels</div>
) : (
entries
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => <Row key={key} label={key} value={value} />)
)}
</Panel>
);
}
function TimestampsPanel({ details }: { details: SandboxDetails }) {
return (
<Panel title="Timestamps">
<Row label="Created" value={nullableTimestamp(details.timestamps.created_at)} />
<Row
label="Last activity"
value={nullableTimestamp(details.timestamps.last_activity_at)}
/>
</Panel>
);
}
export default function RunSandbox({ params }: { params: { id: string } }) {
const sandboxQuery = useRunSandboxDetails(params.id);
if (sandboxQuery.error) {
return (
<div className="py-12">
<ErrorState
title="Sandbox unavailable"
description={
sandboxQuery.error instanceof Error
? sandboxQuery.error.message
: "Could not load sandbox details."
}
/>
</div>
);
}
if (sandboxQuery.isLoading && !sandboxQuery.data) {
return <div className="py-12" />;
}
if (!sandboxQuery.data) {
return (
<div className="py-12">
<EmptyState
title="No sandbox"
description="This run has no sandbox or its provider does not expose details."
/>
</div>
);
}
const details = sandboxQuery.data;
return (
<div className="mx-auto max-w-3xl space-y-4">
<StatusStrip details={details} />
<OverviewPanel details={details} />
<ResourcesPanel resources={details.resources} />
<LabelsPanel labels={details.labels} />
<TimestampsPanel details={details} />
</div>
);
}