feat(web): show sandbox status dot on the run overview panel

The Sandbox cell now renders a colored status dot before the resource
summary, with a tooltip explaining the state on hover. The dot reuses
the data already fetched for CPU/memory, so no new API call. Falls back
to the state label when resources are unavailable.

Lifts the per-state display map into a shared lib/sandbox-state module
so the overview panel and the dedicated sandbox page stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-20 21:31:45 -04:00
parent 81d1715e22
commit f2efaf70fb
No known key found for this signature in database
4 changed files with 152 additions and 26 deletions

View file

@ -19,6 +19,7 @@ function render(props: Partial<RunSummaryPanelViewProps> = {}) {
const full: RunSummaryPanelViewProps = {
run: null,
runLoading: false,
sandboxState: null,
sandboxResources: null,
sandboxLoading: false,
artifactsCount: null,
@ -118,11 +119,20 @@ describe("RunSummaryPanelView", () => {
test("renders sandbox CPU and memory", () => {
const tree = render({
run: makeRun(),
sandboxState: "running",
sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any,
});
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB");
});
test("renders sandbox status dot and falls back to state label without resources", () => {
const tree = render({ run: makeRun(), sandboxState: "stopped" });
const cell = cellAfterLabel(tree, "Sandbox");
expect(instanceText(cell)).toBe("Stopped");
const dot = cell.find((node) => node.props["aria-label"] === "Sandbox Stopped");
expect(dot.props.className).toContain("bg-fg-muted");
});
test("renders artifacts count when positive", () => {
const tree = render({ run: makeRun(), artifactsCount: 3 });
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe("3");

View file

@ -7,7 +7,12 @@ import {
QuestionMarkCircleIcon,
ServerIcon,
} from "@heroicons/react/20/solid";
import type { Principal, Run, SandboxResources } from "@qltysh/fabro-api-client";
import type {
Principal,
Run,
SandboxResources,
SandboxState,
} from "@qltysh/fabro-api-client";
import {
formatBytesAsMemory,
@ -15,6 +20,8 @@ import {
formatUsdMicros,
} from "../lib/format";
import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries";
import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state";
import { Tooltip } from "./ui";
const LABEL_CLASS =
"text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted";
@ -104,15 +111,46 @@ function createdByDisplay(actor: Principal): CreatedByDisplay {
export interface RunSummaryPanelViewProps {
run: Run | null;
runLoading: boolean;
sandboxState: SandboxState | null;
sandboxResources: SandboxResources | null;
sandboxLoading: boolean;
artifactsCount: number | null;
artifactsLoading: boolean;
}
function SandboxValue({
state,
resources,
}: {
state: SandboxState;
resources: SandboxResources | null;
}) {
const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown;
const cpu = resources?.cpu_cores;
const memory = resources?.memory_bytes;
const valueText =
cpu != null && memory != null
? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}`
: display.label;
return (
<div className="flex items-center gap-2">
<Tooltip label={display.description}>
<span
role="img"
aria-label={`Sandbox ${display.label}`}
className={`size-2 rounded-full ${display.dot}`}
/>
</Tooltip>
<span className={VALUE_CLASS}>{valueText}</span>
</div>
);
}
export function RunSummaryPanelView({
run,
runLoading,
sandboxState,
sandboxResources,
sandboxLoading,
artifactsCount,
@ -159,13 +197,8 @@ export function RunSummaryPanelView({
<Cell label="Sandbox">
{sandboxLoading ? (
<Skeleton widthClass="w-24" />
) : sandboxResources &&
sandboxResources.cpu_cores != null &&
sandboxResources.memory_bytes != null ? (
<span className={VALUE_CLASS}>
{formatCpuCores(sandboxResources.cpu_cores)} CPU ·{" "}
{formatBytesAsMemory(sandboxResources.memory_bytes)}
</span>
) : sandboxState ? (
<SandboxValue state={sandboxState} resources={sandboxResources} />
) : (
<EmDash />
)}
@ -204,6 +237,7 @@ export function RunSummaryPanel({ runId }: { runId: string }) {
<RunSummaryPanelView
run={runQuery.data ?? null}
runLoading={runQuery.isLoading && !runQuery.data}
sandboxState={sandboxQuery.data?.state ?? null}
sandboxResources={sandboxQuery.data?.resources ?? null}
sandboxLoading={sandboxQuery.isLoading && !sandboxQuery.data}
artifactsCount={artifactsQuery.data?.data.length ?? null}

View file

@ -0,0 +1,98 @@
import type { SandboxState } from "@qltysh/fabro-api-client";
export interface SandboxStateDisplay {
/** Short human-readable label, e.g. "Running". */
label: string;
/** One-sentence explanation shown on hover. */
description: string;
/** Tailwind background class for the status dot. */
dot: string;
/** Tailwind text color class matching the dot. */
text: string;
}
/**
* Display metadata for every normalized sandbox lifecycle state. Shared by the
* run overview summary panel and the dedicated sandbox page so the dot color,
* label, and hover copy stay consistent.
*/
export const SANDBOX_STATE_DISPLAY: Record<SandboxState, SandboxStateDisplay> = {
unknown: {
label: "Unknown",
description: "The sandbox state could not be determined.",
dot: "bg-fg-muted",
text: "text-fg-muted",
},
provisioning: {
label: "Provisioning",
description: "The sandbox is being provisioned.",
dot: "bg-amber",
text: "text-amber",
},
starting: {
label: "Starting",
description: "The sandbox is starting up.",
dot: "bg-amber",
text: "text-amber",
},
running: {
label: "Running",
description: "The sandbox is running.",
dot: "bg-teal-500",
text: "text-teal-500",
},
stopping: {
label: "Stopping",
description: "The sandbox is shutting down.",
dot: "bg-amber",
text: "text-amber",
},
stopped: {
label: "Stopped",
description: "The sandbox is stopped.",
dot: "bg-fg-muted",
text: "text-fg-muted",
},
paused: {
label: "Paused",
description: "The sandbox is paused.",
dot: "bg-amber",
text: "text-amber",
},
deleting: {
label: "Deleting",
description: "The sandbox is being deleted.",
dot: "bg-amber",
text: "text-amber",
},
deleted: {
label: "Deleted",
description: "The sandbox has been deleted.",
dot: "bg-coral",
text: "text-coral",
},
archived: {
label: "Archived",
description: "The sandbox has been archived.",
dot: "bg-fg-muted",
text: "text-fg-muted",
},
restoring: {
label: "Restoring",
description: "The sandbox is being restored.",
dot: "bg-amber",
text: "text-amber",
},
resizing: {
label: "Resizing",
description: "The sandbox resources are being resized.",
dot: "bg-amber",
text: "text-amber",
},
error: {
label: "Error",
description: "The sandbox encountered an error.",
dot: "bg-coral",
text: "text-coral",
},
};

View file

@ -10,11 +10,11 @@ import {
formatCpuCores,
} from "../lib/format";
import { useRunSandboxDetails } from "../lib/queries";
import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state";
import type {
SandboxDetails,
SandboxNetwork,
SandboxResources,
SandboxState,
} from "@qltysh/fabro-api-client";
import FilesystemPanel from "./run-sandbox/filesystem-panel";
import ServicesPanel from "./run-sandbox/services-panel";
@ -40,22 +40,6 @@ function vncTabAvailable(provider: string | null | undefined): boolean {
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" },
};
function nullable(value: string | null | undefined): string {
return value && value.length > 0 ? value : EMPTY_VALUE;
}
@ -149,7 +133,7 @@ function Panel({ title, children }: PanelProps) {
}
function StatusStrip({ details }: { details: SandboxDetails }) {
const display = STATE_DISPLAY[details.state] ?? STATE_DISPLAY.unknown;
const display = SANDBOX_STATE_DISPLAY[details.state] ?? SANDBOX_STATE_DISPLAY.unknown;
const provider = details.sandbox.provider;
const showNative =
details.native_state &&