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 |