Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions apps/web/e2e/data/routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/features/sandbox/NodeDetail.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -22,7 +23,26 @@ function Diagnostic({ value }: { value: string }) {
return <span className="node-diagnostic">{message.label}<HelpTip label={message.label}>{message.advice}</HelpTip></span>;
}

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 <td>{MISSING}</td>;
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 <td className="nodes-nowrap" title={formatDateTime(timing.since, locale)}>{text}</td>;
}

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";
Expand Down Expand Up @@ -76,6 +96,8 @@ export function NodeDetail({ node, allocations, stale }: { node: SandboxNode; al
<th scope="col">{t("Session")}</th>
<th scope="col">{t("Recorded state")}</th>
<th scope="col">{t("Recorded compute")}</th>
{/* Only microsandbox changes compute phase; under Docker it is always disabled. */}
{suspension ? <th scope="col"><span className="column-help">{t("In this state")}<HelpTip>{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.")}</HelpTip></span></th> : null}
<th scope="col">{t("Issue")}</th>
<th scope="col">{t("Created")}</th>
</tr>
Expand All @@ -86,6 +108,7 @@ export function NodeDetail({ node, allocations, stale }: { node: SandboxNode; al
<th scope="row"><CopyableId id={allocation.session_id} label={t("Session")} /></th>
<td>{sandboxStateLabel(allocation.state, shortLocale)}</td>
<td>{allocation.compute_phase ? sandboxStateLabel(allocation.compute_phase, shortLocale) : MISSING}</td>
{suspension ? <PhaseTime allocation={allocation} retentionSeconds={suspension.retention_seconds} now={now} /> : null}
<td>{allocation.diagnostic ? <Diagnostic value={allocation.diagnostic} /> : MISSING}</td>
<td className="nodes-nowrap">{formatDateTime(seconds(allocation.created_at), locale)}</td>
</tr>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/sandbox/SandboxManagerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
/>
<div className="console-page-body sandbox-content">
{status}
{selected ? <NodeDetail node={selected} allocations={allocations} stale={!confirmed} /> : snapshot && !loading ? (
{selected ? <NodeDetail node={selected} allocations={allocations} stale={!confirmed} suspension={snapshot?.deployment.suspension ?? null} /> : snapshot && !loading ? (
<EmptyState icon={Server} title={t("Node not found")} hint={t("This node is not registered. It may have been removed.")} action={<button type="button" className="button outline" onClick={back}>{t("Back")}</button>} />
) : null}
</div>
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/features/sandbox/allocation-phase.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions apps/web/src/features/sandbox/allocation-phase.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
6 changes: 6 additions & 0 deletions apps/web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/locale-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 并准备运行后端配置。执行命令前,请调整绝对路径、节点名称和容量。",
Expand Down
2 changes: 1 addition & 1 deletion docs/web/protocol-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading