diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md
index 1ac14c97d..36186fb75 100644
--- a/apps/web/PRODUCT.md
+++ b/apps/web/PRODUCT.md
@@ -83,7 +83,8 @@ workbench.
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.
+ metrics and Overview; a node's allocations show how long each has been suspended and
+ about when Core reclaims it. Docker never suspends and shows none of it.
- **First run**: after the administrator account is created and while no project
exists, full-screen steps outside the shell create the first project (default name
`Default`) and its first key, show the plaintext once with an example request,
diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs
index 9ed68eab8..e031b2c17 100644
--- a/apps/web/e2e/data/routes.mjs
+++ b/apps/web/e2e/data/routes.mjs
@@ -81,10 +81,16 @@ export function buildDemo(now = Math.floor(Date.now() / 1000)) {
{ 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");
- const allocations = hosted.map((session, index) => ({
- id: uuid(), node_id: index % 2 ? "node-gpu" : "node-local", tenant_id: "project", session_id: session.id, environment_id: session.environment.id,
- state: "active", compute_phase: index % 3 === 0 ? "suspended" : "running", diagnostic: "", initialization: "ready", created_at: new Date(session.created_at * 1000).toISOString(),
- }));
+ const allocations = hosted.map((session, index) => {
+ const suspended = index % 3 === 0;
+ // Running since creation; suspended two hours ago, or at creation when that is later.
+ const changed = suspended ? Math.max(session.created_at, now - 7_200) : session.created_at;
+ return {
+ id: uuid(), node_id: index % 2 ? "node-gpu" : "node-local", tenant_id: "project", session_id: session.id, environment_id: session.environment.id,
+ state: "active", compute_phase: suspended ? "suspended" : "running", compute_phase_changed_at: new Date(changed * 1000).toISOString(),
+ diagnostic: "", initialization: "ready", created_at: new Date(session.created_at * 1000).toISOString(),
+ };
+ });
const observations = sessions.map((session) => {
const base = { id: session.id, object: "agent.runtime_observation", session_id: session.id, resolved_at: now };
if (session.environment.type === "none") return { ...base, environment_id: null, mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null };
diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx
index b206b3827..268b13b5b 100644
--- a/apps/web/src/features/sandbox/NodeDetail.tsx
+++ b/apps/web/src/features/sandbox/NodeDetail.tsx
@@ -1,13 +1,14 @@
-import type { SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client";
+import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client";
import { suspendedSandboxes } from "../fleet/fleet-model";
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 { formatDateTime, formatInteger, formatRelative, formatSpan, MISSING } from "../../lib/format";
import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic";
import { sandboxStateLabel } from "../../lib/sandbox-labels";
import { DiagnosticTip } from "../fleet/DiagnosticTip";
+import { phaseTiming } from "./allocation-phase";
import { nodeState, NodeStatus, seconds } from "./NodeList";
/** Why a node is not serving: disconnected, or the reason its provider is not ready. */
@@ -22,7 +23,26 @@ function Diagnostic({ value }: { value: string }) {
return {message.label}{message.advice};
}
-export function NodeDetail({ node, allocations, stale }: { node: SandboxNode; allocations: readonly SandboxAllocation[]; stale: boolean }) {
+/** How long the allocation has been in its compute phase and, while suspended, about when Core reclaims it. */
+function PhaseTime({ allocation, retentionSeconds, now }: { allocation: SandboxAllocation; retentionSeconds: number; now: number }) {
+ const { t, i18n } = useTranslation("sandbox");
+ const locale = i18n.resolvedLanguage;
+ const timing = phaseTiming(allocation.compute_phase, allocation.compute_phase_changed_at, retentionSeconds, now);
+ if (!timing) return
{MISSING} | ;
+ const elapsed = formatSpan(timing.elapsed, locale);
+ const text = timing.reclaimIn === null ? t("Since {{time}}", { time: formatRelative(timing.since, now, locale) })
+ : timing.reclaimIn > 0 ? t("Suspended for {{elapsed}} · reclaimed in about {{remaining}}", { elapsed, remaining: formatSpan(timing.reclaimIn, locale) })
+ : t("Suspended for {{elapsed}} · reclaim due", { elapsed });
+ return {text} | ;
+}
+
+export function NodeDetail({ node, allocations, stale, suspension }: {
+ node: SandboxNode;
+ allocations: readonly SandboxAllocation[];
+ stale: boolean;
+ /** The deployment's idle suspension policy; only microsandbox has one. */
+ suspension: SandboxDeployment["suspension"];
+}) {
const { t, i18n } = useTranslation("sandbox");
const locale = i18n.resolvedLanguage;
const shortLocale = locale?.startsWith("zh") ? "zh" : "en";
@@ -76,6 +96,8 @@ export function NodeDetail({ node, allocations, stale }: { node: SandboxNode; al
{t("Session")} |
{t("Recorded state")} |
{t("Recorded compute")} |
+ {/* Only microsandbox changes compute phase; under Docker it is always disabled. */}
+ {suspension ? {t("In this state")}{t("How long the sandbox has been in its compute state. For a suspended one, the reclaim time is estimated from when it was suspended and the deployment's retention; Core reclaims it around then. Older allocations show a dash until their state next changes.")} | : null}
{t("Issue")} |
{t("Created")} |
@@ -86,6 +108,7 @@ export function NodeDetail({ node, allocations, stale }: { node: SandboxNode; al
|
{sandboxStateLabel(allocation.state, shortLocale)} |
{allocation.compute_phase ? sandboxStateLabel(allocation.compute_phase, shortLocale) : MISSING} |
+ {suspension ? : null}
{allocation.diagnostic ? : MISSING} |
{formatDateTime(seconds(allocation.created_at), locale)} |
diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx
index c1fc4783b..eef5baa3b 100644
--- a/apps/web/src/features/sandbox/SandboxManagerView.tsx
+++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx
@@ -205,7 +205,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
/>
{status}
- {selected ? : snapshot && !loading ? (
+ {selected ? : snapshot && !loading ? (
{t("Back")}} />
) : null}
diff --git a/apps/web/src/features/sandbox/allocation-phase.test.ts b/apps/web/src/features/sandbox/allocation-phase.test.ts
new file mode 100644
index 000000000..b2e4c939d
--- /dev/null
+++ b/apps/web/src/features/sandbox/allocation-phase.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { formatSpan } from "../../lib/format";
+import { phaseTiming } from "./allocation-phase";
+
+const now = Date.parse("2026-09-25T12:00:00Z") / 1000;
+const ago = (seconds: number) => new Date((now - seconds) * 1000).toISOString();
+
+describe("allocation phase timing", () => {
+ it("estimates when Core reclaims a suspended sandbox from the deployment's retention", () => {
+ const timing = phaseTiming("suspended", ago(7_300), 86_400, now);
+ expect(timing).toEqual({ since: now - 7_300, elapsed: 7_300, reclaimIn: 79_100 });
+ expect([formatSpan(timing!.elapsed, "en"), formatSpan(timing!.reclaimIn!, "en")]).toEqual(["2 hours", "22 hours"]);
+ expect([formatSpan(timing!.elapsed, "zh-CN"), formatSpan(timing!.reclaimIn!, "zh-CN")]).toEqual(["2 小时", "22 小时"]);
+ expect(phaseTiming("running", ago(300), 86_400, now)).toEqual({ since: now - 300, elapsed: 300, reclaimIn: null });
+ });
+
+ it("reports a reclaim past its estimate as due", () => {
+ expect(phaseTiming("suspended", ago(90_000), 86_400, now)?.reclaimIn).toBe(0);
+ });
+
+ it("knows nothing for allocations from before Core recorded the time", () => {
+ expect(phaseTiming("suspended", null, 86_400, now)).toBeNull();
+ });
+});
diff --git a/apps/web/src/features/sandbox/allocation-phase.ts b/apps/web/src/features/sandbox/allocation-phase.ts
new file mode 100644
index 000000000..7e74d81c1
--- /dev/null
+++ b/apps/web/src/features/sandbox/allocation-phase.ts
@@ -0,0 +1,25 @@
+/** How long an allocation has been in its compute phase, from Core's `compute_phase_changed_at`. */
+export interface PhaseTiming {
+ /** When it entered the phase, in epoch seconds. */
+ since: number;
+ /** Seconds in the phase so far. */
+ elapsed: number;
+ /**
+ * While suspended: about how many seconds remain until Core reclaims the snapshot, 0 once that is
+ * due; otherwise null. Core fixes the deadline when suspension starts, so this is an estimate.
+ */
+ reclaimIn: number | null;
+}
+
+/** Null when Core has not recorded when the phase began (allocations older than that record). */
+export function phaseTiming(phase: string, changedAt: string | null, retentionSeconds: number, nowSeconds: number): PhaseTiming | null {
+ const parsed = changedAt ? Date.parse(changedAt) : Number.NaN;
+ if (Number.isNaN(parsed)) return null;
+ const since = Math.floor(parsed / 1000);
+ return {
+ since,
+ // A time ahead of the browser is clock skew, not a future change.
+ elapsed: Math.max(0, nowSeconds - since),
+ reclaimIn: phase === "suspended" ? Math.max(0, since + retentionSeconds - nowSeconds) : null,
+ };
+}
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index f0005c1ba..c2b1fb7f2 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -103,6 +103,12 @@ export function formatPeriod(seconds: number | null | undefined, locale?: string
return locale?.startsWith("zh") ? text.replace(/^([\d.,]+)(?=[^\d\s.,])/, "$1 ") : text;
}
+/** A measured span, rounded to its largest unit, as words: 7,300 s reads "2 hours / 2 小时", 90 s "2 minutes". */
+export function formatSpan(seconds: number, locale?: string): string {
+ const unit = seconds >= 172_800 ? 86_400 : seconds >= 3_600 ? 3_600 : seconds >= 60 ? 60 : 1;
+ return formatPeriod(Math.round(seconds / unit) * unit, locale);
+}
+
/** A chart bucket as words: "1 分钟", "15 minutes", "2 hours". */
export function formatBucket(seconds: number, locale?: string): string {
const [amount, unit] = seconds >= 3600 ? [seconds / 3600, "hour"] : [Math.max(1, seconds / 60), "minute"];
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts
index 29f7fde87..0ac6c460b 100644
--- a/apps/web/src/lib/locale-strings.ts
+++ b/apps/web/src/lib/locale-strings.ts
@@ -78,6 +78,11 @@ export const chinese = {
"Session": "Session",
"Recorded state": "已记录状态",
"Recorded compute": "已记录计算状态",
+ "In this state": "当前状态持续",
+ "How long the sandbox has been in its compute state. For a suspended one, the reclaim time is estimated from when it was suspended and the deployment's retention; Core reclaims it around then. Older allocations show a dash until their state next changes.": "沙箱处于当前计算状态的时长。已挂起的沙箱,回收时间按挂起时间和部署的保留时长估算,Core 会在这个时间前后回收它。较早的资源分配在状态下次变化前显示为“—”。",
+ "Since {{time}}": "{{time}}起",
+ "Suspended for {{elapsed}} · reclaimed in about {{remaining}}": "已挂起 {{elapsed}} · 约 {{remaining}}后回收",
+ "Suspended for {{elapsed}} · reclaim due": "已挂起 {{elapsed}} · 即将回收",
"No sandbox allocations.": "暂无沙箱资源分配。",
"Add node": "添加节点",
"Install parsar-sandbox-node on the target host and prepare its provider configuration. Adjust the absolute paths, node name and capacity in the command before running it.": "在目标主机安装 parsar-sandbox-node 并准备运行后端配置。执行命令前,请调整绝对路径、节点名称和容量。",
diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md
index 75cd4e15f..4af1c9c39 100644
--- a/docs/web/protocol-coverage.md
+++ b/docs/web/protocol-coverage.md
@@ -101,7 +101,7 @@ log (`GET /audit-log`) is not consumed; System shows the sandbox deployment only
| Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider |
| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page |
| Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range |
-| Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics |
+| Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. Under microsandbox, a node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase and, while suspended, about when Core reclaims it (that time plus the deployment's `suspension.retention_seconds`); a null time shows a dash |
| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum |
| Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) |
| Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion |