mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
85ca7d5310
commit
f73a1bb8f7
8 changed files with 424 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
144
apps/fabro-web/app/routes/run-sandbox.test.tsx
Normal file
144
apps/fabro-web/app/routes/run-sandbox.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
214
apps/fabro-web/app/routes/run-sandbox.tsx
Normal file
214
apps/fabro-web/app/routes/run-sandbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue