From 436f97cc595e875944ad35ec80b80c28be045b4b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 09:54:49 +0000 Subject: [PATCH] Show node host figures and history; tidy Core metrics revision and sub-millisecond latency --- apps/web/e2e/fixture-console.mjs | 21 ++++++ apps/web/e2e/monitoring.spec.ts | 4 +- apps/web/src/features/fleet/fleet-queries.ts | 10 ++- .../src/features/metrics/CoreMetricsPage.tsx | 2 +- .../src/features/metrics/NodeHostCharts.tsx | 67 +++++++++++++++++++ .../features/metrics/SandboxMetricsPage.tsx | 29 +++++++- apps/web/src/i18n/locales/en/metrics.ts | 11 ++- apps/web/src/i18n/locales/zh-CN/metrics.ts | 11 ++- apps/web/src/lib/format.ts | 2 + docs/web/protocol-coverage.md | 1 + packages/agents-client/src/sandbox-client.ts | 25 +++++++ 11 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/features/metrics/NodeHostCharts.tsx diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 94f770f86..f293efab4 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -199,6 +199,23 @@ function adminRead(response, path, url) { return error(response, 404, "Not found."); } +// A node as Core details it: its last heartbeat and an hour of 60-second host buckets. +function nodeDetail(node) { + const total = 64 * 2 ** 30; + const minute = Math.floor(Date.now() / 60_000) * 60; + const points = Array.from({ length: 60 }, (_, index) => ({ + start: new Date((minute - (60 - index) * 60) * 1000).toISOString(), + cpu_utilization_max: node.online ? 0.3 + 0.1 * Math.sin(index / 6) : null, + memory_used_bytes_max: node.online ? total - node.available_memory_bytes : null, + available_disk_bytes_min: node.available_disk_bytes, + })); + return { + ...node, + host: { effective_cpu_cores: node.cpu_count, cpu_utilization: node.online ? 0.35 : null, total_memory_bytes: node.online ? total : null, available_memory_bytes: node.available_memory_bytes, available_disk_bytes: node.available_disk_bytes, observed_at: node.last_seen_at }, + history: { resolution_seconds: 60, points }, + }; +} + async function sandboxRoute(request, response, path) { if (path === "/deployment" && request.method === "POST") { const input = await body(request); @@ -216,6 +233,10 @@ async function sandboxRoute(request, response, path) { } let m; if ((m = path.match(/^\/nodes\/([^/]+)\/allocations$/))) return send(response, 200, { data: state.allocations.filter((entry) => entry.node_id === m[1]) }); + if ((m = path.match(/^\/nodes\/([^/]+)$/)) && request.method === "GET") { + const node = state.nodes.find((entry) => entry.id === m[1]); + return node ? send(response, 200, nodeDetail(node)) : error(response, 404, "No such node."); + } if ((m = path.match(/^\/nodes\/([^/]+)$/)) && request.method === "DELETE") { const index = state.nodes.findIndex((node) => node.id === m[1]); if (index < 0) return error(response, 404, "No such node."); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 8046963e6..4a3e62021 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -34,7 +34,9 @@ test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page await openConsole(page, request, "sandbox-metrics"); await page.getByRole("button", { name: "Show core-01" }).click(); const node = page.getByRole("dialog", { name: "core-01" }); - await expect(node.getByLabel("Node figures")).toContainText("Free memory"); + // The machine's own load, from its heartbeats, beside the sandboxes placed on it. + await expect(node.getByLabel("Node figures")).toContainText("35% of 16 cores"); + await expect(node.getByRole("figure", { name: /^Host CPU/ })).toBeVisible(); await expect(node).toContainText("Hosted sandboxes on this node"); await node.getByRole("button", { name: "Close dialog" }).click(); diff --git a/apps/web/src/features/fleet/fleet-queries.ts b/apps/web/src/features/fleet/fleet-queries.ts index f25b263bd..f0caaa4d0 100644 --- a/apps/web/src/features/fleet/fleet-queries.ts +++ b/apps/web/src/features/fleet/fleet-queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode } from "@agents-core-web/agents-client"; +import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxNodeHistoryRange } from "@agents-core-web/agents-client"; import { sandboxConsoleConfig } from "../sandbox/console-config"; @@ -47,3 +47,11 @@ export function fleetQuery(allocations: boolean) { queryFn: ({ signal }) => loadFleet(allocations, signal), }); } + +/** One node's host observation and host history over a range; polled while shown. */ +export function nodeDetailQuery(nodeId: string, range: SandboxNodeHistoryRange) { + return queryOptions({ + queryKey: ["sandbox-node", nodeId, range], + queryFn: ({ signal }) => client().retrieveNode(nodeId, range, { signal }), + }); +} diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index 76f9922b1..df71953c6 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -101,7 +101,7 @@ function ServiceMeta({ metrics }: { metrics: CoreMetrics }) { tone={notOwner ? "danger" : statusTone[metrics.service.status]} label={t("core.meta", { status: notOwner ? t("core.notOwner") : t(`core.status.${metrics.service.status}`), - revision: metrics.service.revision ?? MISSING, + revision: metrics.service.revision?.slice(0, 12) ?? MISSING, uptime: started === null ? MISSING : formatDuration(Math.max(0, now - started)), })} /> diff --git a/apps/web/src/features/metrics/NodeHostCharts.tsx b/apps/web/src/features/metrics/NodeHostCharts.tsx new file mode 100644 index 000000000..173c0b155 --- /dev/null +++ b/apps/web/src/features/metrics/NodeHostCharts.tsx @@ -0,0 +1,67 @@ +import type { SandboxNodeDetail } from "@agents-core-web/agents-client"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { TimeSeriesChart } from "../../components/charts/TimeSeriesChart"; +import { formatBucket, formatBytes } from "../../lib/format"; + +const GIB = 1024 ** 3; + +/** A share (0–1) as a percentage that never rounds a small load down to "0%". */ +export function formatShare(value: number, locale?: string): string { + const percent = value * 100; + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: percent === 0 ? 0 : percent < 1 ? 2 : percent < 10 ? 1 : 0 }).format(percent)}%`; +} + +/** + * A node machine's host history as the console's chart pair: the busy share of + * its CPU and its used memory against the total, the highest in each bucket. + * Offline and unmeasured buckets stay gaps, never zero. + */ +export function NodeHostCharts({ detail, height = 150 }: { detail: SandboxNodeDetail; height?: number }) { + const { t, i18n } = useTranslation("metrics"); + const locale = i18n.resolvedLanguage; + const { resolution_seconds: resolution, points } = detail.history; + const bucket = formatBucket(resolution, locale); + const total = detail.host.total_memory_bytes; + const series = useMemo(() => ({ + buckets: points.map((point) => Math.floor(Date.parse(point.start) / 1000)), + cpu: points.map((point) => (point.cpu_utilization_max === null ? null : point.cpu_utilization_max * 100)), + memory: points.map((point) => (point.memory_used_bytes_max === null ? null : point.memory_used_bytes_max / GIB)), + total: points.map(() => (total === null ? null : total / GIB)), + }), [points, total]); + const percent = (value: number) => formatShare(value / 100, locale); + return ( +
+
+
{t("sandbox.nodeDialog.hostCpu", { bucket })}
+ +
+
+
{t("sandbox.nodeDialog.hostMemory", { bucket })}
+ formatBytes(value * GIB)} + formatAxis={(value) => `${value.toFixed(value < 10 ? 1 : 0)} GiB`} + height={height} + /> +
+
+ ); +} diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 766268d9f..6c6c9a86a 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -27,7 +27,9 @@ import { loadRuntimeDurableSnapshot, RUNTIME_DURABLE_RANGES, type RuntimeDurable import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { RUNTIME_SNAPSHOT_REFRESH_MS } from "../dashboard/runtime-snapshot"; import { capacitySummary, nodeHealth, type NodeHealth } from "../fleet/fleet-model"; +import { nodeDetailQuery } from "../fleet/fleet-queries"; import { fleetSnapshot, useSandboxFleet, type FleetState } from "../fleet/use-sandbox-fleet"; +import { formatShare, NodeHostCharts } from "./NodeHostCharts"; import { hostedRuntimeRows, hostedRuntimeUsage, @@ -383,9 +385,27 @@ function NodeDialog({ node, rows, load, range, onClose }: { [load, node, sessionIds], ); const history = useRuntimeHistory(snapshot, range); + // The machine itself: its last heartbeat and host history over the page's range. + const hostRange = RUNTIME_DURABLE_RANGES.find((entry) => entry.milliseconds === range)?.label ?? "1h"; + const detail = useQuery({ + ...nodeDetailQuery(shown?.id ?? "", hostRange), + enabled: node !== null, + placeholderData: keepPreviousData, + refetchInterval: RUNTIME_SNAPSHOT_REFRESH_MS, + refetchIntervalInBackground: false, + }); + const host = detail.data?.id === shown?.id ? detail.data?.host ?? null : null; const now = Math.floor(Date.now() / 1000); const online = shown?.online ?? false; const seen = shown?.last_seen_at ? Date.parse(shown.last_seen_at) / 1000 : null; + const cores = host?.effective_cpu_cores ?? shown?.cpu_count ?? null; + const coresLabel = cores === null ? null : t("sandbox.cores", { value: formatInteger(cores, locale) }); + const cpu = !online || coresLabel === null ? MISSING + : host?.cpu_utilization != null ? t("sandbox.nodeDialog.cpuOf", { percent: formatShare(host.cpu_utilization, locale), cores: coresLabel }) : coresLabel; + const memory = !online ? MISSING + : host?.total_memory_bytes != null && host.available_memory_bytes != null + ? t("sandbox.nodeDialog.memoryOf", { used: formatBytes(host.total_memory_bytes - host.available_memory_bytes), total: formatBytes(host.total_memory_bytes) }) + : formatBytes(shown?.available_memory_bytes ?? null); return (
{t("sandbox.status")}
{t("sandbox.slots")}
{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
-
{t("sandbox.cpus")}
{online && shown.cpu_count !== null ? t("sandbox.cores", { value: formatInteger(shown.cpu_count, locale) }) : MISSING}
-
{t("sandbox.freeMemory")}
{online ? formatBytes(shown.available_memory_bytes) : MISSING}
+
{t("sandbox.cpus")}
{cpu}
+
{t("sandbox.memory")}
{memory}
{t("sandbox.freeDiskColumn")}
{online ? formatBytes(shown.available_disk_bytes) : MISSING}
{t("sandbox.cleanupPending")}
{formatInteger(shown.cleanup_pending, locale)}
{t("sandbox.lastSeen")}
{formatRelative(seen, now, locale)}
+
+ {detail.data?.id === shown.id ? + : detail.isError ?

{t("sandbox.nodeDialog.hostFailed", { reason: detail.error instanceof Error ? detail.error.message : "" })}

+ : } +
{t("sandbox.nodeDialog.runtimes")}{t("sandbox.nodeDialog.runtimesMeta", { n: formatInteger(sessionIds.size, locale) })}} diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 6f070665b..dd3ea6162 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -179,7 +179,16 @@ export const metrics = { openLabel: "Show {{name}}", retainedSlots: "Retained / limit", runtimes: "Hosted sandboxes on this node", - runtimesHelp: "CPU and memory of the Core-managed sandboxes placed on this node, over the page's range. Nodes report their free memory and disk with each heartbeat, not a history.", + runtimesHelp: "CPU and memory of the Core-managed sandboxes placed on this node, over the page's range.", + host: "Host", + hostHelp: "The whole machine as the node reports it with each heartbeat: the busy share of its CPU and its used memory, the highest in each interval. Intervals while the node was offline stay empty.", + hostCpu: "Host CPU / {{bucket}}", + hostMemory: "Host memory / {{bucket}}", + used: "Used", + total: "Total", + memoryOf: "{{used}} / {{total}}", + cpuOf: "{{percent}} of {{cores}}", + hostFailed: "Host history could not be read. {{reason}}", runtimesMeta: "{{n}} sandboxes", noRuntimes: "No hosted sandbox runs on this node.", openNode: "Node details", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 15ccbd842..e159f1f47 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -179,7 +179,16 @@ export const metrics = { openLabel: "查看 {{name}}", retainedSlots: "保留 / 上限", runtimes: "这个节点上的托管沙箱", - runtimesHelp: "放在这个节点上的 Core 托管沙箱的 CPU 和内存,时间范围与页面一致。节点每次心跳只上报当前的可用内存和磁盘,没有历史。", + runtimesHelp: "放在这个节点上的 Core 托管沙箱的 CPU 和内存,时间范围与页面一致。", + host: "宿主机", + hostHelp: "节点每次心跳上报的整台机器的情况:CPU 忙碌占比和已用内存,取每个时间段内的最高值。节点离线的时间段留空。", + hostCpu: "宿主机 CPU / {{bucket}}", + hostMemory: "宿主机内存 / {{bucket}}", + used: "已用", + total: "总量", + memoryOf: "{{used}} / {{total}}", + cpuOf: "{{percent}}(共 {{cores}})", + hostFailed: "无法读取宿主机历史。{{reason}}", runtimesMeta: "{{n}} 个沙箱", noRuntimes: "这个节点上没有运行中的托管沙箱。", openNode: "节点详情", diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 1382e2167..d87c8026b 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -38,6 +38,8 @@ export function formatBytes(value: number | null | undefined): string { /** Durations in seconds, as a compact unit string (850 ms, 12.4 s, 4m 12s, 3h 5m). */ export function formatDuration(seconds: number | null | undefined): string { if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return MISSING; + // Sub-10 ms values keep decimals, so a fast database never reads "1 ms" on every tick. + if (seconds < 0.00995) return `${new Intl.NumberFormat("en", { maximumFractionDigits: seconds < 0.001 ? 2 : 1 }).format(seconds * 1000)} ms`; if (seconds < 0.9995) return `${Math.round(seconds * 1000)} ms`; if (seconds < 9.95) return `${seconds.toFixed(1)} s`; const whole = Math.round(seconds); diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 0c6934b8d..0f1eeecd0 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -104,6 +104,7 @@ log (`GET /audit-log`) and Runtime history capabilities | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, Core origin, maintenance state, installation ID and specification; initialize the deployment with `resources`, the Docker or microsandbox `runtime` release, or the E2B account; change its settings with the expected generation | | 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 | +| 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 | | Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: a single-use token inside a command that verifies the installer checksum | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion | diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 45e2e1d3b..27f3d2c35 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -54,6 +54,28 @@ export interface SandboxNode { cleanup_pending: number; created_at: string; } +/** The node machine's last heartbeat observation; unavailable measurements are null. */ +export interface SandboxNodeHost { + effective_cpu_cores: number | null; + /** Busy share of the whole host's CPU between heartbeats, 0–1. */ + cpu_utilization: number | null; + total_memory_bytes: number | null; + available_memory_bytes: number | null; + available_disk_bytes: number | null; + observed_at: string | null; +} +export interface SandboxNodeHostPoint { + start: string; + cpu_utilization_max: number | null; + memory_used_bytes_max: number | null; + available_disk_bytes_min: number | null; +} +export type SandboxNodeHistoryRange = "1h" | "6h" | "24h"; +/** One node with its host observation and complete UTC buckets of host history. */ +export interface SandboxNodeDetail extends SandboxNode { + host: SandboxNodeHost; + history: { resolution_seconds: number; points: SandboxNodeHostPoint[] }; +} export interface SandboxAllocation { id: string; node_id: string; @@ -110,6 +132,9 @@ export class SandboxAdminClient extends OpenAIAgentsClient { listNodes(options?: ReadOptions): Promise<{ data: SandboxNode[] }> { return this.request("/nodes", { signal: options?.signal }, undefined, false); } + retrieveNode(nodeId: string, range: SandboxNodeHistoryRange, options?: ReadOptions): Promise { + return this.request(`/nodes/${encodeURIComponent(nodeId)}?range=${range}`, { signal: options?.signal }, undefined, false); + } listAllocations(nodeId: string, options?: ReadOptions): Promise<{ data: SandboxAllocation[] }> { return this.request(`/nodes/${encodeURIComponent(nodeId)}/allocations`, { signal: options?.signal }, undefined, false); }