diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 78e0ebfe3..5ebb8d745 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -594,13 +594,14 @@ still frame, the request is a plain block and no transition runs. Setting up hosted sandboxes, and changing the provider or resources in maintenance, is a set of pages inside the Nodes page, one decision each: where sandboxes run (own machines or E2B), then the backend (Docker or microsandbox, compared behind a help tip) or -the E2B account, then the size of each sandbox (three presets), then a review. +the E2B account, then the size of each sandbox (three presets; E2B skips it, since +each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, asking for it only when the console's own address cannot serve. -Advanced settings, one link away, hold the complete form: resources, the Core -address, the Runtime release and the E2B template. A change keeps the saved size +Advanced settings, one link away, hold the complete form: resources (not for +E2B), the Core address, the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current); another backend starts from its standard size and this console's Runtime, and E2B always needs its key again. Rules sit behind help tips. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 2f9ddc4f3..1ac14c97d 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -67,16 +67,20 @@ workbench. and offer Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (sandbox setup as pages — where sandboxes run, the - backend or E2B account, the size of each sandbox, a review, and advanced settings + backend or E2B account, the size of each sandbox (own machines only; E2B takes the + template build's), a review, and advanced settings with the complete form — then the node list with each node's capacity, host figures and allocations, enrollment, renaming, sandbox limits and removal; Add node asks for the node's sandbox limits before it issues the one-time command), System (only the sandbox configuration every project shares: where sandboxes run, each sandbox's size, the - Runtime or E2B template, the Core address and maintenance). + Runtime or E2B template build, microsandbox's idle suspension, the Core address and maintenance). +- A node whose provider is not ready names the reason (Docker unreachable, no Docker + limits, missing Runtime image, no KVM, missing microsandbox components, a host too + small) and its fix in the help tip beside its status, wherever that status shows. - **E2B deployments** have no machines: the Nodes entry becomes Sandbox backend, and Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud - (running, starting, size, template) instead of node capacity, with no node column - or Add node action. + (running, starting, size, template build) instead of node capacity, with no node column + or Add node action; a sandbox's dialog adds its disk use. - **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox metrics and Overview; Docker never suspends and shows none of it. diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index 1918200e7..9ed68eab8 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -77,7 +77,7 @@ export function buildDemo(now = Math.floor(Date.now() / 1000)) { sessions.sort((a, b) => b.created_at - a.created_at); const nodes = [ { id: "node-local", name: "core-01", provider: "docker", online: true, provider_ready: true, diagnostic: "", cpu_count: 16, available_memory_bytes: 38 * 2 ** 30, available_disk_bytes: 410 * 2 ** 30, running: 5, snapshots: 2, last_seen_at: new Date((now - 8) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 5, reserved: 1, retained: 2, cleanup_pending: 0, created_at: new Date((now - 86400 * 30) * 1000).toISOString() }, - { id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: true, diagnostic: "", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, + { id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, diagnostic: "docker_limits_unsupported", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, { id: "node-edge", name: "edge-03", provider: "docker", online: false, provider_ready: false, diagnostic: "", cpu_count: 8, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: new Date((now - 5400) * 1000).toISOString(), max_active: 4, max_retained: 8, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date((now - 86400 * 3) * 1000).toISOString() }, ]; const hosted = sessions.filter((session) => session.environment.type === "openai_hosted"); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 225669eac..55c93362a 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -20,12 +20,14 @@ const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${h const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} }; function configuredDeployment() { - return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture" }; + return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } +/** The E2B template build as Core read it when the selection was saved. */ +const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } function reset(mode = "setup", fresh = false, sandbox = "configured") { @@ -146,7 +148,9 @@ function adminRead(response, path, url) { if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); if (path === "/runtime-observations") { - const data = a.runtimeObservations(); + // Only E2B reports a sandbox's disk. + const e2b = state.deployment?.provider === "e2b"; + const data = a.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); } if (path === "/core-metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); @@ -206,12 +210,20 @@ async function sandboxRoute(request, response, path) { if (path === "/deployment" && request.method === "POST") { const input = await body(request); if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); - if (!input.resources || (input.provider !== "e2b" && !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); - state.deployment = { ...configuredDeployment(), provider: input.provider, core_url: input.core_url, mode: input.provider === "e2b" ? "direct" : "nodes", specification: { resources: input.resources, ...(input.runtime ? { runtime: input.runtime } : {}) } }; + const e2b = input.provider === "e2b"; + if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. + const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; + state.deployment = { + ...configuredDeployment(), provider: input.provider, core_url: input.core_url, mode: e2b ? "direct" : "nodes", + specification: { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }, + ...(e2b ? { e2b: { template: input.e2b?.template ?? "", credential_configured: true, template_build: templateBuild } } : {}), + suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, + }; return send(response, 201, state.deployment); } if (path === "/deployment") { - return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 } }); + return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index b42e0b324..309b0cae8 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -20,6 +20,8 @@ test("shows the deployment's health on Overview and each monitor page", async ({ await expect(page.getByLabel("Agent run summary")).toContainText("Requests"); await page.getByRole("button", { name: "Sandbox metrics" }).click(); await expect(page.getByRole("table").first()).toContainText("core-01"); + // A degraded node names why its provider is not ready. + await expect(page.getByRole("button", { name: "Docker limits unsupported" })).toBeVisible(); }); test("opens a Session's conversation from the Session log, read-only", async ({ page, request }) => { @@ -50,6 +52,8 @@ test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page test("shows E2B's cloud instead of machines", async ({ page, request }) => { await openConsole(page, request, "sandbox-metrics", { sandbox: "e2b" }); await expect(page.getByRole("heading", { name: "E2B cloud" })).toBeVisible(); + // Each sandbox takes the template build's size, disk included. + await expect(page.getByText("10 GiB disk")).toBeVisible(); await expect(page.getByRole("button", { name: "Add node" })).toHaveCount(0); await expect(page.getByRole("columnheader", { name: "Node", exact: true })).toHaveCount(0); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 2035c67d0..fc8aa9722 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -73,7 +73,7 @@ test("keeps the saved size and Runtime for the same backend, and starts another expect(submitted?.resources).toEqual({ cpus: 2, memory_mib: 4096, root_disk_mib: 8192, environment_disk_mib: 8192 }); expect(submitted?.runtime).toMatchObject({ source_commit: "c0ffee".padEnd(40, "0") }); - // E2B needs its key again and takes no Runtime or disks. + // E2B needs its key again and takes its size from the template build: no size, Runtime or disks. await back.click(); await back.click(); await back.click(); @@ -81,10 +81,9 @@ test("keeps the saved size and Runtime for the same backend, and starts another await page.getByLabel("E2B API key").fill("fixture-private-key"); await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); await page.getByRole("button", { name: "Next" }).click(); - await page.getByRole("button", { name: /^Standard/ }).click(); await save.click(); await expect.poll(() => submitted?.provider).toBe("e2b"); - expect(submitted?.resources).toEqual({ cpus: 2, memory_mib: 2048 }); + expect(submitted?.resources).toBeUndefined(); expect(submitted?.runtime).toBeUndefined(); }); diff --git a/apps/web/src/features/fleet/DiagnosticTip.tsx b/apps/web/src/features/fleet/DiagnosticTip.tsx new file mode 100644 index 000000000..6151e8386 --- /dev/null +++ b/apps/web/src/features/fleet/DiagnosticTip.tsx @@ -0,0 +1,16 @@ +import { useTranslation } from "react-i18next"; + +import { HelpTip } from "../../components/console-ui"; +import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; + +/** The specific reason behind a node's status, in the help tip beside it: what is wrong, then how to fix it. */ +export function DiagnosticTip({ code }: { code: string }) { + const { i18n } = useTranslation(); + const message = sandboxDiagnosticMessage(code, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); + if (!message) return null; + return ( + + {message.label} {message.advice} + + ); +} diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 33123ddc6..abb9eb625 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -25,12 +25,15 @@ import { ListToolbar, listSummary, NameCell, SearchField } from "../../component import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatBytes, formatClock, formatCompact, formatCores, formatDateTime, formatDuration, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; import { projectClient, ProjectName, useProjects } from "../../lib/projects"; +import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { loadRuntimeDurableSnapshot, RUNTIME_DURABLE_RANGES, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { RUNTIME_SNAPSHOT_REFRESH_MS } from "../dashboard/runtime-snapshot"; +import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { capacitySummary, nodeHealth, suspendedSandboxes, type NodeHealth } from "../fleet/fleet-model"; import { nodeDetailQuery } from "../fleet/fleet-queries"; import { fleetSnapshot, useSandboxFleet, type FleetState } from "../fleet/use-sandbox-fleet"; +import { sandboxSize, templateBuildStatus } from "../sandbox/deployment-specification"; import { formatShare, NodeHostCharts } from "./NodeHostCharts"; import { hostedRuntimeRows, @@ -148,27 +151,24 @@ export function SandboxMetricsPage() { - {fleet.nodes.map((node) => { - const health = nodeHealth(node); - return ( - setOpenNode(node.id)}> - setOpenNode(node.id)} openLabel={t("sandbox.nodeDialog.openLabel", { name: node.name || node.id })} /> - - - - - {node.active} / {node.max_active} - - - {suspends ? {suspendedSandboxes(node)} : null} - {node.online ? node.cpu_count ?? MISSING : MISSING} - {node.online ? formatBytes(node.available_memory_bytes) : MISSING} - {node.online ? formatBytes(node.available_disk_bytes) : MISSING} - 0 ? "numeric numeric-warning" : "numeric"}>{node.cleanup_pending} - {formatRelative(node.last_seen_at ? Date.parse(node.last_seen_at) / 1000 : null, now, locale)} - - ); - })} + {fleet.nodes.map((node) => ( + setOpenNode(node.id)}> + setOpenNode(node.id)} openLabel={t("sandbox.nodeDialog.openLabel", { name: node.name || node.id })} /> + + + + + {node.active} / {node.max_active} + + + {suspends ? {suspendedSandboxes(node)} : null} + {node.online ? node.cpu_count ?? MISSING : MISSING} + {node.online ? formatBytes(node.available_memory_bytes) : MISSING} + {node.online ? formatBytes(node.available_disk_bytes) : MISSING} + 0 ? "numeric numeric-warning" : "numeric"}>{node.cleanup_pending} + {formatRelative(node.last_seen_at ? Date.parse(node.last_seen_at) / 1000 : null, now, locale)} + + ))} @@ -198,12 +198,31 @@ export function SandboxMetricsPage() { ); } +/** A node's health; a degraded node names the reason its provider is not ready behind the help tip. */ +function NodeHealthStatus({ node }: { node: SandboxNode }) { + const { t } = useTranslation("metrics"); + const health = nodeHealth(node); + const diagnostic = health === "degraded" ? nodeProviderDiagnostic(node) : ""; + return ( + + + {/* The tip opens on its own, not the row's node dialog. */} + {diagnostic ? event.stopPropagation()}> : null} + + ); +} + /** An E2B deployment in place of the node table: what runs in its cloud now, and with what. */ function CloudSection({ deployment }: { deployment: SandboxDeployment }) { const { t, i18n } = useTranslation("metrics"); const locale = i18n.resolvedLanguage; const { navigate } = useConsoleNavigation(); - const resources = deployment.specification?.resources; + // An E2B selection may adopt its template build's size instead of saving one. + const resources = sandboxSize(deployment); + const build = deployment.e2b?.template_build; + const disk = build?.resources.root_disk_mib ?? null; + const status = templateBuildStatus(build); + const template = deployment.e2b?.template; return (
- - {deployment.e2b.template} : MISSING} /> + + {t("sandbox.cores", { value: formatInteger(resources.cpus, locale) })} · {formatBytes(resources.memory_mib * 2 ** 20)} + {disk !== null ? {t("sandbox.cloud.disk", { disk: formatBytes(disk * 2 ** 20) })} : null} + : MISSING} + /> + + {t(`sandbox.cloud.build.${status}`)} + {template ? {template} : null} + } + />
); @@ -449,7 +482,7 @@ function NodeDialog({ node, rows, load, range, onClose }: { {shown ? (
-
{t("sandbox.status")}
+
{t("sandbox.status")}
{t("sandbox.slots")}
{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}
{shown.provider === "microsandbox" ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} {shown.provider === "microsandbox" ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null} @@ -504,6 +537,8 @@ function RuntimeDialog({ row, showNode, onClose }: { row: HostedRuntimeRow | nul {showNode ?
{t("sandbox.node")}
{shown.node ? shown.node.name || shown.node.id : MISSING}
: null}
{t("sandbox.status")}
{t("sandbox.uptime")}
{formatDuration(shown.uptimeSeconds)}
+ {/* Only E2B reports a sandbox's disk. */} + {observation.disk ?
{t("sandbox.disk")}
{formatBytes(observation.disk.usage_bytes)} / {formatBytes(observation.disk.limit_bytes)}
: null}
{shown.session ? diff --git a/apps/web/src/features/overview/FleetTopology.tsx b/apps/web/src/features/overview/FleetTopology.tsx index ab578c24e..4f6545b6e 100644 --- a/apps/web/src/features/overview/FleetTopology.tsx +++ b/apps/web/src/features/overview/FleetTopology.tsx @@ -7,6 +7,8 @@ import { useTranslation } from "react-i18next"; import { ConsolePopover } from "../../components/console-popover"; import { StatusDot, type Tone } from "../../components/console-ui"; import { formatBytes, formatDateTime, formatDuration, formatInteger, formatRelative, MISSING } from "../../lib/format"; +import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; +import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes, type NodeHealth } from "../fleet/fleet-model"; import { coreMetricsQuery } from "../metrics/metrics-queries"; import { seconds } from "../sandbox/NodeList"; @@ -175,16 +177,22 @@ function Fact({ label, children }: { label: string; children: ReactNode }) { return
{label}
{children}
; } -/** A node at a glance: reachability, sandbox slots and what the node has left. */ +/** A node at a glance: reachability (with the reason a degraded provider is not ready), sandbox slots and what the node has left. */ function NodeGlance({ node, health }: { node: SandboxNode; health: NodeHealth }) { const { t, i18n } = useTranslation("overview"); const locale = i18n.resolvedLanguage; const now = Math.floor(Date.now() / 1000); const seen = seconds(node.last_seen_at); const count = (value: number) => formatInteger(value, locale); + const diagnostic = health === "degraded" ? nodeProviderDiagnostic(node) : ""; return ( - + + + + {diagnostic ? : null} + + {seen === null ? t("fleet.facts.never") : formatRelative(seen, now, locale)} {count(node.active)}/ {count(node.max_active)} {node.provider === "microsandbox" ? {count(suspendedSandboxes(node))} : null} diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 5c906f5c1..b206b3827 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -5,15 +5,14 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, Kpi, KpiStrip, Section } from "../../components/console-ui"; import { CopyableId } from "../../components/list-ui"; import { formatDateTime, formatInteger, formatRelative, MISSING } from "../../lib/format"; -import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; +import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxStateLabel } from "../../lib/sandbox-labels"; +import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeState, NodeStatus, seconds } from "./NodeList"; -/** Why a node is not serving: disconnected, provider down, or a reported diagnostic. */ +/** Why a node is not serving: disconnected, or the reason its provider is not ready. */ function nodeDiagnostic(node: SandboxNode): string { - if (!node.online) return "node_unavailable"; - if (!node.provider_ready) return "provider_unavailable"; - return node.diagnostic; + return node.online ? nodeProviderDiagnostic(node) : "node_unavailable"; } function Diagnostic({ value }: { value: string }) { @@ -42,7 +41,7 @@ export function NodeDetail({ node, allocations, stale }: { node: SandboxNode; al
{t("Status")}
- {diagnostic ? {sandboxDiagnosticMessage(diagnostic, shortLocale)?.advice} : null} + {diagnostic ? : null}
diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 4cb874b11..c7017656d 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -5,6 +5,8 @@ import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { NameCell, RowActions } from "../../components/list-ui"; import { formatDateTime, formatRelative } from "../../lib/format"; import type { MessageKey } from "../../lib/locale-strings"; +import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; +import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes } from "../fleet/fleet-model"; export type NodeState = "unconfirmed" | "offline" | "degraded" | "attention" | "available"; @@ -68,12 +70,15 @@ export function NodeList({ nodes, allocations, stale, disabled, suspends = false {nodes.map((node) => { const name = node.name || node.id; + const state = nodeState(node, allocations, stale); + // A degraded node names the reason its provider is not ready. + const diagnostic = state === "degraded" ? nodeProviderDiagnostic(node) : ""; return ( onOpen(node)} openLabel={t("Open {{name}}", { name })} idLabel={t("Node ID")} /> - + {diagnostic ? : null} {suspends ? {node.active} / {node.max_active} : null} {suspends ? {suspendedSandboxes(node)} : null} diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index ab457ab5f..218b6db26 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -2,10 +2,15 @@ import { useState } from "react"; import type { InitializeSandboxDeployment, SandboxDeployment } from "@agents-core-web/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; -import { formatBytes, MISSING } from "../../lib/format"; +import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; +import type { MessageKey } from "../../lib/locale-strings"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; +import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; +const MIB = 2 ** 20; +const buildStatusLabel: Record, MessageKey> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; + export function SandboxDeploymentSettings({ deployment, disabled, fresh, onMaintenance, onUpdate, onRefresh }: { deployment: SandboxDeployment; disabled: boolean; @@ -18,6 +23,11 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onMaint const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; const [changing, setChanging] = useState(false); const spec = deployment.specification; + // An E2B selection may adopt its template build's size instead of saving one. + const size = sandboxSize(deployment); + const build = deployment.e2b?.template_build; + const buildSize = templateBuildSize(deployment); + const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); const clean = fresh && deployment.resources?.allocations === 0 && deployment.resources?.pending === 0; return
@@ -29,13 +39,25 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onMaint
{t("Provider")}
{sandboxProviderLabel(deployment.provider, locale)}
-
{t("Each sandbox")}
{spec ? `${t("{{cpus}} CPU · {{memory}}", { cpus: spec.resources.cpus, memory: formatBytes(spec.resources.memory_mib * 2 ** 20) })}${spec.resources.root_disk_mib ? ` · ${t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(spec.resources.root_disk_mib * 2 ** 20), data: formatBytes((spec.resources.environment_disk_mib ?? 0) * 2 ** 20) })}` : ""}` : MISSING}
+
{t("Each sandbox")}
{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING}
{spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} + {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null}
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
{deployment.provider === "e2b" ?
-
{t("Immutable Runtime template")}
{deployment.e2b?.template || t("Unknown state")}
{t("E2B credential")}
{t(deployment.e2b?.credential_configured ? "Configured" : "Not configured")}
+
+
{t("Immutable Runtime template")}
{deployment.e2b?.template || t("Unknown state")}
+
+
{t("Template build")}
+
{[ + t(buildStatusLabel[templateBuildStatus(build)]), + ...(buildSize ? [sizeLabel(buildSize)] : []), + ...(build?.resources.root_disk_mib != null ? [t("{{disk}} disk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })] : []), + ].join(" · ")}
+
+
{t("E2B credential")}
{t(deployment.e2b?.credential_configured ? "Configured" : "Not configured")}
+
: null} {!deployment.maintenance ? : <>
{t("Open Sessions")}
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 58da18da2..1322e994a 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -60,8 +60,9 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese /** * Hosted sandbox setup as pages, one decision each: where sandboxes run, - * which backend (own machines) or the E2B account, how big each sandbox is, - * then a review. Advanced settings hold the complete form. The Runtime + * which backend (own machines) or the E2B account, how big each sandbox is + * (own machines only: E2B sandboxes take the template build's size), then a + * review. Advanced settings hold the complete form. The Runtime * release comes from this console's distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its @@ -102,10 +103,12 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin const runtimeReady = !needsRuntime || isRuntimeRelease(release); // Core keeps no key across a change: E2B always needs one. const e2bReady = provider !== "e2b" || (apiKey.trim().length > 0 && validTemplate(template.trim())); - const sizeReady = provider !== null && validSandboxResources(provider, resources); + // Core sizes E2B sandboxes from the template build, so E2B sends no resources. + const sized = provider !== null && provider !== "e2b"; + const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources)); const ready = provider !== null && origin !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; - const order: Step[] = where === "direct" ? ["where", "e2b", "size", "review"] : ["where", "backend", "size", "review"]; + const order: Step[] = where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); const back = () => setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); @@ -128,7 +131,7 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin await onSubmit({ provider, core_url: origin!, - resources, + ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}), }); @@ -168,11 +171,11 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin setApiKey(event.target.value)} /> - + setTemplate(event.target.value)} placeholder="parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b" autoComplete="off" spellCheck={false} aria-invalid={Boolean(template && !validTemplate(template.trim()))} />
-