From f2efaf70fbda6a03ad4ddc7bd397f78012254587 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 20 May 2026 21:31:45 -0400 Subject: [PATCH] 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) --- .../app/components/run-summary-panel.test.tsx | 10 ++ .../app/components/run-summary-panel.tsx | 50 ++++++++-- apps/fabro-web/app/lib/sandbox-state.ts | 98 +++++++++++++++++++ apps/fabro-web/app/routes/run-sandbox.tsx | 20 +--- 4 files changed, 152 insertions(+), 26 deletions(-) create mode 100644 apps/fabro-web/app/lib/sandbox-state.ts diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx index 822c10717..05cabf2d3 100644 --- a/apps/fabro-web/app/components/run-summary-panel.test.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -19,6 +19,7 @@ function render(props: Partial = {}) { 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"); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx index 0837f44da..c5004105f 100644 --- a/apps/fabro-web/app/components/run-summary-panel.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -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 ( +
+ + + + {valueText} +
+ ); +} + export function RunSummaryPanelView({ run, runLoading, + sandboxState, sandboxResources, sandboxLoading, artifactsCount, @@ -159,13 +197,8 @@ export function RunSummaryPanelView({ {sandboxLoading ? ( - ) : sandboxResources && - sandboxResources.cpu_cores != null && - sandboxResources.memory_bytes != null ? ( - - {formatCpuCores(sandboxResources.cpu_cores)} CPU ·{" "} - {formatBytesAsMemory(sandboxResources.memory_bytes)} - + ) : sandboxState ? ( + ) : ( )} @@ -204,6 +237,7 @@ export function RunSummaryPanel({ runId }: { runId: string }) { = { + 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", + }, +}; diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index 3a8402e12..b25c53704 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -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 = { - 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 &&