From 80641b94798dcddaf87b856a99c87573b09aca90 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 14 Oct 2026 05:50:03 +0000 Subject: [PATCH 1/2] Give health and capacity facts one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nodes owns the node list, per-node capacity, host figures, Add node and Edit node. Overview rows open their pages instead of popovers. Sandbox metrics keeps the hosted Runtime series and counts running against total. Settings → Sandbox and scheduling keeps setup, rollout, reset and the idle policy. Core metrics leads with whether Core can run Turns and folds process internals into Diagnostics. --- apps/web/DESIGN.md | 14 +- apps/web/PRODUCT.md | 10 +- apps/web/e2e/access.spec.ts | 2 +- apps/web/e2e/monitoring.spec.ts | 29 +-- apps/web/e2e/nodes.spec.ts | 24 +- apps/web/e2e/sandbox-lifecycle.spec.ts | 16 +- apps/web/e2e/unified-settings.spec.ts | 22 +- apps/web/src/components/console-popover.tsx | 43 ---- apps/web/src/components/ui/popover.tsx | 118 --------- .../src/features/fleet/FleetReadNotice.tsx | 4 - .../src/features/fleet/fleet-model.test.ts | 4 +- apps/web/src/features/fleet/fleet-model.ts | 8 - .../src/features/metrics/CoreMetricsPage.tsx | 154 ++++++------ apps/web/src/features/metrics/MetricsView.css | 13 + .../src/features/metrics/NodeHostCharts.tsx | 67 ------ .../features/metrics/SandboxMetricsPage.tsx | 226 ++---------------- .../overview/FleetOverview.render.test.tsx | 17 +- .../src/features/overview/FleetOverview.tsx | 169 ++----------- .../src/features/overview/GettingStarted.tsx | 2 +- .../src/features/overview/OverviewPage.tsx | 13 +- apps/web/src/features/overview/overview.css | 2 +- apps/web/src/features/sandbox/NodeDetail.tsx | 7 +- apps/web/src/features/sandbox/NodeHost.tsx | 111 +++++++++ apps/web/src/features/sandbox/NodeList.tsx | 2 +- .../sandbox/SandboxCapacitySettings.tsx | 94 -------- .../sandbox/SandboxDeploymentPage.tsx | 8 +- .../sandbox/SandboxLifecycle.test.tsx | 44 ---- .../src/features/sandbox/SandboxLifecycle.tsx | 55 ----- .../features/sandbox/SandboxManagerView.tsx | 65 ++++- .../sandbox/SuspensionSettings.test.tsx | 16 ++ .../features/sandbox/SuspensionSettings.tsx | 61 +++++ .../sandbox/sandbox-page-ownership.test.tsx | 5 +- apps/web/src/i18n/locales/en/metrics.ts | 95 ++------ apps/web/src/i18n/locales/en/overview.ts | 29 +-- apps/web/src/i18n/locales/zh-CN/metrics.ts | 95 ++------ apps/web/src/i18n/locales/zh-CN/overview.ts | 29 +-- apps/web/src/i18n/locales/zh-CN/sandbox.ts | 27 ++- apps/web/src/lib/console-navigation.tsx | 5 +- apps/web/src/lib/console-routes.test.ts | 1 - apps/web/src/styles/app.css | 1 - apps/web/src/styles/system.css | 79 ------ contracts/agents-api/sandbox-deployment.md | 2 +- contracts/agents-api/zh/sandbox-deployment.md | 4 +- docs/configuration.md | 8 +- docs/getting-started/install.md | 2 +- docs/getting-started/nodes.md | 6 +- docs/web/console-api-usage.md | 10 +- docs/web/index.md | 10 +- docs/zh/configuration.md | 10 +- docs/zh/getting-started/install.md | 4 +- docs/zh/getting-started/nodes.md | 8 +- docs/zh/web/console-api-usage.md | 12 +- docs/zh/web/index.md | 10 +- 53 files changed, 546 insertions(+), 1326 deletions(-) delete mode 100644 apps/web/src/components/console-popover.tsx delete mode 100644 apps/web/src/components/ui/popover.tsx delete mode 100644 apps/web/src/features/metrics/NodeHostCharts.tsx create mode 100644 apps/web/src/features/sandbox/NodeHost.tsx delete mode 100644 apps/web/src/features/sandbox/SandboxCapacitySettings.tsx delete mode 100644 apps/web/src/features/sandbox/SandboxLifecycle.test.tsx delete mode 100644 apps/web/src/features/sandbox/SandboxLifecycle.tsx create mode 100644 apps/web/src/features/sandbox/SuspensionSettings.test.tsx create mode 100644 apps/web/src/features/sandbox/SuspensionSettings.tsx diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 7761fe578..aa9a49ca8 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -297,7 +297,7 @@ A restrained neutral ledger with one indigo voice, three signal colours and a se A fixed 224px sidebar sits beside the flat main column, separated by a 1px rule. Below 640px it becomes a 56px icon rail with accessible names and no clipped labels. The header is at least 56px tall, with a 20px/600 title and actions on the right; actions wrap onto a new line when the available width cannot contain them. The scrolling body uses `24px 24px 40px` padding and a 24px section gap; horizontal padding becomes 16px below 640px. Wide tables scroll within their own frame. -The recurring shapes in the body are the KPI strip (auto-fit columns, min 158px; three per row below 1180px), chart grids (two equal columns, single below 1180px), full-width resource tables, and a fact row on detail pages. Overview has its own arrangement: Getting started while a step is to do, a four-column metric strip separated by vertical rules, Session activity beside a compact fleet inventory, then flat attention and project usage tables. The activity and fleet grid has a 24px gap and stacks below 680px of content width; the metric strip uses two columns below 560px. Core and up to four nodes open anchored popovers with facts and links to their pages. When more nodes exist, offline and degraded nodes take priority and a link leads to the full list. Popovers are the overlay card (8px radius, overlay shadow, 16px padding): a 14px title, 12px labels over 13px values, links at a ruled foot. Nodes itself is a plain list with a detail page. +The recurring shapes in the body are the KPI strip (auto-fit columns, min 158px; three per row below 1180px), chart grids (two equal columns, single below 1180px), full-width resource tables, and a fact row on detail pages. Overview has its own arrangement: Getting started while a step is to do, a four-column metric strip separated by vertical rules, Session activity beside a compact fleet inventory, then flat attention and project usage tables. The activity and fleet grid has a 24px gap and stacks below 680px of content width; the metric strip uses two columns below 560px. Core, E2B and up to four nodes are plain rows that open their pages: Core metrics, Sandbox metrics and the node's page on Nodes. When more nodes exist, offline and degraded nodes take priority and a link leads to the full list. Nodes itself is a plain list with a detail page. Spacing follows a 4px base: 4, 8, 12, 16, 24 (page gutter and section gap). Controls are 32px tall, segmented options 26px, table rows 44px (32px compact), table headers 36px. @@ -313,7 +313,7 @@ Page structure comes from typography, spacing and dividers. Reserve elevation fo - **Page surface**: flat and full-height, with no outer radius or shadow. - **Card** (no shadow; a 1px `frame-line` ring at 11% ink): KPI strips, chart grids, Overview cards, the Session transcript and Settings row cards. Cards are flat; no page surface is translucent or blurred. - **Control ring** (a 1px Firm Rule ring with an extra-small shadow): outline buttons, the active segment, and search fields and selects in toolbars. Inputs inside cards and dialogs carry only a Hairline ring. -- **Overlay** (a 1px Hairline ring with a large soft shadow): anchored popovers, menus, dialogs, help tips and chart tooltips. +- **Overlay** (a 1px Hairline ring with a large soft shadow): menus, dialogs, help tips and chart tooltips. ### Named Rules **The One Card Rule.** Figures, charts and tables sit in one card divided by 1px internal rules. A card never contains another bordered, shadowed container; empty states remain unframed, including inside a card. @@ -332,7 +332,7 @@ Compact and quiet; the primary button is the only filled button in a header. - **Danger:** Fault Red fill, white text: the confirm button of every destructive dialog. On a page, a destructive button such as Delete on a detail page is red text on an outline button that takes a red tint on hover. - **Ghost:** transparent with Graphite text; hover takes Ink on the Hover wash. - **Focus / Press:** focus draws a 2px indigo outline 2px outside the button; press scales to 0.96. -- **Text action:** borderless Graphite 12.5px/500, 24px tall with 6px corners, that turns Ink on the Hover wash; used only for per-row actions in tables (Rename, Archive, Delete) and links in a popover's foot, never in a header. A destructive text action turns red on hover. +- **Text action:** borderless Graphite 12.5px/500, 24px tall with 6px corners, that turns Ink on the Hover wash; used only for per-row actions in tables (Rename, Archive, Delete), never in a header. A destructive text action turns red on hover. ### Refresh button A 32px ghost icon button with the refresh glyph. Controls that scope the whole page (project filter, time range) come before it; on detail pages it leads, followed by any outline actions and Delete. It spins while reading; its tooltip carries the last update time instead of a visible timestamp. @@ -412,7 +412,7 @@ Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and sc ### Sandbox lifecycle -For deployments with a suspension policy, Nodes and Sandbox metrics lead with one shared KPI strip: active slots, suspended placements, retained slots, idle threshold and snapshot retention. The counts identify their Core accounting scope in help tips; offline nodes never contribute to online slot limits. Read failures preserve known counts with a visible unconfirmed notice, and absent evidence stays a dash. Policy values are read-only. Allocation rows show elapsed time in the recorded compute phase without estimating expiry. Sandbox metrics places recorded compute beside the separate Runtime status, retaining unavailable diagnostics. Supporting persistence and resume conditions belong in help tips. +For deployments with a suspension policy, the Nodes list adds Suspended and Retained / limit columns and a node's Capacity strip adds Suspended, Retained / limit and Snapshots; one-line help tips say what each limit counts. Overview adds the suspended total of all registered nodes under its sandbox slots. The idle threshold is edited in Settings → Sandbox and scheduling, where snapshot retention is read-only. Read failures preserve known counts with a visible unconfirmed notice, and absent evidence stays a dash. Allocation rows show elapsed time in the recorded compute phase without estimating expiry. Sandbox metrics places recorded compute beside the separate Runtime status, retaining unavailable diagnostics. Supporting persistence and resume conditions belong in help tips. ### Configuration generations A single rollout row opens a details dialog for Core's target generation, previous-generation sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or while the independent reset is active. Settled is preparation state, not proof that all nodes are ready or all older Sessions have ended. Retained old resources alone must not keep rapid polling alive. Render failed, update-required and unknown target states distinctly. Keep offline/live-provider status separate from a node's durable serving-generation pin; the pin alone never means the node is online or ready. Node detail shows the serving generation and target preparation; allocation detail shows the owned configuration generation. Do not calculate rollout completion from these rows or promise immediate placement on the target. @@ -427,11 +427,11 @@ A persistent progress panel uses Core's busy, idle and cleanup counts, deadline Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state. ### Settings page -Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes, metrics and Getting started open the owning tab and, where appropriate, its node editor or enrollment flow. Forms save independently through their resource APIs, never through one cross-resource Save all action. +Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes and metrics open the owning tab, and the Sandbox and scheduling header links back to Nodes. Forms save independently through their resource APIs, never through one cross-resource Save all action. The hierarchy is page, tab, section, row. The page header loses its rule to an underline tab row: 40px text tabs in Graphite 13px/500, the current one Ink with a 2px Ink underline. The body is a left-aligned column at most 880px wide with 32px between sections. A section is the shared Section heading (14px/600, help tip, section-wide outline actions on the right) over one card of label/value rows (`settings.css`): a 200px Graphite label column with its help tip and an Ink value column, 8px by 16px padding, 32px minimum line height, Faint Rules between rows; below 640px the label sits above the value. An action that changes one value sits at the end of its row (Edit tenant capacity; Save beside the idle-seconds field, whose error appears beneath it). Read-only status is a row too (Configuration rollout, Snapshot retention); tables sit in one framed card. Settings never repeats the KPI strips of Nodes or the metrics pages. The irreversible operation closes the page in its own section: a red-ringed card with one Ink line stating the consequence and a red-text outline button. -Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Each sandbox, the E2B connection rows and the rollout row, with Change resources in the section header), then Capacity and suspension (the deployment-wide idle policy and read-only snapshot retention, shown only when the deployment declares suspension), then Sandbox nodes (Add node in the header, per-node limits in a table), then Reset. While a reset runs, its progress section leads the page with Force reset now and Cancel reset in its header. Add node opens the enrollment form here. Nodes remains an operational inventory: status, allocations, host figures and removal, with links to the canonical settings forms. Resource changes never imply rewriting existing allocations. Lower limits do not imply forced termination or snapshot deletion. +Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Each sandbox, the E2B connection rows and the rollout row, with Change resources in the section header), then Automatic suspension (the deployment-wide idle policy and read-only snapshot retention, shown only when the deployment declares suspension), then Reset. While a reset runs, its progress section leads the page with Force reset now and Cancel reset in its header. Nodes owns the nodes themselves: status, per-node limits, allocations, host figures and charts, Add node (with its enrollment form), Edit node and removal. Resource changes never imply rewriting existing allocations. Lower limits do not imply forced termination or snapshot deletion. Workspace storage shows the selected configuration's adapter and tenant capacity (in bytes units, or No tenant capacity limit) under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. The configuration ID and parameters JSON sit in a closed Configuration details disclosure at the end of the card. Select new configuration accepts an adapter identifier, configuration ID, parameters JSON object and optional write-only credential JSON object. Edit tenant capacity keeps the current configuration identity. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. Never prefill credentials or persist their drafts across navigation. A failed read is not an unconfigured filesystem, and an uncertain write is not a safe automatic retry. @@ -450,7 +450,7 @@ The self-hosted connection panel names Core's observed state, the bound key and ### Loading and motion The console has no spinners and no "Loading…" lines. Reads are cached (TanStack Query) and prefetched on navigation hover, so revisits show data at once and refreshes keep the last data on screen. Only a first read shows a skeleton in the final layout's cards: table rows, a headline strip with chart panels, or a facts card with a table, with a sweep that repeats every 900ms and stops under reduced motion. Work in progress is the Agent's shimmering "Working…" line in the conversation and the breathing pending dot. -Motion reports state and never makes anyone wait: the navigation chip and segmented thumbs glide (Motion, one 320ms spring without bounce), figures roll, charts draw in once per range, new conversation messages settle 6px upward in 260ms, pages fade in 160ms, anchored popovers scale from 98% and dialogs from 96%. Reduced motion makes all of it instant. +Motion reports state and never makes anyone wait: the navigation chip and segmented thumbs glide (Motion, one 320ms spring without bounce), figures roll, charts draw in once per range, new conversation messages settle 6px upward in 260ms, pages fade in 160ms and dialogs scale from 96%. Reduced motion makes all of it instant. ## Do's and Don'ts diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 1f3b5c4b5..76cad846c 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -29,23 +29,23 @@ The console runs beside the administrator's own Core, with execution, files and ## Information Architecture -- **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. +- **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, where Core opens Core metrics, a node opens its page on Nodes and E2B opens Sandbox metrics, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (whether Core can run Turns: execution slots, the Turn queue, its p95 wait and interrupted Turns, connected daemons, database latency, the Core process's CPU and memory, and background jobs; Go heap, goroutines and the database pool sit in a collapsed Diagnostics area), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (hosted Runtimes across projects over time, running against total; a sandbox opens in a dialog with its figures and CPU and memory charts; an E2B deployment adds what Core holds in E2B's cloud), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. An empty list says, in one sentence, that applications create these resources through the Agents API, and links to that resource's section of the API guide; Agent metrics does the same for Sessions. -- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity observations, host figures, allocations and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset, idle policy, per-node capacity and enrollment. Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Nodes links to these forms; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each harness's default model and shows whether each harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. +- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each harness's default model and shows whether each harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. - A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows. - A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes list and its page its status is Old address, with "Remove and add again", never Available. - **Sandbox reset** is an explicit administrator operation in Settings → Sandbox and scheduling. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save. - **Online sandbox configuration** changes the same backend's resources, Runtime or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. -- **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (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. -- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes and Sandbox metrics show Core's active/retained placement counts and limits across online nodes, suspended placements across all registered nodes, and the current idle and snapshot-retention policy. Settings → Sandbox and scheduling owns one **Capacity and suspension** area: deployment-wide idle seconds are editable there; snapshot retention remains read-only. The adjacent **Sandbox nodes** area owns each node's active and retained limits. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics retains Runtime observation status separately from the matching allocation's recorded phase. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. +- **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. +- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics retains Runtime observation status separately from the matching allocation's recorded phase. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. - **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default harness (on any enabled harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. - Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, Skill, Vault, Credential, Harness). The sign-in credential is the Core key ("Core 密钥"); keys issued in a project for applications are project API keys ("项目 API 密钥"). Empty lists read "No … yet" ("还没有…"). Provider readiness is "Provider not ready / 提供方未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms. ## Capabilities and Constraints - **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. The list shows active projects by default; the Archived filter, with its count, shows the rest. Archiving a project revokes every key and keeps its assets viewable and deletable; it can't be undone, so its confirmation says so, counts the active keys it revokes and, when there are any, asks for the project's name. Key plaintext is shown once, at issuance, and never stored by the console. Beside it, and without the key on an active project's page, the console tells developers to set `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python samples that list Agents and create a Session. -- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures: Nodes and Sandbox metrics own those counts. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. +- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures. Each fact has one page: Nodes owns the node list, per-node capacity and host figures; Overview summarizes capacity and links to Nodes; Sandbox metrics owns Runtime time series and activity; Core metrics owns the Core process. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. - **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the existing storage API. It shows the adapter and tenant capacity; the configuration ID and adapter parameters stay under Configuration details. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save. - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 815ebe0bc..5d39309fb 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -163,7 +163,7 @@ test("opens Add node from Getting started when no node has joined", async ({ pag const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); await expect(step).toContainText("To do"); await step.getByRole("button", { name: "Add node" }).click(); - await expect(page.getByRole("heading", { name: "Sandbox and scheduling", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); }); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 28f5f2921..0fb4ee692 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -10,9 +10,13 @@ test.afterEach(async ({ request }) => expectManagementBoundary(request)); test("shows the deployment's health on Overview and each monitor page", async ({ page, request }) => { await openConsole(page, request, "overview"); await expect(page.getByRole("article").filter({ hasText: "Service status" })).toContainText("Degraded"); + // A node row opens that node's page on Nodes. await page.getByRole("button", { name: /^edge-03, Offline/ }).click(); - await expect(page.getByRole("dialog", { name: "edge-03" })).toContainText("Available memory"); - await page.keyboard.press("Escape"); + await expect(page.getByRole("heading", { name: "edge-03", level: 1 })).toBeVisible(); + await expect(page.getByRole("region", { name: "Host" })).toContainText("Free disk"); + // A degraded node names why its provider is not ready. + await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await expect(page.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Host unsupported", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Core metrics" }).click(); const core = page.getByLabel("Core summary"); @@ -22,9 +26,7 @@ test("shows the deployment's health on Overview and each monitor page", async ({ await page.getByRole("button", { name: "Agent metrics" }).click(); 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.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Host unsupported", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Hosted Runtimes" })).toContainText("core-01"); }); test("opens a Session's conversation from the Session log, read-only", async ({ page, request }) => { @@ -227,16 +229,15 @@ test("issues a new executor credential after the unanswered one was rotated from await expect(page.getByRole("dialog", { name: "Executor credential" }).getByLabel("Executor credential file")).toContainText("exec_fixture_"); }); -test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page, request }) => { - await openConsole(page, request, "sandbox-metrics"); - await page.getByRole("button", { name: "Show core-01" }).click(); - const node = page.getByRole("dialog", { name: "core-01" }); - // 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(); +test("shows a node's host on Nodes and opens a sandbox in a dialog from Sandbox metrics", async ({ page, request }) => { + await openConsole(page, request, "nodes"); + await page.getByRole("button", { name: "Open core-01", exact: true }).click(); + // The machine's own load, from its heartbeats. + const host = page.getByRole("region", { name: "Host" }); + await expect(host).toContainText("35% of 16 CPU"); + await expect(host.getByRole("figure", { name: /^Host CPU/ })).toBeVisible(); + await page.getByRole("button", { name: "Sandbox metrics", exact: true }).click(); await page.getByRole("button", { name: /^Show sandbox of / }).first().click(); const sandbox = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Open Session" }) }); await expect(sandbox.getByLabel("Sandbox")).toContainText("Node"); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index ef6e161fb..836049f0d 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -112,7 +112,7 @@ test("issues no command before the installation is read, for a loopback public U await held; await route.fulfill({ status: 500, json: { error: { message: "Unavailable.", type: "server_error", code: null, param: null } } }); }); - await openConsole(page, request, "settings?id=sandbox", { installation: "local" }); + await openConsole(page, request, "nodes", { installation: "local" }); await page.getByRole("button", { name: "Add node" }).click(); const add = page.getByRole("dialog", { name: "Add node" }); await expect(add.getByRole("status")).toHaveText("Checking this installation's public URL…"); @@ -351,7 +351,7 @@ test("keeps the page usable when Core refuses a sandbox change, and shows a safe test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => { await openConsole(page, request, "nodes"); - // Nodes links to the sole capacity editor in Settings. + // Nodes owns each node's limits and their editor. const capacity = page.getByRole("table", { name: "Sandbox nodes" }); await expect(capacity).toContainText("Active / limit"); await expect(capacity).toContainText("5 / 8"); @@ -361,23 +361,7 @@ test("renames a node and sets how many sandboxes run on it at once", async ({ pa await edit.getByLabel("Sandboxes at once").fill("6"); await edit.getByRole("button", { name: "Save" }).click(); await expect(edit).toBeHidden(); - await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("core-01-large"); - await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("5 / 6"); + await expect(capacity).toContainText("core-01-large"); + await expect(capacity).toContainText("5 / 6"); }); - -test("node settings history follows the selected node URL", async ({ page, request }) => { - await openConsole(page, request, "nodes"); - await page.getByRole("row").filter({ hasText: "core-01" }).getByRole("button", { name: "Edit node", exact: true }).click(); - const editor = page.getByRole("dialog", { name: "Edit node", exact: true }); - await expect(editor).toBeVisible(); - await expect(page).toHaveURL(/#settings\?id=sandbox&node=/); - await editor.getByRole("button", { name: "Cancel", exact: true }).click(); - await expect(editor).toBeHidden(); - await page.goBack(); - await expect(editor).toBeVisible(); - await page.goForward(); - await expect(page).toHaveURL(/#settings\?id=sandbox$/); - await expect(editor).toBeHidden(); - expect(await writes(request)).toEqual([]); -}); diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index c90563edb..9ef49f1e7 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -3,7 +3,7 @@ import { openConsole, expectManagementBoundary, setDeployment, writes } from "./ test.afterEach(async ({ request }) => expectManagementBoundary(request)); -test("shows automatic suspension policy, retained capacity and elapsed phase time without inventing expiry", async ({ page, request }, testInfo) => { +test("shows each node's retained limit and elapsed phase time without inventing expiry", async ({ page, request }, testInfo) => { await page.route("**/core/v1/sandbox/deployment", async (route) => { const response = await route.fetch(); const deployment = await response.json(); @@ -15,9 +15,9 @@ test("shows automatic suspension policy, retained capacity and elapsed phase tim await route.fulfill({ response, json: { data: data.map((node: Record) => ({ ...node, provider: "microsandbox", retained: Number(node.active) + 2 })) } }); }); await openConsole(page, request, "nodes"); - await expect(page.getByRole("heading", { name: "Automatic suspension" })).toBeVisible(); - await expect(page.getByText("5 minutes", { exact: true })).toBeVisible(); - await expect(page.getByRole("region", { name: "Automatic suspension" }).getByRole("columnheader", { name: "Retained / limit" })).toBeVisible(); + // Nodes shows each node's limits; the deployment's idle policy lives in Settings. + await expect(page.getByRole("table", { name: "Sandbox nodes" }).getByRole("columnheader", { name: "Retained / limit" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Automatic suspension" })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath("nodes-en.png"), fullPage: true }); await page.getByRole("button", { name: "Open core-01", exact: true }).click(); const allocations = page.getByRole("table", { name: "Sandbox allocations" }); @@ -30,25 +30,23 @@ test("shows automatic suspension policy, retained capacity and elapsed phase tim await page.screenshot({ path: testInfo.outputPath("sandbox-metrics-en.png"), fullPage: true }); await page.getByRole("button", { name: "Language and appearance" }).click(); await page.getByRole("menuitemradio", { name: "简体中文" }).click(); - await expect(page.getByRole("heading", { name: "自动挂起" })).toBeVisible(); await expect(page.getByRole("columnheader", { name: "记录的计算状态" })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("sandbox-metrics-zh.png"), fullPage: true }); }); -test("hides suspension policy when the provider declares no suspension support", async ({ page, request }) => { +test("hides the retained limit when the provider declares no suspension support", async ({ page, request }) => { await openConsole(page, request, "nodes"); await expect(page.getByRole("table", { name: "Sandbox nodes" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Automatic suspension" })).toHaveCount(0); await expect(page.getByRole("columnheader", { name: "Retained / limit" })).toHaveCount(0); }); -test("edits deployment idle time beside node limits without changing generation", async ({ page, request }) => { +test("edits deployment idle time without changing generation", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); const deployment = await setDeployment(request, { suspension: { idle_seconds: 300, retention_seconds: 86400 } }); await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); - const area = page.getByRole("region", { name: "Capacity and suspension" }); + const area = page.getByRole("region", { name: "Automatic suspension" }); const idle = area.getByLabel("Idle before suspension (seconds)"); await expect(idle).toHaveValue("300"); await idle.fill("29"); diff --git a/apps/web/e2e/unified-settings.spec.ts b/apps/web/e2e/unified-settings.spec.ts index 561b64cea..01b3f6bfb 100644 --- a/apps/web/e2e/unified-settings.spec.ts +++ b/apps/web/e2e/unified-settings.spec.ts @@ -102,22 +102,16 @@ test("capacity editor refuses to select an old configuration after observing a r expect(saves[1].id).toBe(selected.id); }); -test("settings content width does not constrain reset and node dialog backdrops", async ({ page, request }) => { +test("settings content width does not constrain the reset dialog backdrop", async ({ page, request }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await openConsole(page, request, "settings?id=sandbox"); - for (const action of ["Reset deployment", "Edit node"]) { - await page.getByRole("button", { name: action, exact: true }).first().click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - const backdrop = page.locator(".modal-backdrop:not(.closing)"); - const bounds = await backdrop.boundingBox(); - expect(bounds?.x).toBe(0); - expect(bounds?.y).toBe(0); - expect(bounds?.width).toBe(1440); - expect(bounds?.height).toBe(1000); - await page.keyboard.press("Escape"); - await expect(dialog).toBeHidden(); - } + await page.getByRole("button", { name: "Reset deployment", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + const bounds = await page.locator(".modal-backdrop").boundingBox(); + expect(bounds).toEqual({ x: 0, y: 0, width: 1440, height: 1000 }); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); }); diff --git a/apps/web/src/components/console-popover.tsx b/apps/web/src/components/console-popover.tsx deleted file mode 100644 index b1d507b48..000000000 --- a/apps/web/src/components/console-popover.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { ReactElement, ReactNode } from "react"; - -import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; - -/** - * The console's anchored popover: coss ui's Base UI popover as a small overlay - * card for a glance at one object and the ways onward. It opens on click, - * closes on Escape or an outside click, and returns focus to its trigger. The - * body mounts only while open, so queries inside it run on demand. - */ -export function ConsolePopover({ trigger, title, meta, children, actions, side = "bottom", align = "center" }: { - /** The element that opens the popover; it keeps its own classes and label. */ - trigger: ReactElement; - title: ReactNode; - /** A quiet line under the title, such as an ID. */ - meta?: ReactNode; - children: ReactNode; - /** Links onward, set apart at the foot. */ - actions?: ReactNode; - side?: "top" | "bottom" | "left" | "right"; - align?: "start" | "center" | "end"; -}) { - return ( - - - -
-
- {title} - {meta ? {meta} : null} -
- {children} - {actions ?
{actions}
: null} -
-
-
- ); -} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx deleted file mode 100644 index 69d3e1b76..000000000 --- a/apps/web/src/components/ui/popover.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; -import type React from "react"; -import { cn } from "@/lib/utils"; - -export const PopoverCreateHandle: typeof PopoverPrimitive.createHandle = - PopoverPrimitive.createHandle; - -export const Popover: typeof PopoverPrimitive.Root = PopoverPrimitive.Root; - -export function PopoverTrigger({ - className, - children, - ...props -}: PopoverPrimitive.Trigger.Props): React.ReactElement { - return ( - - {children} - - ); -} - -export function PopoverPopup({ - children, - className, - side = "bottom", - align = "center", - sideOffset = 4, - alignOffset = 0, - tooltipStyle = false, - anchor, - portalProps, - ...props -}: PopoverPrimitive.Popup.Props & { - portalProps?: PopoverPrimitive.Portal.Props; - side?: PopoverPrimitive.Positioner.Props["side"]; - align?: PopoverPrimitive.Positioner.Props["align"]; - sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]; - alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"]; - tooltipStyle?: boolean; - anchor?: PopoverPrimitive.Positioner.Props["anchor"]; -}): React.ReactElement { - return ( - - - - - {children} - - - - - ); -} - -export function PopoverClose({ - ...props -}: PopoverPrimitive.Close.Props): React.ReactElement { - return ; -} - -export function PopoverTitle({ - className, - ...props -}: PopoverPrimitive.Title.Props): React.ReactElement { - return ( - - ); -} - -export function PopoverDescription({ - className, - ...props -}: PopoverPrimitive.Description.Props): React.ReactElement { - return ( - - ); -} - -export { PopoverPrimitive, PopoverPopup as PopoverContent }; diff --git a/apps/web/src/features/fleet/FleetReadNotice.tsx b/apps/web/src/features/fleet/FleetReadNotice.tsx index 9b2bd7565..5f9496be1 100644 --- a/apps/web/src/features/fleet/FleetReadNotice.tsx +++ b/apps/web/src/features/fleet/FleetReadNotice.tsx @@ -14,7 +14,3 @@ export function FleetReadNotice({ state, onRetry }: { state: FleetState; onRetry : null} ; } - -export function fleetObservationStale(state: FleetState): boolean { - return state.status === "ready" && (state.error !== null || state.targetGeneration !== state.snapshot.deployment.generation); -} diff --git a/apps/web/src/features/fleet/fleet-model.test.ts b/apps/web/src/features/fleet/fleet-model.test.ts index 97b4fe71f..c8de45dbc 100644 --- a/apps/web/src/features/fleet/fleet-model.test.ts +++ b/apps/web/src/features/fleet/fleet-model.test.ts @@ -6,11 +6,11 @@ import { capacitySummary, coreStatus, nodeHealth } from "./fleet-model"; describe("fleet model", () => { it("counts limits only on online nodes", () => { const summary = capacitySummary([ - node("a", { active: 3, max_active: 4, retained: 1, max_retained: 8 }), + node("a", { active: 3, max_active: 4 }), node("b", { online: false, active: 0, max_active: 4, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null }), node("c", { provider_ready: false, cpu_count: null }), ]); - expect(summary).toMatchObject({ nodes: 3, online: 2, available: 1, active: 4, maxActive: 8, retained: 1, maxRetained: 16 }); + expect(summary).toMatchObject({ nodes: 3, online: 2, available: 1, active: 4, maxActive: 8 }); }); it("classifies node health", () => { diff --git a/apps/web/src/features/fleet/fleet-model.ts b/apps/web/src/features/fleet/fleet-model.ts index c35e58747..48cd3a1c6 100644 --- a/apps/web/src/features/fleet/fleet-model.ts +++ b/apps/web/src/features/fleet/fleet-model.ts @@ -25,10 +25,6 @@ export interface CapacitySummary { active: number; /** Active-sandbox limit across online nodes. */ maxActive: number; - retained: number; - maxRetained: number; - reserved: number; - cleanupPending: number; /** Sandboxes held suspended (where the Provider declares checkpoint support): placed, but not counted as active. */ suspended: number; } @@ -52,10 +48,6 @@ export function capacitySummary(nodes: readonly SandboxNode[]): CapacitySummary available: nodes.filter((node) => nodeHealth(node) === "available").length, active: online.reduce((sum, node) => sum + node.active, 0), maxActive: online.reduce((sum, node) => sum + node.max_active, 0), - retained: online.reduce((sum, node) => sum + node.retained, 0), - maxRetained: online.reduce((sum, node) => sum + node.max_retained, 0), - reserved: nodes.reduce((sum, node) => sum + node.reserved, 0), - cleanupPending: nodes.reduce((sum, node) => sum + node.cleanup_pending, 0), suspended: nodes.reduce((sum, node) => sum + suspendedSandboxes(node), 0), }; } diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index 887bc7a4e..0a6a3f06f 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -6,12 +6,10 @@ import { useTranslation } from "react-i18next"; import { LiveNumber } from "../../components/live-number"; import { TimeSeriesChart } from "../../components/charts/TimeSeriesChart"; -import { DashboardSkeleton, TableSkeleton } from "../../components/Skeleton"; +import { DashboardSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section, SegmentedControl, StatusDot, type Tone } from "../../components/console-ui"; import { epochSeconds, formatBucket, formatBytes, formatClock, formatDuration, formatInteger, formatRelative, MISSING } from "../../lib/format"; -import { FleetReadNotice } from "../fleet/FleetReadNotice"; -import { fleetSnapshot, useSandboxFleet } from "../fleet/use-sandbox-fleet"; import { coreMetricsQuery } from "./metrics-queries"; import "./MetricsView.css"; @@ -42,11 +40,12 @@ function Figure({ value, unit }: { value: ReactNode; unit?: ReactNode }) { } /** - * Monitor › Core metrics: the health of the one Core process — can it run - * Turns (slots, queue, daemons), is its database responsive, are its - * background jobs running. Agent outcomes stay on Agent metrics and sandbox - * capacity on Sandbox metrics; the page follows the same header, headline - * figures and chart-then-table sections as they do. + * Monitor › Core metrics: the operator's questions about the one Core process + * first — can it run Turns (slots, queue, wait, interruptions), is its + * database responsive, are its background jobs running — and the process + * internals (CPU and memory history, Go heap, goroutines, the database pool) + * collapsed under Diagnostics. Agent outcomes stay on Agent metrics and node + * capacity on Nodes. */ export function CoreMetricsPage() { const { t, i18n } = useTranslation("metrics"); @@ -117,11 +116,6 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { const count = (value: number | null) => (value === null ? MISSING : formatInteger(value, locale)); const integer = (value: number) => formatInteger(value, locale); const slotsFull = execution.slots_in_use >= execution.slots_total; - // Sandbox nodes hold their own connection to Core, as daemons do; E2B deployments have none. - const { state: fleetState, refresh: refreshFleet } = useSandboxFleet({ poll: true }); - const fleet = fleetSnapshot(fleetState); - const nodeBacked = fleet ? fleet.deployment.mode === "nodes" : false; - const online = fleet ? fleet.nodes.filter((node) => node.online).length : null; return ( <> @@ -139,16 +133,14 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { value={execution.queued_turns === null ? MISSING :
} unit={execution.waiting_for_daemon ? t("core.waitingForDaemon", { n: execution.waiting_for_daemon }) : undefined} />} tone={(execution.queued_turns ?? 0) > 0 ? "warning" : undefined} /> - } /> - {nodeBacked ? ( - } unit={`/ ${integer(fleet!.nodes.length)}`} />} - tone={online !== null && online < fleet!.nodes.length ? "warning" : undefined} - /> - ) : null} - + + 0 ? "warning" : undefined} + /> + -
{t("core.execution.title")}{t("core.execution.meta", { oldest: execution.oldest_queued_seconds === null ? MISSING : formatDuration(execution.oldest_queued_seconds), - interrupted: count(execution.interrupted), + daemons: integer(execution.connected_daemons), unavailable: count(execution.unavailable), })}} help={t("core.execution.help")} @@ -206,14 +197,10 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) {
{t("core.database.title")}{t("core.database.meta", { - size: formatBytes(database.size_bytes), - inUse: count(database.pool.in_use), - max: count(database.pool.max), - })}} + title={<>{t("core.database.title")}{t("core.database.meta", { size: formatBytes(database.size_bytes) })}} help={t("core.database.help")} > -
+
{t("core.database.latencyChart", { bucket })}
(value === 0 ? "0" : milliseconds(value))} />
-
-
{t("core.database.poolChart", { bucket })}
- entry.pool_in_use) }, - // The limit is drawn only where Core observed the pool. - { id: "max", label: t("core.database.max"), color: "var(--ink-3)", values: database.series.map((entry) => (entry.pool_in_use === null ? null : database.pool.max)) }, - ]} - formatValue={integer} - counts - /> -
-
{t("core.process.title")}{t("core.process.meta", { +
+ {metrics.jobs.length ? ( +
+ + + + + + + + + + + + {metrics.jobs.map((job) => ( + + + + + + + + ))} + +
{t("core.jobs.job")}{t("core.jobs.status")}{t("core.jobs.lastRun")}{t("core.jobs.processed")}{t("core.jobs.failed")}
{t(`core.jobs.names.${job.id}`, { defaultValue: job.id })}{formatRelative(epochSeconds(job.last_run_at), now, locale)}{count(job.processed)}{count(job.failed)}
+
+ ) : } +
+
+ {t("core.diagnostics.title")}{t("core.diagnostics.meta", { heap: formatBytes(process.memory_bytes), goroutines: count(process.goroutines), - })}} - help={t("core.process.help")} - > + inUse: count(database.pool.in_use), + max: count(database.pool.max), + })} +

{t("core.diagnostics.help")}

{t("core.process.cpuChart", { bucket })}
@@ -287,36 +286,25 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { />
-
- -
- {metrics.jobs.length ? ( -
- - - - - - - - - - - - {metrics.jobs.map((job) => ( - - - - - - - - ))} - -
{t("core.jobs.job")}{t("core.jobs.status")}{t("core.jobs.lastRun")}{t("core.jobs.processed")}{t("core.jobs.failed")}
{t(`core.jobs.names.${job.id}`, { defaultValue: job.id })}{formatRelative(epochSeconds(job.last_run_at), now, locale)}{count(job.processed)}{count(job.failed)}
-
- ) : } -
+
+
+
{t("core.database.poolChart", { bucket })}
+ entry.pool_in_use) }, + // The limit is drawn only where Core observed the pool. + { id: "max", label: t("core.database.max"), color: "var(--ink-3)", values: database.series.map((entry) => (entry.pool_in_use === null ? null : database.pool.max)) }, + ]} + formatValue={integer} + counts + /> +
+
+ ); } diff --git a/apps/web/src/features/metrics/MetricsView.css b/apps/web/src/features/metrics/MetricsView.css index 81e0d9fc1..031695d36 100644 --- a/apps/web/src/features/metrics/MetricsView.css +++ b/apps/web/src/features/metrics/MetricsView.css @@ -83,3 +83,16 @@ } .metrics-node-status { display: grid; gap: 4px; } + +/* Core process internals stay collapsed below the operator's figures. */ +.core-diagnostics > summary { + color: var(--fg); + font-size: 15px; + font-weight: 600; + line-height: 22px; + cursor: pointer; +} + +.core-diagnostics > * + * { + margin-top: 12px; +} diff --git a/apps/web/src/features/metrics/NodeHostCharts.tsx b/apps/web/src/features/metrics/NodeHostCharts.tsx deleted file mode 100644 index 8bb507a41..000000000 --- a/apps/web/src/features/metrics/NodeHostCharts.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { SandboxNodeDetail } from "@oac/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 c79c62e4a..48dcaa880 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -23,30 +23,22 @@ import { TableSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; import { ListToolbar, listSummary, NameCell, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; -import { formatBytes, formatClock, formatCompact, formatCores, formatDateTime, formatDuration, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; +import { formatBytes, formatClock, formatCompact, formatCores, formatDuration, formatInteger, formatPercent, MISSING } from "../../lib/format"; import { projectClient, ProjectName, useProjects } from "../../lib/projects"; import { readError } from "../../lib/read-error"; import { sandboxStateLabel } from "../../lib/sandbox-labels"; -import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { loadRuntimeDurableSnapshot, RUNTIME_DURABLE_RANGES, RuntimeDurableHistoryIncompleteError, 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 { nodeHealth, suspendedSandboxes, type NodeHealth } from "../fleet/fleet-model"; -import { nodeDetailQuery } from "../fleet/fleet-queries"; -import { SandboxLifecycle } from "../sandbox/SandboxLifecycle"; -import { NodeRolloutStatus } from "../sandbox/NodeRolloutStatus"; -import { FleetReadNotice, fleetObservationStale } from "../fleet/FleetReadNotice"; +import { FleetReadNotice } from "../fleet/FleetReadNotice"; import { SandboxResetNotice } from "../fleet/SandboxResetNotice"; -import { fleetSnapshot, useSandboxFleet, type FleetState } from "../fleet/use-sandbox-fleet"; +import { fleetSnapshot, useSandboxFleet } from "../fleet/use-sandbox-fleet"; import { sandboxSize, templateBuildStatus } from "../sandbox/deployment-specification"; -import { formatShare, NodeHostCharts } from "./NodeHostCharts"; import { hostedRuntimeRows, hostedRuntimeUsage, matchesRuntime, runtimeSnapshot, - runtimeSnapshotWhere, type HostedRuntimeLoad, type HostedRuntimeRow, } from "./sandbox-runtime"; @@ -55,9 +47,7 @@ import { RuntimeCharts } from "./RuntimeCharts"; import { hostedRuntimesQuery } from "./metrics-queries"; import { SessionRuntimeSection } from "../sessions/SessionRuntimeSection"; import { sessionTitle } from "../../lib/session-title"; -import type { SandboxDeployment, SandboxNode } from "@oac/agents-client"; - -const healthTone: Record = { available: "ok", degraded: "warning", offline: "danger" }; +import type { SandboxDeployment } from "@oac/agents-client"; type RuntimeState = | { status: "loading"; load: HostedRuntimeLoad | null } @@ -89,11 +79,6 @@ const loadHistory = (snapshot: RuntimeDashboardSnapshot, range: RuntimeDurableRa return projectId ? projectClient(projectId) : null; }, snapshot, range, signal); -function fleetMessage(state: FleetState, t: TFunction<"metrics">): string { - if (state.status === "failed") return t("sandbox.fleetFailed"); - return t("sandbox.fleetLoading"); -} - export function SandboxMetricsPage() { const { t, i18n } = useTranslation("metrics"); const locale = i18n.resolvedLanguage; @@ -101,18 +86,15 @@ export function SandboxMetricsPage() { const { state: fleetState, refresh: refreshFleet, deployment } = useSandboxFleet({ allocations: true }); const { state: runtimeState, refresh: refreshRuntime, stale: runtimeStale } = useHostedRuntimes(); const fleet = fleetSnapshot(fleetState); - const now = Math.floor(Date.now() / 1000); const refreshing = runtimeState.status === "loading" || (fleetState.status === "ready" && fleetState.refreshing); const updatedAt = runtimeState.load?.loadedAt ?? fleet?.loadedAt ?? null; - const message = fleetMessage(fleetState, t); const [range, setRange] = useState(RUNTIME_DURABLE_RANGES[0]); - // A node or a sandbox opens in a dialog with its own figures, instead of leaving the page. - const [openNode, setOpenNode] = useState(null); + // A sandbox opens in a dialog with its own figures, instead of leaving the page. const [openRuntime, setOpenRuntime] = useState(null); const rows = useMemo(() => (runtimeState.load ? hostedRuntimeRows(runtimeState.load, "", fleet) : []), [fleet, runtimeState.load]); - // A direct Provider runs sandboxes in its cloud: no machines, so no node table, node column or node dialog. + // A direct Provider runs sandboxes in its cloud: no machines, so no node column. const cloud = fleet?.deployment.mode === "direct"; - // Only a Provider that declares checkpoint support suspends sandboxes; its nodes also show how many sleep. + // Only a Provider that declares checkpoint support records a sandbox's compute phase. const suspends = Boolean(fleet?.deployment.suspension); return ( @@ -134,103 +116,23 @@ export function SandboxMetricsPage() { void deployment.refetch()} /> - {fleet && suspends ? : null} - {cloud && fleet ? :
navigate("nodes")}>{t("sandbox.manageNodes")}} - > - {fleet ? fleet.nodes.length ? ( -
- - - - - - - {suspends ? : null} - - - - - - - - - {fleet.nodes.map((node) => ( - setOpenNode(node.id)}> - - - - {suspends ? : null} - - - - - - - ))} - -
{t("sandbox.node")}{t("sandbox.status")}{t("sandbox.slots")}{t("sandbox.suspended")}{t("sandbox.suspendedHelp")}{t("sandbox.cpus")}{t("sandbox.freeMemory")}{t("sandbox.freeDiskColumn")}{t("sandbox.cleanupPending")}{t("sandbox.lastSeen")}
setOpenNode(node.id)} openLabel={t("sandbox.nodeDialog.openLabel", { name: node.name || node.id })} />
- - - {node.active} / {node.max_active} - - {suspendedSandboxes(node)}{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)}
-
- ) : ( - // Before sandbox setup there is nothing to add a node to; setup comes first. - fleet?.deployment.provider ? ( - navigate("settings", { id: "sandbox" }, "add-node")}>{t("sandbox.addNode")}} - /> - ) : ( - navigate("settings", { id: "sandbox" })}>{t("sandbox.setUp")}} - /> - ) - ) : fleetState.status === "loading" - ? - :

{message}

} -
} - - + {fleet && !fleet.deployment.provider ? ( + navigate("settings", { id: "sandbox" })}>{t("sandbox.setUp")}} + /> + ) : <> + {cloud && fleet ? : null} + + }
- node.id === openNode) ?? null} - rows={rows} - stale={fleetObservationStale(fleetState)} - load={runtimeState.load} - range={range} - suspends={suspends} - onClose={() => setOpenNode(null)} - /> row.observation.session_id === openRuntime) ?? null} showNode={!cloud} showCompute={suspends} onClose={() => setOpenRuntime(null)} />
); } -/** 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. */ +/** An E2B deployment: what runs in its cloud now, and with what. */ function CloudSection({ deployment }: { deployment: SandboxDeployment }) { const { t, i18n } = useTranslation("metrics"); const locale = i18n.resolvedLanguage; @@ -337,6 +239,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {usage?.hosted ? ( {t(fleet?.deployment.suspension ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", { + running: formatInteger(usage.active, locale), n: formatInteger(usage.hosted, locale), sleeping: formatInteger(usage.sleeping, locale), cpu: usage.cpuUsageCores === null ? MISSING : t("sandbox.cores", { value: formatCores(usage.cpuUsageCores, locale) }), @@ -460,97 +363,6 @@ function useLast(value: T | null): T | null { return value ?? last.current; } -/** - * A node in a dialog: the host figures it reports with each heartbeat, and - * CPU and memory of the hosted sandboxes placed on it over the page's range. - */ -function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: { - stale: boolean; - /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ - suspends: boolean; - node: SandboxNode | null; - rows: readonly HostedRuntimeRow[]; - load: HostedRuntimeLoad | null; - range: RuntimeDurableRange; - onClose: () => void; -}) { - const { t, i18n } = useTranslation("metrics"); - const { t: tCommon } = useTranslation("common"); - const locale = i18n.resolvedLanguage; - const { navigate } = useConsoleNavigation(); - const shown = useLast(node); - const sessionIds = useMemo(() => new Set(rows.filter((row) => row.node?.id === node?.id).map((row) => row.observation.session_id)), [node?.id, rows]); - const snapshot = useMemo( - () => (load && node && sessionIds.size ? runtimeSnapshotWhere(load, (observation) => sessionIds.has(observation.session_id)) : null), - [load, node, sessionIds], - ); - const history = useRuntimeHistory(snapshot, range); - // The machine itself: its last heartbeat and host history over the page's range. - const detail = useQuery({ - ...nodeDetailQuery(shown?.id ?? "", range), - 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 ( - navigate("nodes", { id: shown.id })}>{t("sandbox.nodeDialog.openNode")} : undefined} - > - {shown ? ( -
- {stale ?

{t("sandbox.nodeObservationStale")}

: null} -
-
{t("sandbox.status")}
-
{t("sandbox.targetPreparation")}
-
{t("sandbox.servingGeneration")}
{shown.rollout.ready_generation ?? MISSING}{t("sandbox.servingGenerationHelp")}
-
{t("sandbox.slots")}
{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}
- {suspends ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} - {suspends ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null} -
{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) })}} - help={t("sandbox.nodeDialog.runtimesHelp")} - > - {!sessionIds.size ? - : history.data ? - : history.isError ?

{t("sandbox.charts.historyFailed", { reason: historyFailure(history.error, t, tCommon) })}

- : history.isFetched ?

{t("sandbox.charts.historyUnavailable")}

- : } -
-
- ) : null} -
- ); -} - /** A hosted sandbox in a dialog: where it runs, and its Session's Runtime state and history. */ function RuntimeDialog({ row, showNode, showCompute, onClose }: { row: HostedRuntimeRow | null; showNode: boolean; showCompute: boolean; onClose: () => void }) { const { t, i18n } = useTranslation("metrics"); diff --git a/apps/web/src/features/overview/FleetOverview.render.test.tsx b/apps/web/src/features/overview/FleetOverview.render.test.tsx index 7604690bc..381827a9d 100644 --- a/apps/web/src/features/overview/FleetOverview.render.test.tsx +++ b/apps/web/src/features/overview/FleetOverview.render.test.tsx @@ -4,26 +4,21 @@ import { describe, expect, it } from "vitest"; import { FleetOverview } from "./FleetOverview"; import { node } from "./test-fixtures"; -describe("fleet overview popovers", () => { - it("opens Core and each node in a popover rather than navigating", () => { +describe("fleet overview", () => { + it("lists Core and each node as a row that opens its page", () => { const noop = () => undefined; const html = renderToStaticMarkup( , ); - const triggers = html.match(/]*aria-haspopup="dialog"[^>]*>/g) ?? []; - expect(triggers).toHaveLength(3); - expect(triggers.every((trigger) => trigger.includes('aria-expanded="false"'))).toBe(true); + expect(html.match(/]*class="fleet-row/g)).toHaveLength(3); + expect(html).not.toContain("aria-haspopup"); expect(html).toContain("worker-a"); - // The glance mounts only once opened. - expect(html).not.toContain("console-popover-facts"); }); }); diff --git a/apps/web/src/features/overview/FleetOverview.tsx b/apps/web/src/features/overview/FleetOverview.tsx index 310a8a2cc..3698fde0d 100644 --- a/apps/web/src/features/overview/FleetOverview.tsx +++ b/apps/web/src/features/overview/FleetOverview.tsx @@ -1,17 +1,10 @@ import type { SandboxNode } from "@oac/agents-client"; -import { useQuery } from "@tanstack/react-query"; import { ChevronRight, Cloud, Network, Server } from "lucide-react"; -import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { ConsolePopover } from "../../components/console-popover"; -import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; -import { epochSeconds, 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 { NodeRolloutStatus } from "../sandbox/NodeRolloutStatus"; +import { StatusDot, type Tone } from "../../components/console-ui"; +import { formatInteger } from "../../lib/format"; +import { nodeHealth, type NodeHealth } from "../fleet/fleet-model"; /** Nodes shown in the overview; the rest are counted and listed on the Nodes page. */ export const FLEET_LIMIT = 4; @@ -36,66 +29,33 @@ export interface CloudHost { template: string | null; } -/** A compact fleet inventory with on-demand operational details. */ -export function FleetOverview({ nodes, cloud, suspends, coreLabel, coreTone, stale, onOpenNode, onOpenBackend, onOpenSandboxMetrics, onOpenCoreMetrics }: { +/** A compact fleet inventory: Core, then E2B's cloud or the nodes; each row opens its page. */ +export function FleetOverview({ nodes, cloud, coreLabel, coreTone, onOpenNode, onOpenCloud, onOpenCore }: { nodes: readonly SandboxNode[]; /** A direct deployment: Core links to the Provider's cloud instead of to machines. */ cloud?: CloudHost | null; - /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ - suspends: boolean; - onOpenBackend?: () => void; coreLabel: string; coreTone: Tone; - /** Older generation or failed refresh: retain observations without implying live traffic. */ - stale: boolean; onOpenNode: (node: SandboxNode) => void; - onOpenSandboxMetrics: () => void; - onOpenCoreMetrics: () => void; + onOpenCloud: () => void; + onOpenCore: () => void; }) { const { t, i18n } = useTranslation("overview"); const locale = i18n.resolvedLanguage; const shown = cloud ? [] : overviewNodes(nodes); return (
- - + {cloud ? ( - - + ) : null} {shown.map((node) => { const health = nodeHealth(node); @@ -103,97 +63,14 @@ export function FleetOverview({ nodes, cloud, suspends, coreLabel, coreTone, sta const state = t(`nodeHealth.${health}`); const slots = `${formatInteger(node.active, locale)} / ${formatInteger(node.max_active, locale)}`; return ( - - + ); })}
); } - -function Facts({ children }: { children: ReactNode }) { - return
{children}
; -} - -function Fact({ label, children }: { label: string; children: ReactNode }) { - return
{label}
{children}
; -} - -/** 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, stale, suspends }: { node: SandboxNode; health: NodeHealth; stale: boolean; suspends: boolean }) { - const { t, i18n } = useTranslation("overview"); - const locale = i18n.resolvedLanguage; - const now = Math.floor(Date.now() / 1000); - const seen = epochSeconds(node.last_seen_at); - const count = (value: number) => formatInteger(value, locale); - const diagnostic = health === "degraded" ? nodeProviderDiagnostic(node) : ""; - return ( - - - - - {diagnostic ? : null} - - - - {node.rollout.ready_generation ?? MISSING}{t("fleet.servingGenerationHelp")} - {seen === null ? t("fleet.facts.never") : formatRelative(seen, now, locale)} - {count(node.active)}/ {count(node.max_active)} - {suspends ? {count(suspendedSandboxes(node))} : null} - {node.cpu_count === null ? MISSING : t("fleet.facts.cores", { count: node.cpu_count })} - {formatBytes(node.available_memory_bytes)} - {formatBytes(node.available_disk_bytes)} - - ); -} - -/** - * Core at a glance: its status, and — once Core reports its own metrics — its - * uptime, execution slots, Turn queue, connected daemons and database latency. - */ -function CoreGlance({ label, tone }: { label: string; tone: Tone }) { - const { t, i18n } = useTranslation("overview"); - const locale = i18n.resolvedLanguage; - const query = useQuery({ ...coreMetricsQuery("1h"), retry: false }); - const metrics = query.data ?? null; - const count = (value: number | null) => (value === null ? MISSING : formatInteger(value, locale)); - const started = epochSeconds(metrics?.service.started_at ?? null); - const now = Math.floor(Date.now() / 1000); - return ( - - - {metrics ? <> - {started === null ? MISSING : formatDuration(Math.max(0, now - started))} - - {count(metrics.execution.slots_in_use)} - / {count(metrics.execution.slots_total)} - - {count(metrics.execution.queued_turns)} - {count(metrics.execution.connected_daemons)} - {metrics.database.ping_ms.p95 === null ? MISSING : formatDuration(metrics.database.ping_ms.p95 / 1000)} - : null} - - ); -} diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx index d2f940263..c13e390cf 100644 --- a/apps/web/src/features/overview/GettingStarted.tsx +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -123,7 +123,7 @@ export function GettingStarted({ fleet, sessions, localOnly, sandboxReset, onRet : sandbox.action === "setup" ? { label: t("gettingStarted.sandboxes.setup"), run: () => navigate("settings", { id: "sandbox" }) } : sandbox.action === "add-node" - ? { label: t("gettingStarted.sandboxes.addNode"), run: () => navigate("settings", { id: "sandbox" }, "add-node") } + ? { label: t("gettingStarted.sandboxes.addNode"), run: () => navigate("nodes", {}, "add-node") } : { label: t(sandbox.cloud ? "gettingStarted.sandboxes.backend" : "gettingStarted.sandboxes.nodes"), run: () => sandbox.cloud ? navigate("settings", { id: "sandbox" }) : navigate("nodes") }; const keyProject = steps.key.project; const keyAction = keyProject diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 1ff44edad..5048e1881 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -27,7 +27,7 @@ import { formatClock, formatCompact, formatInteger, formatPercent, formatRelativ import { ProjectName, useProjects } from "../../lib/projects"; import { sessionTitle } from "../../lib/session-title"; import { capacitySummary, coreStatus, type CoreStatus } from "../fleet/fleet-model"; -import { FleetReadNotice, fleetObservationStale } from "../fleet/FleetReadNotice"; +import { FleetReadNotice } from "../fleet/FleetReadNotice"; import { SandboxResetNotice } from "../fleet/SandboxResetNotice"; import { fleetSnapshot, useSandboxFleet, type FleetSnapshot, type FleetState } from "../fleet/use-sandbox-fleet"; import { type InProject } from "../metrics/project-sessions"; @@ -335,7 +335,7 @@ function cloudHost(fleet: FleetSnapshot | null): CloudHost | null { return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null }; } -/** Core and its sandbox nodes (or E2B's cloud) as an inventory; each opens a popover with the way onward. */ +/** Core and its sandbox nodes (or E2B's cloud) as an inventory; each row opens its page. */ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; core: CoreStatus; localOnly: boolean }) { const { t } = useTranslation("overview"); const { navigate } = useConsoleNavigation(); @@ -356,21 +356,18 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co ? : cloud || hosts.length ? - : + : ) : null}
navigate("settings", { id: "sandbox" })} coreLabel={t(`coreStatus.${core}`)} coreTone={coreTone[core]} - stale={fleetObservationStale(fleetState)} onOpenNode={(node) => navigate("nodes", { id: node.id })} - onOpenSandboxMetrics={() => navigate("sandbox-metrics")} - onOpenCoreMetrics={() => navigate("core-metrics")} + onOpenCloud={() => navigate("sandbox-metrics")} + onOpenCore={() => navigate("core-metrics")} /> {hidden ? : null} diff --git a/apps/web/src/features/overview/overview.css b/apps/web/src/features/overview/overview.css index c013c0b3a..86b6cafea 100644 --- a/apps/web/src/features/overview/overview.css +++ b/apps/web/src/features/overview/overview.css @@ -263,7 +263,7 @@ border-top: 1px solid var(--line-soft); cursor: pointer; } -.fleet-row:hover, .fleet-row[data-popup-open] { background: var(--hover); } +.fleet-row:hover { background: var(--hover); } .fleet-row > svg { flex-shrink: 0; } .fleet-row-info { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; } .fleet-row-info strong { overflow: hidden; color: var(--ink); font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index ca2f6331b..d88debfbd 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -9,6 +9,7 @@ import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "../../lib/sand import { sandboxStateLabel } from "../../lib/sandbox-labels"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { NodeRolloutStatus } from "./NodeRolloutStatus"; +import { NodeHost } from "./NodeHost"; import { phaseTiming } from "./allocation-phase"; import { nodeState, NodeStatus, OldAddressHint } from "./NodeList"; @@ -77,18 +78,20 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale
{t("Added")}
{formatDateTime(epochSeconds(node.created_at), locale)}
- {/* Cleanup and host resources are on Sandbox metrics; the node's limit is here as well, beside Edit node that sets it. */}
{suspends ? : null} - {suspends ? : null} + {suspends ? : null} {suspends ? : null} + 0 ? "warning" : undefined} value={count(node.cleanup_pending)} />
+ +
{t("Allocations")}{own.length}} diff --git a/apps/web/src/features/sandbox/NodeHost.tsx b/apps/web/src/features/sandbox/NodeHost.tsx new file mode 100644 index 000000000..1b5599d25 --- /dev/null +++ b/apps/web/src/features/sandbox/NodeHost.tsx @@ -0,0 +1,111 @@ +import type { SandboxNode, SandboxNodeDetail, SandboxNodeHistoryRange } from "@oac/agents-client"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { TimeSeriesChart } from "../../components/charts/TimeSeriesChart"; +import { Section, SegmentedControl } from "../../components/console-ui"; +import { TableSkeleton } from "../../components/Skeleton"; +import { formatBucket, formatBytes, formatInteger, MISSING } from "../../lib/format"; +import { nodeDetailQuery } from "../fleet/fleet-queries"; + +const GIB = 1024 ** 3; +const RANGES: readonly SandboxNodeHistoryRange[] = ["1h", "6h", "24h"]; +const RANGE_LABELS = { "1h": "1 hour", "6h": "6 hours", "24h": "24 hours" } as const; + +/** A share (0–1) as a percentage that never rounds a small load down to "0%". */ +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)}%`; +} + +/** + * The node's machine: CPU, memory and free disk from its last heartbeat, and + * its host history over a range, read while the node page is open. + */ +export function NodeHost({ node, reporting }: { node: SandboxNode; reporting: boolean }) { + const { t, i18n } = useTranslation("sandbox"); + const locale = i18n.resolvedLanguage; + const [range, setRange] = useState("1h"); + const detail = useQuery({ ...nodeDetailQuery(node.id, range), placeholderData: keepPreviousData, refetchInterval: 30_000, refetchIntervalInBackground: false }); + const current = detail.data?.id === node.id ? detail.data : null; + const host = current?.host ?? null; + const cores = host?.effective_cpu_cores ?? node.cpu_count; + const coresLabel = cores === null ? null : formatInteger(cores, locale); + const cpu = !reporting || coresLabel === null ? MISSING + : host?.cpu_utilization != null ? t("{{percent}} of {{cores}} CPU", { percent: formatShare(host.cpu_utilization, locale), cores: coresLabel }) : t("{{cores}} CPU", { cores: coresLabel }); + const memory = !reporting ? MISSING + : host?.total_memory_bytes != null && host.available_memory_bytes != null + ? t("{{used}} of {{total}} used", { used: formatBytes(host.total_memory_bytes - host.available_memory_bytes), total: formatBytes(host.total_memory_bytes) }) + : formatBytes(node.available_memory_bytes); + return ( +
({ value, label: t(RANGE_LABELS[value]) }))} onChange={setRange} />} + > +
+
{t("CPU")}
{cpu}
+
{t("Memory")}
{memory}
+
{t("Free disk")}
{reporting ? formatBytes(node.available_disk_bytes) : MISSING}
+
+ {current ? + : detail.isError ?

{t("Host history could not be read. {{reason}}", { reason: detail.error instanceof Error ? detail.error.message : "" })}

+ : } +
+ ); +} + +/** + * 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. + */ +function NodeHostCharts({ detail, height = 150 }: { detail: SandboxNodeDetail; height?: number }) { + const { t, i18n } = useTranslation("sandbox"); + 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("Host CPU / {{bucket}}", { bucket })}
+ +
+
+
{t("Host memory / {{bucket}}", { bucket })}
+ formatBytes(value * GIB)} + formatAxis={(value) => `${value.toFixed(value < 10 ? 1 : 0)} GiB`} + height={height} + /> +
+
+ ); +} diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 90ba85e9d..6b551c767 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -81,7 +81,7 @@ export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspend {t("Status")} {t("Active / limit")} {suspends ? {t("Suspended")}{t("Core records these sandboxes as suspended. Their next Turn requests a resume when compatible capacity is available. They count toward the retained limit, not the active one.")} : null} - {suspends ? {t("Retained / limit")} : null} + {suspends ? {t("Retained / limit")}{t("The most sandboxes this node keeps, running and suspended together. A suspended one resumes here on its next Turn.")} : null} {t("Last seen")} {t("Added")} {t("Actions")} diff --git a/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx b/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx deleted file mode 100644 index 1511ab880..000000000 --- a/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useCallback, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; -import { Section } from "../../components/console-ui"; -import { useToast } from "../../components/Toast"; -import { sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; -import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries"; -import { useSandboxPageState } from "./use-sandbox-page-state"; -import { NodeEnrollment } from "./NodeEnrollment"; -import { NodeEditDialog } from "./NodeEditDialog"; -import { SuspensionSettings } from "./SandboxLifecycle"; -import { sandboxSize } from "./deployment-specification"; -import { writeSandboxDeployment } from "./sandbox-deployment-write"; - -export function SandboxCapacitySettings() { - const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; - const configQuery = useQuery(sandboxConsoleConfigQuery); - const consoleConfig = configQuery.data; - const { deploymentQuery, query, compatible, inventoryOlder, inventoryLoading, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh } = useSandboxPageState({ poll: true }); - const queryClient = useQueryClient(); - const toast = useToast(); - const client = sandboxAdmin; - const { params, navigate } = useConsoleNavigation(); - const [adding, setAdding] = useState(false); - const { t: tCommon } = useTranslation("common"); - const localOnly = installation.data?.local_only === true; - const nodesConfirmed = confirmed && compatible && !inventoryOlder && !query.isError && !snapshot?.nodesError; - const hostedNodes = snapshot?.deployment.mode === "nodes"; - const suspends = Boolean(snapshot?.deployment.suspension); - const editTarget = params.node ? snapshot?.nodes.find((node) => node.id === params.node) ?? null : null; - const closeEdit = () => navigate("settings", { id: "sandbox" }); - - const { refetch: refetchInventory } = query; - const refreshNodes = useCallback(async () => { - const result = await refetchInventory(); - return result.isSuccess && !result.data.nodesError ? result.data.nodes : null; - }, [refetchInventory]); - // Getting started asks for Add node on arrival. A request the first settled read cannot - // serve (no own-machines deployment, active reset, a failed read) is dropped, so the - // dialog never opens later on its own. - const addNodeReadiness = loading || busy || installation.isPending || inventoryLoading ? "wait" - : !snapshot ? (deploymentQuery.isError ? "unavailable" : "wait") - : hostedNodes && fresh && nodesConfirmed && !snapshot.deployment.reset && !localOnly ? "ready" : "unavailable"; - useConsoleIntent("add-node", addNodeReadiness, () => setAdding(true)); - // Enrollment and capacity editing have one owner in Settings. - const enrollment = hostedNodes && snapshot && consoleConfig ? setAdding(false)} onRefresh={refreshNodes} /> : null; - - const editDialog = { - const saved = editTarget; - closeEdit(); - toast.show(t("Node saved"), { tone: "success" }); - refresh(); - // Other pages read the fleet and the node's detail separately. - void queryClient.invalidateQueries({ queryKey: ["sandbox-fleet"] }); - if (saved) void queryClient.invalidateQueries({ queryKey: ["sandbox-node", saved.id] }); - }} - />; - - async function saveIdle(seconds: number) { - if (busy || loading || !fresh || setupNeedsRefresh) return false; - try { - return await writeSandboxDeployment(queryClient, (signal) => client.updateSuspension({ idle_seconds: seconds }, { signal })) !== null; - } catch (error) { - toast.show(t(sandboxWriteUncertain(error) ? "Couldn't confirm the sandbox change" : "Core rejected the sandbox change"), { tone: "error", detail: sandboxRequestError(error, locale), key: "sandbox-write" }); - return false; - } - } - - const disabled = busy || loading || !fresh || !nodesConfirmed || setupNeedsRefresh || Boolean(snapshot?.deployment.reset); - if (!snapshot?.deployment.provider) return null; - const nodes = snapshot.nodes; - return <> - - {hostedNodes ?
setAdding(true)}>{t("Add node")}}> - {configQuery.isError ?

{t("The console configuration could not be read. Refresh to try again.")}

: null} - {snapshot.nodesError ?

{sandboxRequestError(snapshot.nodesError, locale)}

: null} - {localOnly ?

{tCommon("installationNotice.addBlocked")}

: null} - {nodes.some((node) => node.active > node.max_active || node.retained > node.max_retained) ?

{t("Current usage exceeds a saved limit. Existing sandboxes are preserved; new placements wait for capacity.")}

: null} -
{suspends ? : null} - {nodes.map((node) => {suspends ? : null})} -
{t("Node")}{t("Active / limit")}{t("Retained / limit")}{t("Actions")}
{node.name || node.id}{node.active} / {node.max_active}{node.retained} / {node.max_retained}
-
: null} - {enrollment}{editDialog} - ; -} diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index f595621a5..31bf31443 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -19,7 +19,7 @@ import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; import { SandboxResetControls } from "./SandboxResetControls"; import "./SandboxManagerView.css"; import { SettingsTabs } from "../settings/SettingsTabs"; -import { SandboxCapacitySettings } from "./SandboxCapacitySettings"; +import { SuspensionSettings } from "./SuspensionSettings"; function DeploymentHeader({ actions }: { actions?: ReactNode }) { const { t } = useTranslation("sandboxNavigation"); @@ -32,7 +32,7 @@ function DeploymentHeader({ actions }: { actions?: ReactNode }) { ; } -/** Settings owns deployment changes, lifecycle policy and node capacity. */ +/** Settings owns deployment changes and the idle policy; Nodes owns the nodes and their capacity. */ export function SandboxDeploymentPage() { const { i18n } = useTranslation("sandbox"); return
@@ -81,7 +81,7 @@ function DeploymentConfiguration() { return false; } async function initialize(input: InitializeSandboxDeployment) { - if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && deploymentContract.providers[input.provider].mode === "nodes" && !installation.data?.local_only) navigate("settings", { id: "sandbox" }, "add-node"); + if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && deploymentContract.providers[input.provider].mode === "nodes" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); } async function update(input: UpdateSandboxDeployment) { return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true, true); @@ -97,7 +97,7 @@ function DeploymentConfiguration() { const reset = configured ? : null; const sections = [ configured ? : null, - , + , ]; return <> diff --git a/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx b/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx deleted file mode 100644 index 68f5bdac4..000000000 --- a/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { SandboxDeployment } from "@oac/agents-client"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { node } from "../overview/test-fixtures"; -import { SandboxLifecycle, SuspensionSettings } from "./SandboxLifecycle"; - -const deployment = { suspension: { idle_seconds: 300, retention_seconds: 86400 } } as SandboxDeployment; -describe("sandbox lifecycle evidence", () => { - it("uses online slot accounting while retaining suspended records from offline nodes", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("2 / 4"); - expect(html).toContain("5 / 8"); - expect(html).toContain("5 minutes"); - expect(html).not.toContain("3 / 14"); - }); - it("offers policy editing only in the settings owner, without repeating the evidence strip", () => { - const nodes = [node("online", { active: 2, max_active: 4, retained: 5, max_retained: 8 })]; - const html = renderToStaticMarkup( true} />); - expect(html).toContain("Capacity and suspension"); - expect(html).toContain('id="sandbox-idle-seconds"'); - expect(html).toContain('value="300"'); - expect(html).toContain("24 hours"); - expect(html).not.toContain("kpi-strip"); - const readonly = renderToStaticMarkup(); - expect(readonly).not.toContain("sandbox-idle-seconds"); - expect(readonly).not.toContain("Edit node"); - expect(renderToStaticMarkup( true} />)).toBe(""); - }); - it("keeps node inventory visible when suspension is unsupported", () => { - const html = renderToStaticMarkup(
node-inventory
); - expect(html).toContain("node-inventory"); - expect(html).not.toContain("sandbox-idle-seconds"); - }); - it("does not replace missing inventory with zero or claim fresh evidence after failure", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("current state is unconfirmed"); - expect(html).toContain("—"); - expect(html).not.toContain("0 / 0"); - expect(renderToStaticMarkup()).toBe(""); - }); -}); diff --git a/apps/web/src/features/sandbox/SandboxLifecycle.tsx b/apps/web/src/features/sandbox/SandboxLifecycle.tsx deleted file mode 100644 index ef3827062..000000000 --- a/apps/web/src/features/sandbox/SandboxLifecycle.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useState, type ReactNode } from "react"; -import type { SandboxDeployment, SandboxNode } from "@oac/agents-client"; -import { useTranslation } from "react-i18next"; -import { HelpTip, Kpi, KpiStrip, Section } from "../../components/console-ui"; -import { formatInteger, formatSpan, MISSING } from "../../lib/format"; -import { capacitySummary } from "../fleet/fleet-model"; -import { Fact } from "../system/Fact"; - -function RetentionHelp({ deployment }: { deployment: SandboxDeployment }) { - const { t } = useTranslation("sandbox"); - return <>{t("Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.")}{deployment.specification?.workspace ? <> {t("Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.")} : null}; -} - -/** Core accounting and the declared policy, separate from host CPU and memory observations. */ -export function SandboxLifecycle({ deployment, nodes, stale, children }: { deployment: SandboxDeployment; nodes: readonly SandboxNode[] | null; stale: boolean; children?: ReactNode }) { - const { t, i18n } = useTranslation("sandbox"); - const policy = deployment.suspension; - if (!policy) return <>{children}; - const count = (value: number) => formatInteger(value, i18n.resolvedLanguage); - const capacity = nodes ? capacitySummary(nodes) : null; - return
- {stale ?

{t("Showing the last loaded lifecycle counts; current state is unconfirmed.")}

: null} - - - - - - } /> - - {children} -
; -} - -/** Settings edits the deployment-wide idle time; snapshot retention is Core's read-only policy. */ -export function SuspensionSettings({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (seconds: number) => Promise }) { - const { t, i18n } = useTranslation("sandbox"); - const policy = deployment.suspension; - const [draft, setDraft] = useState(null); - if (!policy) return null; - const idle = draft ?? String(policy.idle_seconds); - const valid = /^\d+$/.test(idle) && Number(idle) >= 30 && Number(idle) <= 86400; - return
-
-
{ event.preventDefault(); if (valid && !disabled && await onSave(Number(idle))) setDraft(null); }}> -
{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.")}
-
- setDraft(event.target.value)} aria-invalid={!valid} aria-describedby={!valid ? "sandbox-idle-error" : undefined} /> - - {!valid ? {t("Enter a whole number from 30 to 86,400.")} : null} -
-
- }>{formatSpan(policy.retention_seconds, i18n.resolvedLanguage)} -
-
; -} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index d9c4e88f6..bcf272326 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { type SandboxNode } from "@oac/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Plus, Server, Trash2 } from "lucide-react"; @@ -7,7 +7,7 @@ import { ConfirmDialog } from "../../components/ConfirmDialog"; import { EmptyState, HelpTip, RefreshButton } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { useFailureToast, useToast } from "../../components/Toast"; -import { useConsoleNavigation } from "../../lib/console-navigation"; +import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; import { sandboxRequestError } from "../../lib/sandbox-labels"; @@ -15,12 +15,14 @@ import type { SandboxConsoleConfig } from "./console-config"; import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; import { NodeList, onOldAddress } from "./NodeList"; -import { SandboxLifecycle } from "./SandboxLifecycle"; import { NodeDetail } from "./NodeDetail"; +import { NodeEnrollment } from "./NodeEnrollment"; +import { NodeEditDialog } from "./NodeEditDialog"; +import { sandboxSize } from "./deployment-specification"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; import "./SandboxManagerView.css"; -/** Nodes owns fleet observations, node details and removal; configuration lives in Settings. */ +/** Nodes owns the node list, each node's capacity and host figures, Add node, Edit node and removal; deployment configuration lives in Settings. */ export function SandboxManagerView() { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; @@ -55,7 +57,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const { params, navigate, back: goBack } = useConsoleNavigation(); const client = sandboxAdmin; const queryClient = useQueryClient(); - const { deploymentQuery, query, compatible, inventoryOlder, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refetch, refresh } = useSandboxPageState({ poll: true }); + const { deploymentQuery, query, compatible, inventoryOlder, inventoryLoading, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refetch, refresh } = useSandboxPageState({ poll: true }); const { t: tSandboxNav } = useTranslation("sandboxNavigation"); const localOnly = installation.data?.local_only === true; const { t: tCommon } = useTranslation("common"); @@ -63,6 +65,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const [removeTarget, setRemoveTarget] = useState(null); const [removing, setRemoving] = useState(false); const [removeError, setRemoveError] = useState(null); + const [adding, setAdding] = useState(false); + const [editId, setEditId] = useState(null); // After a removal, the host's uninstall command; it stays for the closing animation. const [cleanup, setCleanup] = useState<{ node: NodeCleanup; open: boolean } | null>(null); // Where focus goes once a dialog about a node that is gone closes. @@ -117,6 +121,40 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig // A node enrolled with another address than Core's current one (config.json's public_url) gets no new sandboxes until it is added again. const staleNodes = hostedNodes && snapshot ? nodes.filter((node) => onOldAddress(node, snapshot.deployment.core_url)).map((node) => node.name || node.id) : []; const selected = params.id ? nodes.find((node) => node.id === params.id) : undefined; + const editTarget = editId ? nodes.find((node) => node.id === editId) ?? null : null; + const reset = Boolean(snapshot?.deployment.reset); + const addDisabled = busy || loading || !fresh || !nodesConfirmed || setupNeedsRefresh || reset || localOnly; + + const { refetch: refetchInventory } = query; + const refreshNodes = useCallback(async () => { + const result = await refetchInventory(); + return result.isSuccess && !result.data.nodesError ? result.data.nodes : null; + }, [refetchInventory]); + // Getting started and Overview ask for Add node on arrival. A request the first settled read + // cannot serve (no own-machines deployment, active reset, a failed read) is dropped, so the + // dialog never opens later on its own. + const addNodeReadiness = loading || busy || installation.isPending || inventoryLoading ? "wait" + : !snapshot ? (deploymentQuery.isError ? "unavailable" : "wait") + : hostedNodes && fresh && nodesConfirmed && !reset && !localOnly ? "ready" : "unavailable"; + useConsoleIntent("add-node", addNodeReadiness, () => setAdding(true)); + const enrollment = hostedNodes && snapshot ? setAdding(false)} onRefresh={refreshNodes} /> : null; + const editDialog = setEditId(null)} + onSaved={() => { + const saved = editTarget; + setEditId(null); + toast.show(t("Node saved"), { tone: "success" }); + refresh(); + // Overview and Sandbox metrics read the fleet, and this page the node's host detail, separately. + void queryClient.invalidateQueries({ queryKey: ["sandbox-fleet"] }); + if (saved) void queryClient.invalidateQueries({ queryKey: ["sandbox-node", saved.id] }); + }} + />; const refreshButton = ; const readFailure = error !== null ? sandboxRequestError(error, locale) : null; // A failed refresh keeps the last state on screen and says so in a toast, once while the failure lasts. @@ -159,7 +197,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig title={selected ? selected.name || selected.id : params.id} actions={<> {refreshButton} - {selected ? : null} + {selected ? : null} {selected ? : null} } /> @@ -171,6 +209,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{dialog} {cleanupDialog} + {editDialog} ; } @@ -178,7 +217,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {localOnly && hostedNodes ? {tCommon("installationNotice.addBlocked")} : null} {hostedNodes ? : null} {refreshButton} - {hostedNodes && snapshot ? : null} + {hostedNodes && snapshot ? : null} ; return <> @@ -190,14 +229,16 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {staleNodes.length ?

{staleNodes.length === 1 ? t("{{name}} is still bound to an old Core address. Remove it and add it again.", { name: staleNodes[0] }) : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: new Intl.ListFormat(i18n.resolvedLanguage, { type: "conjunction" }).format(staleNodes) })}

: null} - {hostedNodes ? 0) ? nodes : null} stale={!nodesConfirmed}> - {nodes.length - ? navigate("nodes", { id: node.id })} onEdit={(node) => navigate("settings", { id: "sandbox", node: node.id })} onRemove={askRemove} /> - : nodesConfirmed ? : null} - : null} + {hostedNodes && nodes.some((node) => node.active > node.max_active || node.retained > node.max_retained) ?

{t("Current usage exceeds a saved limit. Existing sandboxes are preserved; new placements wait for capacity.")}

: null} + {hostedNodes ? nodes.length + ? navigate("nodes", { id: node.id })} onEdit={(node) => setEditId(node.id)} onRemove={askRemove} /> + : nodesConfirmed ? : null + : null} : null} {dialog} {cleanupDialog} + {enrollment} + {editDialog} ; } diff --git a/apps/web/src/features/sandbox/SuspensionSettings.test.tsx b/apps/web/src/features/sandbox/SuspensionSettings.test.tsx new file mode 100644 index 000000000..db09a309f --- /dev/null +++ b/apps/web/src/features/sandbox/SuspensionSettings.test.tsx @@ -0,0 +1,16 @@ +import type { SandboxDeployment } from "@oac/agents-client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { SuspensionForm } from "./SuspensionSettings"; + +const deployment = { suspension: { idle_seconds: 300, retention_seconds: 86400 } } as SandboxDeployment; +describe("suspension settings", () => { + it("edits the idle time and shows retention", () => { + const html = renderToStaticMarkup( true} />); + expect(html).toContain("Automatic suspension"); + expect(html).toContain('id="sandbox-idle-seconds"'); + expect(html).toContain('value="300"'); + expect(html).toContain("24 hours"); + expect(renderToStaticMarkup( true} />)).toBe(""); + }); +}); diff --git a/apps/web/src/features/sandbox/SuspensionSettings.tsx b/apps/web/src/features/sandbox/SuspensionSettings.tsx new file mode 100644 index 000000000..8a81d32a7 --- /dev/null +++ b/apps/web/src/features/sandbox/SuspensionSettings.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { SandboxDeployment } from "@oac/agents-client"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { HelpTip, Section } from "../../components/console-ui"; +import { useToast } from "../../components/Toast"; +import { formatSpan } from "../../lib/format"; +import { sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; +import { Fact } from "../system/Fact"; +import { sandboxAdmin } from "./sandbox-queries"; +import { writeSandboxDeployment } from "./sandbox-deployment-write"; +import { useSandboxPageState } from "./use-sandbox-page-state"; + +function RetentionHelp({ deployment }: { deployment: SandboxDeployment }) { + const { t } = useTranslation("sandbox"); + return <>{t("Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.")}{deployment.specification?.workspace ? <> {t("Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.")} : null}; +} + +/** Settings edits the deployment-wide idle time; each node's limits are on Nodes. */ +export function SuspensionSettings() { + const { t, i18n } = useTranslation("sandbox"); + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; + const { snapshot, loading, busy, setupNeedsRefresh, fresh } = useSandboxPageState(); + const queryClient = useQueryClient(); + const toast = useToast(); + const disabled = busy || loading || !fresh || setupNeedsRefresh || Boolean(snapshot?.deployment.reset); + async function save(seconds: number) { + if (disabled) return false; + try { + return await writeSandboxDeployment(queryClient, (signal) => sandboxAdmin.updateSuspension({ idle_seconds: seconds }, { signal })) !== null; + } catch (error) { + toast.show(t(sandboxWriteUncertain(error) ? "Couldn't confirm the sandbox change" : "Core rejected the sandbox change"), { tone: "error", detail: sandboxRequestError(error, locale), key: "sandbox-write" }); + return false; + } + } + if (!snapshot?.deployment.provider) return null; + return ; +} + +/** The idle time form and Core's read-only snapshot retention. */ +export function SuspensionForm({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (seconds: number) => Promise }) { + const { t, i18n } = useTranslation("sandbox"); + const policy = deployment.suspension; + const [draft, setDraft] = useState(null); + if (!policy) return null; + const idle = draft ?? String(policy.idle_seconds); + const valid = /^\d+$/.test(idle) && Number(idle) >= 30 && Number(idle) <= 86400; + return
+
+
{ event.preventDefault(); if (valid && !disabled && await onSave(Number(idle))) setDraft(null); }}> +
{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.")}
+
+ setDraft(event.target.value)} aria-invalid={!valid} aria-describedby={!valid ? "sandbox-idle-error" : undefined} /> + + {!valid ? {t("Enter a whole number from 30 to 86,400.")} : null} +
+
+ }>{formatSpan(policy.retention_seconds, i18n.resolvedLanguage)} +
+
; +} diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index df5653eec..d9842bc34 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -42,13 +42,13 @@ describe("sandbox page ownership", () => { expect(configuration).not.toContain("node-enrollment"); }); - it("keeps Nodes as a node page for both cloud and host deployments", () => { + it("keeps Nodes as the node page, with Add node, for both cloud and host deployments", () => { const cloud = render(, cache("e2b")); expect(cloud).toContain("Nodes"); expect(cloud).toContain("There are no nodes to manage."); expect(cloud).not.toContain("Change resources"); const hosts = render(, cache("docker")); - expect(hosts).not.toContain("node-enrollment"); + expect(hosts).toContain("node-enrollment"); expect(hosts).toContain("Add your first node"); expect(hosts).not.toContain("Reset deployment"); }); @@ -59,6 +59,7 @@ describe("sandbox page ownership", () => { expect(configuration).toContain("Change resources"); expect(configuration).toContain("Reset deployment"); expect(configuration).not.toContain("setup-flow"); + expect(configuration).not.toContain("node-enrollment"); const system = render(, client); expect(system).toContain("Service and deployment"); expect(system).not.toContain("Change resources"); diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 27574c56e..f7ae6dbb9 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -106,7 +106,7 @@ export const metrics = { }, core: { title: "Core metrics", - description: "Health of the Core service process itself: execution concurrency and the Turn queue, connected daemons, the PostgreSQL database and background jobs. Agent calls and durations are on Agent metrics; sandbox capacity is on Sandbox metrics.", + description: "Health of the Core service process: whether it can run Turns, how long they wait and how many were interrupted, whether its PostgreSQL database responds, and its background jobs. Process internals are under Diagnostics. Agent calls and durations are on Agent metrics; node capacity is on Nodes.", loading: "Reading Core metrics…", failed: "Core metrics could not be read. {{reason}}", stale: "Refresh failed; showing the last metrics read. {{reason}}", @@ -121,20 +121,23 @@ export const metrics = { queued: "Queued Turns", queuedHelp: "Turns waiting to run: some for a free slot, some for their Session's daemon to connect.", waitingForDaemon: "{{n}} waiting for a daemon", - daemons: "Connected daemons", - daemonsHelp: "Execution daemons connected to Core, one per hosted or self-hosted environment. A Session without one cannot run.", - nodes: "Connected nodes", - nodesHelp: "Sandbox nodes connected to Core, of all registered nodes. A node that is offline cannot take new sandboxes; the Nodes page shows which.", - databaseLatency: "Database latency", + queueWait: "Queue wait p95", + queueWaitHelp: "How long Turns in the range waited from queueing to start, at the 95th percentile.", + interrupted: "Interrupted Turns", + interruptedHelp: "Turns in the range that failed because Core recovery interrupted their execution, for example after a restart.", + databaseLatency: "Database latency p95", + databaseLatencyHelp: "Round trip of Core's periodic database probe over the range, at the 95th percentile.", cpu: "CPU", cpuHelp: "CPU the Core process used over the last 30 seconds, in cores, against the CPU it may use (its container limit, or the CPUs it may run on).", cores: "{{value}} cores", memory: "Memory", memoryHelp: "Resident memory of the Core process against its container memory limit, when it has one.", + diagnostics: { + title: "Diagnostics", + meta: "Go heap {{heap}} · {{goroutines}} goroutines · pool {{inUse}} / {{max}}", + help: "Process internals for troubleshooting: CPU in cores and resident memory over time, each against its limit; the Go heap, the part of memory the runtime manages; and the database connection pool against its limit.", + }, process: { - title: "Process", - help: "The Core process itself: CPU in cores and resident memory, each against its limit. The Go heap is the part of memory the runtime manages.", - meta: "Go heap {{heap}} · {{goroutines}} goroutines", cpuChart: "CPU / {{bucket}}", cpu: "Used", cpuLimit: "Limit", @@ -145,7 +148,7 @@ export const metrics = { execution: { title: "Execution", help: "Core's scheduling: the worker takes queued Turns from the database and hands them to connected daemons. Queue wait runs from queueing to start.", - meta: "Oldest wait {{oldest}} · interrupted {{interrupted}} · refused for lack of slots {{unavailable}}", + meta: "Oldest wait {{oldest}} · {{daemons}} daemons connected · refused for lack of slots {{unavailable}}", turnsChart: "Turns / {{bucket}}", inProgress: "Running", queued: "Queued", @@ -154,8 +157,8 @@ export const metrics = { }, database: { title: "Database", - help: "Core's only storage: PostgreSQL (files are large objects in it). Latency comes from Core's periodic probe; the pool from the Core process.", - meta: "PostgreSQL · {{size}} · pool {{inUse}} / {{max}}", + help: "Core's only storage: PostgreSQL (files are large objects in it). Latency comes from Core's periodic probe.", + meta: "PostgreSQL · {{size}}", latencyChart: "Latency p95 / {{bucket}}", latency: "p95", poolChart: "Connection pool / {{bucket}}", @@ -176,56 +179,16 @@ export const metrics = { }, }, sandbox: { - nodeObservationStale: "These node facts are earlier observations. Refresh the fleet to confirm its current state.", - servingGeneration: "Serving generation", - servingGenerationHelp: "Core retains this generation for the node. The pin alone does not confirm a live connection, available capacity or readiness for the target.", - targetPreparation: "Target configuration", configurationGeneration: "Sandbox configuration generation", title: "Sandbox metrics", - description: "Host capacity of the whole deployment, and the hosted Runtimes of every project.", + description: "The hosted Runtimes of every project over time: their state, CPU and memory. Node capacity is on Nodes.", cloudDescription: "Sandboxes Core holds in E2B’s cloud, and hosted Runtime observations across projects.", - nodeDialog: { - facts: "Node figures", - 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.", - 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", - }, runtimeDialog: { openLabel: "Show sandbox of {{name}}", facts: "Sandbox", openSession: "Open Session", noSession: "The Session of this sandbox could not be read, so its history cannot be shown.", }, - fleetFailed: "Hosts could not be loaded.", - fleetLoading: "Loading hosts…", - kpiLabel: "Sandbox capacity", - nodesOnline: "Nodes online", - nodesAvailable: "Provider ready: {{count}}", - activeSandboxes: "Active sandboxes", - activeDetail: "Against the limit of online nodes", - retained: "Retained snapshots", - retainedDetail: "Suspended snapshots kept for resuming", - reserved: "Reserved", - reservedDetail: "Held for sandboxes that are starting", - cleanupPending: "Cleanup pending", - cleanupDetail: "Released but not yet removed", - freeMemory: "Free memory", - nodesSection: "Node capacity", - nodesSectionDetail: "Limits, usage and host resources each node reports with its heartbeat.", - manageNodes: "Manage nodes", cloud: { title: "E2B cloud", counts: "The running count comes from Core’s E2B allocations. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", @@ -242,40 +205,20 @@ export const metrics = { }, node: "Node", status: "Status", - slots: "Active / limit", - cpus: "CPU", - freeDiskColumn: "Free disk", - lastSeen: "Last heartbeat", - health: { - available: "Available", - degraded: "Provider not ready", - offline: "Offline", - }, - slotsOf: "Active sandboxes on {{name}}", - noNodesTitle: "No execution nodes", - noNodesDescription: "Hosted Sessions run on enrolled nodes. Add a node to provide sandbox capacity.", - addNode: "Add node", notSetUpTitle: "Sandboxes are not set up yet", setUp: "Set up sandboxes", runtimeSection: "Hosted Runtimes", runtimeSectionDetail: "CPU, memory and token history of Core-managed hosted sandboxes. Other environments report no Runtime figures.", noRuntimeTitle: "No hosted sandboxes", noRuntimeDescription: "No Session uses a Core-managed hosted environment.", - noRuntimeProject: "No Session of this project uses a Core-managed hosted environment.", runtimeLoading: "Loading Runtime observations…", runtimeUnavailable: "Runtime observations are unavailable. {{reason}}", runtimeStale: "Refresh failed; showing the previous observations. {{reason}}", runtimePartial: "Session details were not read for {{unread}} Runtimes (over the read limit) and could not be read for {{failed}}. Those rows show the Session ID only.", - runtimeKpiLabel: "Hosted Runtime usage", - runtimes: "Hosted sandboxes", - runtimeStates: "{{active}} active · {{sleeping}} sleeping", - runtimesDetail: "{{total}} in total · {{pending}} starting or changing state · {{observed}} with a current sample", cpu: "CPU", - cpuDetail: "Cores in use now against the cores of sampled sandboxes.", cores: "{{value}} cores", memory: "Memory", disk: "Disk", - memoryDetail: "Working set now against the limits of sampled sandboxes.", runtimeList: "Hosted Runtimes", search: "Search Runtimes", searchPlaceholder: "Search Session, Agent, node or ID", @@ -306,10 +249,8 @@ export const metrics = { historyBudget: "Charts cover at most {{limit}} Sessions.", historyMixed: "Sessions returned different bucket widths.", }, - runtimeMeta: "{{n}} · CPU {{cpu}} · memory {{memory}}", - runtimeMetaSuspended: "{{n}} · {{sleeping}} suspended · CPU {{cpu}} · memory {{memory}}", - suspended: "Suspended", - suspendedHelp: "Core-recorded suspended placements. Their next Turn requests a resume when compatible capacity is available; they count toward the retained limit, not the active one.", + runtimeMeta: "{{running}} of {{n}} running · CPU {{cpu}} · memory {{memory}}", + runtimeMetaSuspended: "{{running}} of {{n}} running · {{sleeping}} suspended · CPU {{cpu}} · memory {{memory}}", reason: { allocation_pending: "Allocation pending", runtime_not_running: "Not running", diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index ddac3f0f8..4ff49d88d 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -115,34 +115,12 @@ export const overview = { offline: "Offline", }, fleet: { - servingGeneration: "Serving generation", - servingGenerationHelp: "Core retains this generation for the node. The pin alone does not confirm a live connection, available capacity or readiness for the target.", previousGeneration: "Inventory was read for configuration generation {{observed}}; the current target is {{target}}. These are earlier observations.", - targetPreparation: "Target configuration", title: "Fleet", - cloudHelp: "Core and the sandboxes it holds in E2B’s cloud. Select Core or E2B for a summary.", - help: "Core and sandbox nodes, with availability and active sandboxes / limit. Select a row for details. When the list is full, offline and degraded nodes appear first.", + cloudHelp: "Core and the sandboxes it holds in E2B’s cloud. Select Core for Core metrics or E2B for Sandbox metrics.", + help: "Core and sandbox nodes, with availability and active sandboxes / limit. Select Core for Core metrics or a node for its page on Nodes. When the list is full, offline and degraded nodes appear first.", core: "Core", open: "{{name}}, {{state}}, sandboxes {{slots}}", - openNode: "Node details", - openSandboxMetrics: "Sandbox metrics", - openCoreMetrics: "Core metrics", - facts: { - status: "Status", - lastSeen: "Last seen", - never: "Never", - active: "Active sandboxes", - suspended: "Suspended sandboxes", - cpu: "CPU", - cores: "{{count}} cores", cores_one: "{{count}} core", cores_other: "{{count}} cores", - memory: "Available memory", - disk: "Available disk", - uptime: "Uptime", - slots: "Execution concurrency", - queued: "Queued Turns", - daemons: "Connected daemons", - database: "Database latency p95", - }, more: "{{count}} more nodes on the Nodes page", more_one: "{{count}} more node on the Nodes page", more_other: "{{count}} more nodes on the Nodes page", @@ -158,9 +136,6 @@ export const overview = { name: "E2B cloud", open: "E2B cloud, {{running}} running sandboxes", running: "{{count}} running", - runningLabel: "Running sandboxes", - pending: "Starting or changing", - template: "Template build", openBackend: "Sandbox backend", }, }, diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 8b47d06d0..3e1bceb51 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -106,7 +106,7 @@ export const metrics = { }, core: { title: "Core 监控", - description: "Core 服务进程自身的健康:执行并发和 Turn 队列、已连接的 daemon、PostgreSQL 数据库和后台任务。Agent 的调用和耗时见 Agent 监控,沙箱容量见沙箱监控。", + description: "Core 服务进程自身的健康:能否执行 Turn、排队多久、有多少被中断,PostgreSQL 数据库是否响应,以及后台任务。进程内部指标在“诊断”中。Agent 的调用和耗时见 Agent 监控,节点容量见节点页。", loading: "正在读取 Core 指标…", failed: "无法读取 Core 指标。{{reason}}", stale: "刷新失败,显示的是上次读取的指标。{{reason}}", @@ -121,20 +121,23 @@ export const metrics = { queued: "排队 Turn", queuedHelp: "等待执行的 Turn。一部分在等空闲槽位,一部分在等所属 Session 的 daemon 连上。", waitingForDaemon: "{{n}} 个等 daemon", - daemons: "已连接 daemon", - daemonsHelp: "当前连到 Core 的执行 daemon(每个托管或自托管环境一个)。没有 daemon 的 Session 无法执行。", - nodes: "已连接节点", - nodesHelp: "当前连到 Core 的沙箱节点,对比已注册的全部节点。离线的节点不能接新的沙箱,具体是哪台见节点页。", - databaseLatency: "数据库延迟", + queueWait: "排队等待 p95", + queueWaitHelp: "所选时间范围内 Turn 从入队到开始执行的等待时间,取第 95 百分位。", + interrupted: "中断的 Turn", + interruptedHelp: "所选时间范围内因 Core 恢复中断执行而失败的 Turn,例如 Core 重启之后。", + databaseLatency: "数据库延迟 p95", + databaseLatencyHelp: "所选时间范围内 Core 定期探测数据库的往返时间,取第 95 百分位。", cpu: "CPU", cpuHelp: "Core 进程最近 30 秒用掉的 CPU(单位:核),对比它可用的 CPU(容器限额,或它能使用的 CPU 数)。", cores: "{{value}} 核", memory: "内存", memoryHelp: "Core 进程的常驻内存,对比容器内存限额(如有)。", + diagnostics: { + title: "诊断", + meta: "Go 堆 {{heap}} · {{goroutines}} 个 goroutine · 连接池 {{inUse}} / {{max}}", + help: "用于排查问题的进程内部指标:以核计的 CPU 和常驻内存随时间的变化,各自对比限额;Go 堆,即由运行时管理的那部分内存;以及数据库连接池对比上限。", + }, process: { - title: "进程", - help: "Core 进程本身:以核计的 CPU 和常驻内存,各自对比限额。Go 堆是其中由运行时管理的那部分内存。", - meta: "Go 堆 {{heap}} · {{goroutines}} 个 goroutine", cpuChart: "CPU / {{bucket}}", cpu: "已用", cpuLimit: "上限", @@ -145,7 +148,7 @@ export const metrics = { execution: { title: "执行", help: "Core 的执行调度:worker 从数据库里的排队 Turn 中取出任务,交给已连接的 daemon。排队等待从入队算到开始执行。", - meta: "最久等待 {{oldest}} · 中断 {{interrupted}} · 槽位不足拒绝 {{unavailable}}", + meta: "最久等待 {{oldest}} · 已连接 {{daemons}} 个 daemon · 槽位不足拒绝 {{unavailable}}", turnsChart: "Turn / {{bucket}}", inProgress: "执行中", queued: "排队", @@ -154,8 +157,8 @@ export const metrics = { }, database: { title: "数据库", - help: "Core 唯一的存储:PostgreSQL(文件也以大对象存在其中)。延迟来自 Core 的定期探测,连接池来自 Core 进程。", - meta: "PostgreSQL · {{size}} · 连接池 {{inUse}} / {{max}}", + help: "Core 唯一的存储:PostgreSQL(文件也以大对象存在其中)。延迟来自 Core 的定期探测。", + meta: "PostgreSQL · {{size}}", latencyChart: "延迟 p95 / {{bucket}}", latency: "p95", poolChart: "连接池 / {{bucket}}", @@ -176,56 +179,16 @@ export const metrics = { }, }, sandbox: { - nodeObservationStale: "这些节点数据是较早的观测,请刷新节点清单以确认当前状态。", - servingGeneration: "保留的服务代次", - servingGenerationHelp: "Core 为该节点保留的配置代次。仅凭此记录不能确认当前连接、可用容量或目标配置就绪。", - targetPreparation: "目标配置", configurationGeneration: "沙箱配置代次", title: "沙箱监控", - description: "整个部署的宿主机容量,以及所有项目的托管运行时。", + description: "所有项目的托管运行时随时间的状态、CPU 和内存。节点容量见节点页。", cloudDescription: "Core 在 E2B 云端持有的沙箱,以及各项目的托管 Runtime 观测。", - nodeDialog: { - facts: "节点指标", - openLabel: "查看 {{name}}", - retainedSlots: "保留 / 上限", - runtimes: "这个节点上的托管沙箱", - 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: "节点详情", - }, runtimeDialog: { openLabel: "查看 {{name}} 的沙箱", facts: "沙箱", openSession: "打开 Session", noSession: "无法读取这个沙箱所属的 Session,因此无法显示它的历史。", }, - fleetFailed: "无法加载宿主机。", - fleetLoading: "正在加载宿主机…", - kpiLabel: "沙箱容量", - nodesOnline: "节点在线", - nodesAvailable: "提供方就绪:{{count}}", - activeSandboxes: "活跃沙箱", - activeDetail: "相对于在线节点的上限", - retained: "保留快照", - retainedDetail: "为恢复而保留的挂起快照", - reserved: "已预留", - reservedDetail: "为启动中的沙箱保留", - cleanupPending: "待清理", - cleanupDetail: "已释放但尚未移除", - freeMemory: "可用内存", - nodesSection: "节点容量", - nodesSectionDetail: "各节点心跳上报的上限、用量与主机资源。", - manageNodes: "管理节点", cloud: { title: "E2B 云端", counts: "运行数量来自 Core 的 E2B 分配记录;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", @@ -242,40 +205,20 @@ export const metrics = { }, node: "节点", status: "状态", - slots: "活跃 / 上限", - cpus: "CPU", - freeDiskColumn: "可用磁盘", - lastSeen: "最近心跳", - health: { - available: "可用", - degraded: "提供方未就绪", - offline: "离线", - }, - slotsOf: "{{name}} 上的活跃沙箱", - noNodesTitle: "没有执行节点", - noNodesDescription: "托管 Session 运行在已接入的节点上。添加节点即可提供沙箱容量。", - addNode: "添加节点", notSetUpTitle: "还没有设置沙箱", setUp: "设置沙箱", runtimeSection: "托管运行时", runtimeSectionDetail: "Core 托管沙箱的 CPU、内存与 Token 历史。其他环境不上报运行时指标。", noRuntimeTitle: "没有托管沙箱", noRuntimeDescription: "没有使用 Core 托管环境的 Session。", - noRuntimeProject: "这个项目没有使用 Core 托管环境的 Session。", runtimeLoading: "正在加载运行时观测…", runtimeUnavailable: "运行时观测不可用。{{reason}}", runtimeStale: "刷新失败,显示的是上次的观测。{{reason}}", runtimePartial: "有 {{unread}} 个运行时超过读取上限、{{failed}} 个读取失败,未读取 Session 详情,这些行只显示 Session ID。", - runtimeKpiLabel: "托管运行时用量", - runtimes: "托管沙箱", - runtimeStates: "{{active}} 个活跃 · {{sleeping}} 个休眠", - runtimesDetail: "共 {{total}} 个 · {{pending}} 个正在启动或切换状态 · {{observed}} 个有当前采样", cpu: "CPU", - cpuDetail: "当前使用的核数,相对于已采样沙箱的核数。", cores: "{{value}} 核", memory: "内存", disk: "磁盘", - memoryDetail: "当前工作集,相对于已采样沙箱的内存限额。", runtimeList: "托管运行时", search: "搜索运行时", searchPlaceholder: "搜索 Session、Agent、节点或 ID", @@ -306,10 +249,8 @@ export const metrics = { historyBudget: "图表最多覆盖 {{limit}} 个 Session。", historyMixed: "各 Session 返回的时间桶宽度不一致。", }, - runtimeMeta: "{{n}} 个 · CPU {{cpu}} · 内存 {{memory}}", - runtimeMetaSuspended: "{{n}} 个 · {{sleeping}} 个挂起 · CPU {{cpu}} · 内存 {{memory}}", - suspended: "挂起", - suspendedHelp: "Core 记录的已挂起分配。下一个 Turn 会请求恢复,需要兼容的可用容量;占用保留名额,不占活跃名额。", + runtimeMeta: "{{n}} 个中 {{running}} 个运行中 · CPU {{cpu}} · 内存 {{memory}}", + runtimeMetaSuspended: "{{n}} 个中 {{running}} 个运行中 · {{sleeping}} 个挂起 · CPU {{cpu}} · 内存 {{memory}}", reason: { allocation_pending: "等待分配", runtime_not_running: "未运行", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index 8a88ec4ee..567f83d28 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -115,34 +115,12 @@ export const overview = { offline: "离线", }, fleet: { - servingGeneration: "保留的服务代次", - servingGenerationHelp: "Core 为该节点保留的配置代次。仅凭此记录不能确认当前连接、可用容量或目标配置就绪。", previousGeneration: "资源清单读取于配置代次 {{observed}},当前目标为 {{target}}。这里保留的是较早的观测。", - targetPreparation: "目标配置", title: "机群", - cloudHelp: "Core 与它在 E2B 云端持有的沙箱。选择 Core 或 E2B 可查看摘要。", - help: "Core 与沙箱节点的可用状态及活跃沙箱 / 上限。点击一行查看详情。列表显示不下时,优先显示离线和异常节点。", + cloudHelp: "Core 与它在 E2B 云端持有的沙箱。选择 Core 打开 Core 监控,选择 E2B 打开沙箱监控。", + help: "Core 与沙箱节点的可用状态及活跃沙箱 / 上限。选择 Core 打开 Core 监控,选择节点打开它在节点页的详情。列表显示不下时,优先显示离线和异常节点。", core: "Core", open: "{{name}},{{state}},沙箱 {{slots}}", - openNode: "节点详情", - openSandboxMetrics: "沙箱监控", - openCoreMetrics: "Core 监控", - facts: { - status: "状态", - lastSeen: "最近心跳", - never: "从未", - active: "活跃沙箱", - suspended: "挂起的沙箱", - cpu: "CPU", - cores: "{{count}} 核", cores_one: "{{count}} 核", cores_other: "{{count}} 核", - memory: "可用内存", - disk: "可用磁盘", - uptime: "已运行", - slots: "执行并发", - queued: "排队 Turn", - daemons: "已连接 daemon", - database: "数据库延迟 p95", - }, more: "另有 {{count}} 个节点,在节点页查看", more_one: "另有 {{count}} 个节点,在节点页查看", more_other: "另有 {{count}} 个节点,在节点页查看", @@ -158,9 +136,6 @@ export const overview = { name: "E2B 云端", open: "E2B 云端,{{running}} 个运行中的沙箱", running: "{{count}} 个运行中", - runningLabel: "运行中的沙箱", - pending: "启动或切换中", - template: "模板构建", openBackend: "沙箱后端", }, }, diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index 747c1908f..3045ae9d7 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -1,18 +1,13 @@ import { firstRun } from "./first-run"; export const sandbox = { "Current usage exceeds a saved limit. Existing sandboxes are preserved; new placements wait for capacity.": "当前用量超过已保存上限。已有沙箱保留;新的分配需等待容量。", - "Capacity and suspension": "容量与挂起", - "The idle policy applies to the whole deployment. Active and retained limits are set for each node under Sandbox nodes.": "空闲策略对整个部署生效;活跃与保留上限在“沙箱节点”中按节点设置。", + "The idle policy applies to the whole deployment. Each node's active and retained limits are set on Nodes with Edit node.": "空闲策略对整个部署生效;每个节点的活跃与保留上限在节点页用“编辑节点”设置。", "Reset": "重置", "Clear this configuration to choose a different backend. Hosted Sessions are archived permanently.": "清除当前配置后才能选择其他后端。托管 Session 将被永久归档。", "Idle before suspension (seconds)": "空闲多久后挂起(秒)", "Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.": "整个部署统一生效:30–86,400 秒。适用于现有和新建的运行中沙箱,无需重启。已经开始的挂起和快照保留期不变。容量紧张时,沙箱空闲 15 秒后可能提前挂起。", "Enter a whole number from 30 to 86,400.": "请输入 30 到 86,400 之间的整数。", - "Suspended placements across all registered nodes, including offline nodes. They do not consume an active slot.": "所有注册节点(含离线节点)上的已挂起分配,不占活跃名额。", "Automatic suspension": "自动挂起", - "Active and retained limits cover online nodes. Suspended counts include all registered nodes. These are Core placement records, not CPU or memory measurements.": "活跃与保留名额统计在线节点;已挂起数量包含所有注册节点。这些是 Core 分配记录,并非 CPU 或内存实测值。", - "Showing the last loaded lifecycle counts; current state is unconfirmed.": "显示上次读取的生命周期数量;当前状态尚未确认。", - "Idle before suspension": "空闲多久后挂起", "Snapshot retention": "快照保留期", "Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.": "这是新挂起任务采用的当前策略,不是现有快照的到期倒计时。下一轮 Turn 会请求恢复,需要可用容量及兼容节点。", "Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.": "快照到期后释放内存状态。使用独立工作区存储时,文件和对话仍可用于冷启动续接,进程会重新启动。", @@ -122,7 +117,7 @@ export const sandbox = { "Change provider or resources": "修改运行后端或资源", "Sandbox backend": "沙箱后端", "Core records these sandboxes as suspended. Their next Turn requests a resume when compatible capacity is available. They count toward the retained limit, not the active one.": "Core 将这些沙箱记录为已挂起。下一个 Turn 会在兼容容量可用时请求恢复。它们占用保留名额,不占活跃名额。", - "Sandboxes this node holds, active and suspended, against its retained limit.": "这台节点上保留的沙箱(运行中的和挂起的)相对于保留上限。", + "The most sandboxes this node keeps, running and suspended together. A suspended one resumes here on its next Turn.": "这台节点最多保留的沙箱数,运行中的和挂起的合计。挂起的沙箱在下一个 Turn 时在这里恢复。", "Edit node": "编辑节点", "Name": "名称", "Save": "保存", @@ -437,4 +432,22 @@ export const sandbox = { "Enter the key": "输入 key", "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", + "Sandboxes released on this node that it has not removed yet.": "已在这台节点上释放、但节点尚未移除的沙箱。", + "Host": "宿主机", + "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.": "节点每次心跳上报的整台机器的情况:CPU 忙碌占比和已用内存,取每个时间段内的最高值。节点离线的时间段留空。", + "Time range": "时间范围", + "1 hour": "1 小时", + "6 hours": "6 小时", + "24 hours": "24 小时", + "CPU": "CPU", + "Memory": "内存", + "{{percent}} of {{cores}} CPU": "{{cores}} 核中的 {{percent}}", + "{{cores}} CPU": "{{cores}} 核", + "{{used}} of {{total}} used": "已用 {{used}} / {{total}}", + "Host history could not be read. {{reason}}": "无法读取宿主机历史。{{reason}}", + "Loading host history…": "正在加载宿主机历史…", + "Host CPU / {{bucket}}": "宿主机 CPU / {{bucket}}", + "Host memory / {{bucket}}": "宿主机内存 / {{bucket}}", + "Used": "已用", + "Total": "总量", } as const; diff --git a/apps/web/src/lib/console-navigation.tsx b/apps/web/src/lib/console-navigation.tsx index b0df084cf..99144405a 100644 --- a/apps/web/src/lib/console-navigation.tsx +++ b/apps/web/src/lib/console-navigation.tsx @@ -9,8 +9,6 @@ import type { ConsoleView } from "./console-routes"; export interface RouteParams { /** The project a page is filtered to, or a secondary page belongs to. */ project?: string; - /** Node capacity form within Settings. */ - node?: string; /** The resource opened by a secondary page or focused in a list. */ id?: string; } @@ -22,14 +20,13 @@ export function routeParamsFromHash(hash: string): RouteParams { const value = params.get(name); return value && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : undefined; }; - return { project: read("project"), id: read("id"), node: read("node") }; + return { project: read("project"), id: read("id") }; } export function hashWithParams(base: string, params: RouteParams = {}): string { const query = new URLSearchParams(); if (params.project) query.set("project", params.project); if (params.id) query.set("id", params.id); - if (params.node) query.set("node", params.node); const suffix = query.toString(); if (!suffix) return base; return `${base || "#overview"}?${suffix}`; diff --git a/apps/web/src/lib/console-routes.test.ts b/apps/web/src/lib/console-routes.test.ts index ac0e314b9..1fc5d04ed 100644 --- a/apps/web/src/lib/console-routes.test.ts +++ b/apps/web/src/lib/console-routes.test.ts @@ -34,7 +34,6 @@ describe("console routes", () => { it("carries only well-formed project and resource parameters", () => { expect(routeParamsFromHash("#session?project=proj_1&id=s-1")).toEqual({ project: "proj_1", id: "s-1" }); - expect(routeParamsFromHash("#settings?id=sandbox&node=node-1")).toEqual({ project: undefined, id: "sandbox", node: "node-1" }); expect(routeParamsFromHash("#session?project=bad%20id")).toEqual({ project: undefined, id: undefined }); expect(hashWithParams("#session", { project: "proj_1", id: "s1" })).toBe("#session?project=proj_1&id=s1"); expect(hashWithParams("#files")).toBe("#files"); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 4133a5dc3..12894e03f 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -27,5 +27,4 @@ @source "../components/primitives"; @source "../components/ui"; @source "../components/console-select.tsx"; -@source "../components/console-popover.tsx"; @source "../components/magicui"; diff --git a/apps/web/src/styles/system.css b/apps/web/src/styles/system.css index 0ee84a606..b48af71d9 100644 --- a/apps/web/src/styles/system.css +++ b/apps/web/src/styles/system.css @@ -882,85 +882,6 @@ pre { color: var(--tooltip-fg); } -/* An anchored popover: a glance at one object, facts on a two-column grid, - * links onward at the foot. The card itself is the coss ui popup. */ -.console-popover { - display: grid; - gap: 14px; - color: var(--ink); - font-size: 13px; - line-height: 20px; -} - -.console-popover-head { - display: grid; - gap: 2px; - min-width: 0; -} - -.console-popover-head h2 { - margin: 0; - overflow: hidden; - color: var(--ink); - text-overflow: ellipsis; - white-space: nowrap; -} - -.console-popover-meta, -.console-popover-meta code { - overflow: hidden; - color: var(--ink-3); - font-family: var(--font-mono); - font-size: 11.5px; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; - background: none; - padding: 0; -} - -.console-popover-facts { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px 16px; - margin: 0; -} - -.console-popover-facts > div { - display: grid; - gap: 1px; - min-width: 0; -} - -.console-popover-facts dt { - color: var(--ink-3); - font-size: 12px; - font-weight: 500; - line-height: 16px; -} - -.console-popover-facts dd { - margin: 0; - color: var(--ink); - font-size: 13px; - line-height: 20px; - font-variant-numeric: tabular-nums; -} - -.console-popover-facts .kpi-unit { - margin-left: 3px; - font-size: 12px; -} - -.console-popover-actions { - display: flex; - justify-content: flex-end; - gap: 14px; - margin: 0 -16px -4px; - padding: 10px 16px 0; - border-top: 1px solid var(--line); -} - .modal-backdrop { background: var(--overlay); } diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 721b8a915..674077a53 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -10,7 +10,7 @@ This contract owns the Core API routes below and their semantics. The [nodes gui `PATCH /core/v1/sandbox/deployment` accepts `{"idle_seconds": 300}`. `idle_seconds` is a required integer from 30 to 86400 seconds, defaulting to 300, stored in Core's database. Only configured Providers declaring checkpoint support accept this change; unconfigured, unsupported or resetting deployments reject it. Success returns the deployment view with the saved `suspension.idle_seconds`. -Changes apply to existing and new running allocations on the next lifecycle pass, without a Core restart, generation change or node rollout. Suspension already in progress is not cancelled; existing snapshots and the 24-hour retention period are unchanged. [Capacity-pressure reclamation](../../docs/sandbox-provider.md#suspension) can still suspend after 15 seconds of inactivity. Web manages this deployment-wide policy in **Settings** → **Sandbox and scheduling** → **Capacity and suspension**. The adjacent **Sandbox nodes** section owns per-node capacity editing; the policy belongs to the deployment and capacity limits remain node-owned. +Changes apply to existing and new running allocations on the next lifecycle pass, without a Core restart, generation change or node rollout. Suspension already in progress is not cancelled; existing snapshots and the 24-hour retention period are unchanged. [Capacity-pressure reclamation](../../docs/sandbox-provider.md#suspension) can still suspend after 15 seconds of inactivity. Web manages this deployment-wide policy in **Settings** → **Sandbox and scheduling** → **Automatic suspension**; each node's capacity limits stay node-owned and are edited on **Nodes** with **Edit node**. ## Routes diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index e87d4115c..297cf7724 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 9cadc87718aa14157b00bacde3cad94477f961c6d6a4c807732ad9282de890e0 +source_hash: 84fd89164b02406f379720805b6d30b64746b97e5bd322459bffa88496399b91 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -12,7 +12,7 @@ source_hash: 9cadc87718aa14157b00bacde3cad94477f961c6d6a4c807732ad9282de890e0 `PATCH /core/v1/sandbox/deployment` 接受 `{"idle_seconds": 300}`。`idle_seconds` 是必填整数,范围为 30–86400 秒,默认为 300。该设置保存在 Core 的数据库中。只有已配置且声明 checkpoint 支持的 Provider 可修改;未配置、不支持或正在重置的部署拒绝写入。成功返回当前部署视图,`suspension.idle_seconds` 反映已保存的值。 -变更从下一轮生命周期检查起作用于现有和新建的运行中分配,不重启 Core、不推进配置代次,也不重新部署节点。已经开始的挂起不撤销;现有快照和 24 小时快照保留期不变。[容量压力回收](../../../docs/zh/sandbox-provider.md#suspension)仍可在空闲 15 秒后提前挂起。Web 在 **Settings** → **Sandbox and scheduling** → **Capacity and suspension** 管理此部署级策略。相邻的 **Sandbox nodes** 区域负责编辑各节点容量;策略适用于整个部署,名额上限仍属于各个节点。 +变更从下一轮生命周期检查起作用于现有和新建的运行中分配,不重启 Core、不推进配置代次,也不重新部署节点。已经开始的挂起不撤销;现有快照和 24 小时快照保留期不变。[容量压力回收](../../../docs/zh/sandbox-provider.md#suspension)仍可在空闲 15 秒后提前挂起。Web 在 **Settings** → **Sandbox and scheduling** → **Automatic suspension** 管理此部署级策略;各节点的容量上限仍属于节点,在 **Nodes** 中通过 **Edit node** 编辑。 ## 路由 {#routes} diff --git a/docs/configuration.md b/docs/configuration.md index 50323182f..5a4922346 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,8 +94,8 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa | --- | --- | --- | --- | | Sandbox backend: Docker, microsandbox or E2B | **Settings** → **Sandbox and scheduling**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) | | Sandbox size, Runtime release, E2B key and template build | **Settings** → **Sandbox and scheduling** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the [default size the Provider declares](./sandbox-provider.md#register-the-provider-kind). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | -| Nodes and their capacity | **Settings → Sandbox and scheduling**: **Add node**; **Sandbox nodes** → **Edit node**. Removal stays on the node's page | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](./getting-started/nodes.md) | -| Idle suspension | **Settings** → **Sandbox and scheduling** → **Capacity and suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | +| Nodes and their capacity | **Nodes**: **Add node**, **Edit node** and removal | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](./getting-started/nodes.md) | +| Idle suspension | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | | Independent workspace storage and tenant capacity | **Settings → Workspace storage** | `/core/v1/workspace-storage` | See [independent workspace storage](#independent-workspace-storage); existing workspaces retain their configuration | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per harness | **Settings** → **Models and credentials**: **Set** | `/core/v1/harnesses/{harness}/model-configuration` | See [Default models](#default-models) | @@ -144,7 +144,7 @@ Then create or update the microsandbox deployment through the [sandbox deploymen The storage form accepts the adapter identifier, its parameters as a JSON object and an optional write-only credential JSON object. Core validates these adapter-owned values; Web does not define provider-specific storage fields. **Edit tenant capacity** keeps the selected immutable configuration. Selecting another configuration requires a new ID and does not migrate existing workspaces. -After the filesystem and deployment are selected, use **Settings → Sandbox and scheduling → Add node** to enroll the prepared hosts. Connected, ready compute alone does not qualify an external filesystem; validate actual Session creation and access through each participating node before admitting application traffic. Include the independent namespace in the [stopped-write backup and restore procedure](./getting-started/operations.md#back-up). +After the filesystem and deployment are selected, use **Nodes → Add node** to enroll the prepared hosts. Connected, ready compute alone does not qualify an external filesystem; validate actual Session creation and access through each participating node before admitting application traffic. Include the independent namespace in the [stopped-write backup and restore procedure](./getting-started/operations.md#back-up). #### Quota mode storage server @@ -196,7 +196,7 @@ The policy bounds what each tenant can commit, not the filesystem. Size the XFS ### Node capacity -Core approves a node's capacity when you generate its Add node command: **Sandboxes at once** (`max_active`, default 2) and, for microsandbox only, **Retained sandboxes** (`max_retained`, default 8), with `max_retained >= max_active >= 1`. Docker never suspends sandboxes, so Web doesn't ask for it and Core keeps `max_retained` equal to `max_active`. Change them later in **Settings → Sandbox and scheduling → Sandbox nodes** with **Edit node**. Reservations and cleanup that is not confirmed count against capacity; lowering a limit stops no running sandbox. A node's own files can't change its capacity, size or Runtime. +Core approves a node's capacity when you generate its Add node command: **Sandboxes at once** (`max_active`, default 2) and, for microsandbox only, **Retained sandboxes** (`max_retained`, default 8), with `max_retained >= max_active >= 1`. Docker never suspends sandboxes, so Web doesn't ask for it and Core keeps `max_retained` equal to `max_active`. Change them later on **Nodes** with **Edit node**. Reservations and cleanup that is not confirmed count against capacity; lowering a limit stops no running sandbox. A node's own files can't change its capacity, size or Runtime. `OAC_EXECUTION_CONCURRENCY` is unrelated: it limits concurrent execution work in Core. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 91537b68c..74c22d6a6 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -95,7 +95,7 @@ Web's **Overview** tracks these steps in a **Getting started** checklist. The installer selects no sandbox backend. Choose the deployment that fits your infrastructure: -- **Recommended: microsandbox with independent workspace storage.** Prepare the [NFS filesystem and service accounts](../configuration.md#independent-workspace-storage) first, then choose **Own machines** and **microsandbox** in **Settings** → **Sandbox and scheduling**, and [add Linux nodes](./nodes.md) from **Settings → Sandbox and scheduling**. The [nodes guide](./nodes.md#before-you-add-a-node) also covers the private checkpoint store needed for memory recovery across compatible nodes. +- **Recommended: microsandbox with independent workspace storage.** Prepare the [NFS filesystem and service accounts](../configuration.md#independent-workspace-storage) first, then choose **Own machines** and **microsandbox** in **Settings** → **Sandbox and scheduling**, and [add Linux nodes](./nodes.md) from **Nodes**. The [nodes guide](./nodes.md#before-you-add-a-node) also covers the private checkpoint store needed for memory recovery across compatible nodes. - **Managed compute: E2B.** Select E2B and configure its credential and template through the same sandbox configuration flow. No execution nodes are needed; see [deployment setup](../../contracts/agents-api/sandbox-deployment.md#initial-setup-and-same-provider-changes). - **Other supported placements.** Docker nodes remain available. Applications can also connect [self-hosted machines](./self-hosted.md). The [provider comparison](../../contracts/agents-api/sandbox-deployment.md#what-each-field-means-per-sandbox-provider) distinguishes filesystem and suspension support. diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 15b866def..cd2154da9 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -22,7 +22,7 @@ The Core host joins like any other host: to run sandboxes on it, add it as a nod ## Add a node -1. In Web, open **Settings → Sandbox and scheduling** and choose **Add node**. +1. In Web, open **Nodes** and choose **Add node**. 2. Set **Sandboxes at once** and, with microsandbox, **Retained sandboxes**: the node's [capacity](../configuration.md#node-capacity). You can change them later with **Edit node**. 3. Choose **Generate command** and copy the command. It registers one node, once, and only if it runs within 10 minutes; Web counts down and offers **Generate new command** when it expires. 4. Run it on the host. Web follows the node from registered to connected to ready. @@ -33,7 +33,7 @@ Node installation and removal require root. Web's command uses `sudo` unless the ### The command -Copy the current command from **Settings → Sandbox and scheduling → Add node**. It includes this installation's address, checksum and enrollment token. Before downloading, it checks Linux amd64, Python 3.9+ and the required tools. Temporary download failures retry up to three attempts. If checksum verification fails, copy a fresh command from Web and retry. +Copy the current command from **Nodes → Add node**. It includes this installation's address, checksum and enrollment token. Before downloading, it checks Linux amd64, Python 3.9+ and the required tools. Temporary download failures retry up to three attempts. If checksum verification fails, copy a fresh command from Web and retry. - It downloads into the invoking account's private `~/.oac/node-bootstrap` directory, checks SHA-256, and runs with `sudo`, or directly in a root shell. Each retry clears the unfinished download while holding a lock; verified `.pyz` files remain available to installers. - The token reaches the installer on standard input, so it never appears in a process argument, an environment variable or sudo's log. @@ -100,7 +100,7 @@ Run `sudo journalctl -u oac-node-.service`. In a root shell, om Sandbox settings apply to the whole installation; nodes follow them. - **Size, Runtime release, E2B key or template build.** Open **Settings** → **Sandbox and scheduling** → **Change resources**, edit and save. Existing sandboxes keep their configuration. Each node prepares the new one while it keeps serving the old one, and **Nodes** shows its progress: **Preparing target**, **Ready for target** or **Preparation failed** with the [reason](#readiness-codes). Core places new Sessions on nodes ready for the new configuration first, and on nodes still serving an older one when those have no room. E2B changes apply at once. -- **Capacity and idle suspension.** In **Settings** → **Sandbox and scheduling**, use **Sandbox nodes** → **Edit node** for each node's active and retained limits, and **Capacity and suspension** → **Idle before suspension (seconds)** → **Save** for the deployment-wide [idle policy](../../contracts/agents-api/sandbox-deployment.md#suspension-policy). +- **Capacity and idle suspension.** On **Nodes**, use **Edit node** for a node's active and retained limits. In **Settings** → **Sandbox and scheduling**, use **Automatic suspension** → **Idle before suspension (seconds)** → **Save** for the deployment-wide [idle policy](../../contracts/agents-api/sandbox-deployment.md#suspension-policy). - **Backend.** On the same page, choose **Reset deployment**. Auto reset archives idle hosted Sessions at once and lets running work finish until the deadline you set; force cancels it now. Offline nodes must come back so Core can confirm their cleanup. When the reset completes, Core has retired every node and unused command: set up the new backend, then add nodes again. **Cancel reset** stops the remaining work; archived Sessions stay archived. The [reset contract](../../contracts/agents-api/sandbox-deployment.md#generation-ownership-and-rollout) describes what reset archives and keeps. To archive a single hosted Session, use the [Core API](../../contracts/agents-api/admin-api.md#session-archive). diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 0a25afd9b..4ee0d20fd 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -10,7 +10,7 @@ This page lists the Core routes each console page reads and writes, and how the | --- | --- | --- | --- | | Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config`, `/node-install/manifest.json` | The Core key at sign-in, then the console session cookie; `/node-install/manifest.json` needs no sign-in | Sign-in and sign-out; the node installer and node artifacts for Add node; the distribution's Runtime release for Docker and microsandbox setup. See [console server](./console-server.md) | | Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | The Core key, added by the console server | Projects, keys, resource reads and deletion, diagnostics, executor credentials and installation commands, provenance, summaries, Core metrics, the installation, default models | -| Sandbox administration | `/core/v1/sandbox/**` | The Core key, added by the console server | Sandbox configuration, Nodes, fleet and capacity figures on Overview and Sandbox metrics, Runtime observations of every project | +| Sandbox administration | `/core/v1/sandbox/**` | The Core key, added by the console server | Sandbox configuration, Nodes, the fleet and capacity summary on Overview, Runtime observations of every project on Sandbox metrics | | Agents API | `/v1/**` | Project API key | Not used. The console shows developers how to call it (see [Provenance and monitoring](#provenance-and-monitoring)) | Browser requests are same-origin and carry only the console session cookie. The browser sends the Core key once, in the sign-in request body, and never stores it; it never holds or sends an API key or an `OpenAI-Beta` header. The console reads through `AdminClient`, `SandboxAdminClient` and `CoreMetricsClient` from [`packages/agents-client`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md), which validate every response: a malformed value is reported as a failure, or marked as unrecognised where noted below, and never replaced by a guessed or zero value. @@ -73,7 +73,7 @@ In an archived project the section hides **Issue credential** and **Rotate** beh | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | | Installation | `GET /core/v1/installation` | Settings → Service and deployment installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the call samples; `public_url` as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`). A sensitive setting with a value, or an unknown member, fails the read | -| Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting, and the popover then shows only Core's status. The [Core metrics contract](../../contracts/agents-api/core-metrics.md) defines every measurement | +| Core metrics | `GET /core/v1/metrics?range=` | Core metrics page. A Core without the route (404) is shown as not reporting. The [Core metrics contract](../../contracts/agents-api/core-metrics.md) defines every measurement | `local_only`, or no `public_url`, stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and Settings then show a visible warning, with Review the public address leading to Settings → Service and deployment. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. @@ -103,11 +103,11 @@ The list carries each harness's configuration, so the console does not read `GET | Operation | Route | Console use | | --- | --- | --- | -| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows in Settings → Sandbox and scheduling and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows in Settings → Sandbox and scheduling and the Nodes summary | +| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows in Settings → Sandbox and scheduling and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows in Settings → Sandbox and scheduling | | E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the templates the entered E2B key can see, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (a [readiness code](../getting-started/nodes.md#readiness-codes); 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. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Add node** follows only the node whose `enrollment_id` equals its command's | -| 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. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; each hosted Runtime's node on Sandbox metrics. An online node's `diagnostic` (a [readiness code](../getting-started/nodes.md#readiness-codes); any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on the Nodes list and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Add node** follows only the node whose `enrollment_id` equals its command's | +| Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | A node's page on Nodes: the host's CPU busy share, memory and free disk from its last heartbeat, and CPU and memory history over 1 hour, 6 hours or 24 hours, refreshed every 30 s while visible. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. A node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase; a null time shows a dash. It does not infer snapshot expiry from the phase timestamp or the deployment's `suspension.retention_seconds` | | 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, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; root runs it directly. No ordinary-user installation or removal entry is exposed, and the log hint always names the system service. The command downloads the installer from the installation's `public_url`. No token is requested until the installation is read, when it cannot be read, when it is `local_only` (or its `public_url` is not a valid HTTP or HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider. The dialog reads both again on opening and when the window regains focus | | 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) | diff --git a/docs/web/index.md b/docs/web/index.md index d4dd4bf39..02d990584 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -16,15 +16,15 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Group | Page | Purpose | | --- | --- | --- | -| Monitor | Overview | Service status, running Sessions, sandbox slots and work needing attention; 24-hour Session activity; Core and its nodes as a topology, each with a popover glance; Needs attention: failed Sessions and Sessions waiting for their caller, active in the last 24 hours, each with its one-line reason; usage by Project | -| Monitor | Core metrics | The Core process: CPU and resident memory against their limits, execution slots and the Turn queue, connected daemons, database latency and pool, background jobs | +| Monitor | Overview | Service status, running Sessions, sandbox slots and work needing attention; 24-hour Session activity; Core and its nodes as a topology, each opening its page; Needs attention: failed Sessions and Sessions waiting for their caller, active in the last 24 hours, each with its one-line reason; usage by Project | +| Monitor | Core metrics | Whether Core can run Turns: execution slots, the Turn queue and its p95 wait, interrupted Turns, connected daemons, database latency, CPU and resident memory against their limits, background jobs; Go heap, goroutines and the database pool under **Diagnostics** | | Monitor | Agent metrics | Requests, errors, duration, tokens, models, tools, Agents and API keys over 1 h, 6 h, 24 h or 7 d | -| Monitor | Sandbox metrics | Node capacity; **Automatic suspension** when supported; **Recorded compute** lifecycle state shown separately from hosted Runtime CPU and memory observations across Projects | +| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, running against total, with their CPU and memory observations and, when supported, **Recorded compute** lifecycle state shown separately; an E2B deployment adds the sandboxes Core holds in E2B's cloud | | Monitor | Request log | Recent HTTP requests to Core, their status, elapsed time and safe error codes; [scope and retention](../../contracts/agents-api/admin-api.md#request-log) | | Monitor | Session log | Every Session, and each Session's read-only conversation, trace and Turns with the classified reason of a failure and its next step; a self-hosted Session's page also manages its executor credentials and gives the command that connects a host | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | | Platform | Projects and keys | Create, rename and archive Projects; issue and revoke keys; each Project's usage, write history and how to call the API | -| Platform | Nodes | Inspect and remove microsandbox or Docker nodes; each node's readiness, capacity and allocations, with the automatic suspension policy when supported, active and retained slots, and elapsed time in each compute phase | +| Platform | Nodes | Add, edit and remove microsandbox or Docker nodes; each node's readiness, active and retained limits, host CPU and memory with their history, free disk, and allocations with elapsed time in each compute phase | | Platform | Settings | **Sandbox and scheduling**, **Workspace storage**, **Models and credentials**, and **Service and deployment**; see [configuration](../configuration.md#runtime-settings-web) | Missing data is shown as missing (—), never as zero. [Console API usage](./console-api-usage.md) lists what each page reads and how its figures are bounded. @@ -35,7 +35,7 @@ Missing data is shown as missing (—), never as zero. [Console API usage](./con | --- | --- | | Give the installation an HTTPS address | Set `OAC_PUBLIC_URL` and point a reverse proxy at Web; see [Make Core reachable](../getting-started/install.md#configure-the-domain-and-https) | | Choose the sandbox backend (microsandbox, E2B or Docker), the sandbox size and Runtime, or reset the backend | **Settings → Sandbox and scheduling**; see [change the sandbox configuration](../getting-started/nodes.md#change-the-sandbox-configuration) | -| Add or remove execution nodes | **Settings → Sandbox and scheduling → Add node**; removal remains in **Nodes**; see the [nodes guide](../getting-started/nodes.md) | +| Add, resize or remove execution nodes | **Nodes**: **Add node**, **Edit node** and removal; see the [nodes guide](../getting-started/nodes.md) | | Set the default model of a harness | **Settings → Models and credentials**; see [default models](../configuration.md#default-models) | | Create a Project and issue its API keys | **Projects and keys**; see [Projects and API keys](../getting-started/operations.md#projects-and-api-keys) | | Issue, rotate or revoke a self-hosted executor's credential, or copy its install command | The Session's page in the **Session log**; see [self-hosted executors](../getting-started/self-hosted.md) | diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index bf60e862a..6a74d3ac7 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: daafd004cd471e75afc4ceecad8d0ef43e7937715f38e82d23328235205c9234 +source_hash: 72de9b828d70f6f96e20de6bf1173c8a1232924517c50b308ed8ed74d6668255 --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -98,8 +98,8 @@ Runtime 在发现 Harness 适配器之前解析资源目录。`` | --- | --- | --- | --- | | 沙箱后端:Docker、microsandbox 或 E2B | **Settings** → **Sandbox and scheduling**:设置向导,最后点击 **Save configuration** | `/core/v1/sandbox/deployment` | 每个安装只能使用一个后端,在首次登录后选择。要改用其他后端,必须先执行 **Reset deployment**;请参阅[更改沙箱配置](getting-started/nodes.md#change-the-sandbox-configuration) | | 沙箱大小、Runtime 发行版、E2B 密钥和模板构建 | **Settings** → **Sandbox and scheduling** → **Change resources** | `/core/v1/sandbox/deployment` | Web 会推荐 [Provider 声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小和发行版。E2B 密钥仅可写入,并且已加密 | -| 节点及其容量 | **Settings → Sandbox and scheduling**:**Add node**;**Sandbox nodes** → **Edit node**。移除操作仍在节点页面 | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[节点指南](getting-started/nodes.md) | -| 空闲挂起 | **Settings** → **Sandbox and scheduling** → **Capacity and suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | 整个部署统一生效;参阅[挂起策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy) | +| 节点及其容量 | **Nodes**:**Add node**、**Edit node** 和移除 | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[节点指南](getting-started/nodes.md) | +| 空闲挂起 | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | 整个部署统一生效;参阅[挂起策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy) | | 独立工作区存储和租户容量 | **Settings → Workspace storage** | `/core/v1/workspace-storage` | 参阅[独立工作区存储](#independent-workspace-storage);现有工作区保留自身配置 | | 项目和 API 密钥 | **Projects and keys**:**Create project**、**Rename**、**Issue key**、**Revoke**、**Archive** | `/core/v1/projects` | 密钥只显示一次;Core 存储其摘要 | | 每个 Harness 的默认模型 | **Settings** → **Models and credentials**:**Set** | `/core/v1/harnesses/{harness}/model-configuration` | 请参阅[默认模型](#default-models) | @@ -148,7 +148,7 @@ services: 存储表单接受 adapter 标识、JSON 对象形式的参数和可选的只写凭据 JSON 对象。Core 验证这些由 adapter 定义的值;Web 不定义 Provider 专属存储字段。**Edit tenant capacity** 保留已选的不可变配置。选择另一份配置需要新的 ID,不会迁移现有工作区。 -选择文件系统和部署后,通过 **Settings → Sandbox and scheduling → Add node** 注册已准备的主机。计算资源已连接且就绪并不足以证明外部文件系统具备资格;准入应用流量前,通过每个参与节点验证实际 Session 创建和访问。将独立命名空间纳入[停止写入后的备份恢复流程](./getting-started/operations.md#back-up)。 +选择文件系统和部署后,通过 **Nodes → Add node** 注册已准备的主机。计算资源已连接且就绪并不足以证明外部文件系统具备资格;准入应用流量前,通过每个参与节点验证实际 Session 创建和访问。将独立命名空间纳入[停止写入后的备份恢复流程](./getting-started/operations.md#back-up)。 #### 配额模式存储服务器 {#quota-mode-storage-server} @@ -200,7 +200,7 @@ Session 创建可以在异步工作区准入之前成功。容量耗尽时,Cor ### 节点容量 {#node-capacity} -生成 **Add node** 命令时,Core 会批准节点容量:**Sandboxes at once**(`max_active`,默认值为 2),并且仅对 microsandbox 还会批准 **Retained sandboxes**(`max_retained`,默认值为 8),其中 `max_retained >= max_active >= 1`。Docker 从不暂停沙箱,因此 Web 不会询问此项,并且 Core 会使 `max_retained` 保持等于 `max_active`。之后可在 **Settings → Sandbox and scheduling → Sandbox nodes** 使用 **Edit node** 修改这些值。预留和尚未确认的清理操作都会占用容量;调低限制不会停止任何正在运行的沙箱。节点自身的文件无法更改其容量、大小或 Runtime。 +生成 **Add node** 命令时,Core 会批准节点容量:**Sandboxes at once**(`max_active`,默认值为 2),并且仅对 microsandbox 还会批准 **Retained sandboxes**(`max_retained`,默认值为 8),其中 `max_retained >= max_active >= 1`。Docker 从不暂停沙箱,因此 Web 不会询问此项,并且 Core 会使 `max_retained` 保持等于 `max_active`。之后可在 **Nodes** 使用 **Edit node** 修改这些值。预留和尚未确认的清理操作都会占用容量;调低限制不会停止任何正在运行的沙箱。节点自身的文件无法更改其容量、大小或 Runtime。 `OAC_EXECUTION_CONCURRENCY` 与此无关:它限制 Core 中并发执行的工作量。 diff --git a/docs/zh/getting-started/install.md b/docs/zh/getting-started/install.md index ad39497b9..cf7940572 100644 --- a/docs/zh/getting-started/install.md +++ b/docs/zh/getting-started/install.md @@ -1,7 +1,7 @@ --- title: "安装 Core 和 Web" source: docs/getting-started/install.md -source_hash: f0ebc1f28e681ebe9fb47737ab4997b480f2516dfba2fa0210ecd6f7ba666cf4 +source_hash: 52e9519b47d1c05f00f38968dbeab6a27568b51d454a17aab2d80b56563d0bb0 --- 一条命令即可在 Linux、macOS 或 Windows 上安装 Core、Web 控制台和 PostgreSQL。用 Core 密钥登录 Web,设置默认模型并签发 Project API 密钥。应用使用这些密钥调用 Core。Session 在你添加的节点上的沙箱中运行,也可以在 E2B 上运行。 @@ -97,7 +97,7 @@ Web 的 **Overview** 通过 **Getting started** 清单跟踪这些步骤。 安装程序不选择沙箱后端。根据你的基础设施选择部署方案: -- **推荐:microsandbox 配合独立工作区存储。** 先准备 [NFS 文件系统和服务账户](../configuration.md#independent-workspace-storage),再在 **Settings** → **Sandbox and scheduling** 选择 **Own machines** 和 **microsandbox**,通过 **Settings → Sandbox and scheduling** [添加 Linux 节点](./nodes.md)。[节点指南](./nodes.md#before-you-add-a-node)同时说明了跨兼容节点恢复内存所需的私有检查点存储。 +- **推荐:microsandbox 配合独立工作区存储。** 先准备 [NFS 文件系统和服务账户](../configuration.md#independent-workspace-storage),再在 **Settings** → **Sandbox and scheduling** 选择 **Own machines** 和 **microsandbox**,通过 **Nodes** [添加 Linux 节点](./nodes.md)。[节点指南](./nodes.md#before-you-add-a-node)同时说明了跨兼容节点恢复内存所需的私有检查点存储。 - **托管计算:E2B。** 在同一个沙箱配置流程中选择 E2B 并配置凭据和模板。不需要执行节点;参阅[部署设置](../../../contracts/agents-api/zh/sandbox-deployment.md#initial-setup-and-same-provider-changes)。 - **其他支持的执行位置。** Docker 节点仍然可用,应用也可以连接[自托管机器](./self-hosted.md)。[提供商对照表](../../../contracts/agents-api/zh/sandbox-deployment.md#what-each-field-means-per-sandbox-provider)区分各方案的文件系统和挂起支持。 diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 461b3dc43..89bff7e69 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: e9eda521ab01e69a241cd788f872bbd3cd83098b9355df49c383c7e0efa9586c +source_hash: 56719d358e65d838d05bfb60b902a8d8f0803f833048be4a7c56c4731776ed0a --- 节点是一台 Linux 主机,在沙箱后端为 microsandbox(推荐)或 Docker 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 @@ -24,7 +24,7 @@ Microsandbox 的检查点执行兼容性包含主机内核和 CPU profile。因 ## 添加节点 {#add-a-node} -1. 在 Web 打开 **Settings → Sandbox and scheduling**,选择 **Add node**。 +1. 在 Web 打开 **Nodes**,选择 **Add node**。 2. 设置 **Sandboxes at once**,使用 microsandbox 时还设置 **Retained sandboxes**,即节点[容量](../configuration.md#node-capacity)。之后可以通过 **Edit node** 修改。 3. 选择 **Generate command** 并复制命令。命令仅注册一个节点、只能使用一次,且必须在 10 分钟内执行;Web 显示倒计时,过期后提供 **Generate new command**。 4. 在主机上执行。Web 跟踪节点从注册、连接到就绪的过程。 @@ -35,7 +35,7 @@ Microsandbox 的检查点执行兼容性包含主机内核和 CPU profile。因 ### 命令 {#the-command} -从 **Settings → Sandbox and scheduling → Add node** 复制当前命令,其中已填入本安装的地址、校验和与注册令牌。命令先检查 Linux amd64、Python 3.9+ 和必要工具,再下载文件;临时网络故障最多尝试三次。校验失败时,从 Web 复制新命令重试。 +从 **Nodes → Add node** 复制当前命令,其中已填入本安装的地址、校验和与注册令牌。命令先检查 Linux amd64、Python 3.9+ 和必要工具,再下载文件;临时网络故障最多尝试三次。校验失败时,从 Web 复制新命令重试。 - 下载到当前账号的私有 `~/.oac/node-bootstrap` 目录,检查 SHA-256,再通过 `sudo` 执行;root shell 中则直接执行。每次持锁清理下载残片后重试,已验证的 `.pyz` 保留供安装程序使用。 - 令牌通过标准输入传给安装程序,不出现在进程参数、环境变量或 sudo 日志中。 @@ -102,7 +102,7 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 沙箱设置适用于整个安装,节点跟随这些设置。 - **规格、Runtime 版本、E2B 密钥或模板构建。** 打开 **Settings** → **Sandbox and scheduling** → **Change resources**,修改并保存。已有沙箱保持其配置。每个节点继续服务旧配置,同时准备新配置;**Nodes** 展示进度:**Preparing target**、**Ready for target**,或带[原因](#readiness-codes)的 **Preparation failed**。Core 优先在已准备好新配置的节点上放置新 Session;这些节点没有容量时,会使用仍服务旧配置的节点。E2B 变更立即生效。 -- **容量与空闲挂起。** 在 **Settings** → **Sandbox and scheduling** 中,通过 **Sandbox nodes** → **Edit node** 修改各节点的活跃和保留上限;通过 **Capacity and suspension** → **Idle before suspension (seconds)** → **Save** 修改整个部署的[空闲策略](../../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)。 +- **容量与空闲挂起。** 在 **Nodes** 中通过 **Edit node** 修改节点的活跃和保留上限。在 **Settings** → **Sandbox and scheduling** 中,通过 **Automatic suspension** → **Idle before suspension (seconds)** → **Save** 修改整个部署的[空闲策略](../../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)。 - **后端。** 同一页面选择 **Reset deployment**。自动重置立即归档空闲托管 Session,并让运行中的工作在设定截止时间前完成;强制重置立即取消工作。离线节点必须恢复连接,让 Core 确认清理。重置完成时,Core 已撤销所有节点和未使用命令:配置新后端后重新添加节点。**Cancel reset** 停止剩余工作;已归档 Session 仍保持归档。 [重置协议](../../../contracts/agents-api/zh/sandbox-deployment.md#generation-ownership-and-rollout)描述重置会归档和保留哪些内容。归档单个托管 Session 时,使用 [Core API](../../../contracts/agents-api/zh/admin-api.md#session-archive)。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 26f3afc1b..73c393cf8 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 74cbd391bcc6aed0df2aa3b6272689eb75874f88f0c0cf6bc45e22a9b708b905 +source_hash: bfed9ea629660a7018b7993445122e99158bd7b6319f08b0ee6a468f23310bde --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -12,7 +12,7 @@ source_hash: 74cbd391bcc6aed0df2aa3b6272689eb75874f88f0c0cf6bc45e22a9b708b905 | --- | --- | --- | --- | | Console server | `/console/auth`、`/console/auth/{login,logout}`、`/console/config`、`/node-install/manifest.json` | 登录时使用 Core 密钥,随后使用控制台会话 Cookie;`/node-install/manifest.json` 无需登录 | 登录和退出;Add node 所用的节点安装程序和节点构件;用于 Docker 和 microsandbox 设置的发行版 Runtime release。参见 [console server](console-server.md) | | Administrator API | `/core/v1/**`,不包括 `/core/v1/sandbox` | Core 密钥,由控制台服务器添加 | 项目、密钥、资源读取和删除、诊断、执行器凭据和安装命令、来源信息、汇总、Core 指标、安装信息、默认模型 | -| Sandbox administration | `/core/v1/sandbox/**` | Core 密钥,由控制台服务器添加 | Sandbox 配置;Overview 和 Sandbox metrics 中的 Nodes、机群与容量数据;每个项目的 Runtime observations | +| Sandbox administration | `/core/v1/sandbox/**` | Core 密钥,由控制台服务器添加 | Sandbox 配置;Nodes;Overview 上的机群与容量摘要;Sandbox metrics 中每个项目的 Runtime observations | | Agents API | `/v1/**` | 项目 API 密钥 | 不使用。控制台会向开发者说明如何调用它(参见 [Provenance and monitoring](#provenance-and-monitoring)) | 浏览器请求均为同源请求,并且仅携带控制台会话 Cookie。浏览器只在登录请求体中发送一次 Core 密钥,且绝不存储该密钥;它既不持有也不发送 API 密钥或 `OpenAI-Beta` 标头。控制台通过 [`packages/agents-client`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md) 中的 `AdminClient`、`SandboxAdminClient` 和 `CoreMetricsClient` 执行读取,这些客户端会验证每个响应:格式错误的值会被报告为失败,或按下文说明标记为无法识别,绝不会用猜测值或零值替代。 @@ -75,7 +75,7 @@ source_hash: 74cbd391bcc6aed0df2aa3b6272689eb75874f88f0c0cf6bc45e22a9b708b905 | 写入操作 | `GET /core/v1/projects/{project_id}/write-operations` | 项目的写入历史,按最新优先,可按密钥和资源类型筛选,每页 50 条 | | 汇总 | `GET /core/v1/summary` | Overview(按项目)、Agents 列表(`group_by=agent`)、项目页面(按项目并使用 `group_by=key`)、Agent 指标(跳过空闲项目,并统计从范围开始以来按创建密钥划分的使用量)、Projects 列表(最近活动) | | 安装 | `GET /core/v1/installation` | Settings → Service and deployment 的 Installation 信息(`public_url`、`api_base_url`、`installation_id`、`source_commit`)和只读 Startup 设置(`configuration.settings`;敏感设置仅显示其是否为 `configured`);调用示例中的 `api_base_url`;作为下载来源以及节点安装和卸载命令中 `--source-url` 的 `public_url`(还包括安装命令中的 `--core-url`)。如果敏感设置包含值,或存在未知成员,读取会失败 | -| Core 指标 | `GET /core/v1/metrics?range=` | Core 指标页面;Overview 上的 Core 弹出内容。不存在该路由的 Core(404)会显示为未报告数据,此时弹出内容仅显示 Core 状态。[Core metrics contract](../../../contracts/agents-api/zh/core-metrics.md) 定义了每项度量 | +| Core 指标 | `GET /core/v1/metrics?range=` | Core 指标页面。不存在该路由的 Core(404)会显示为未报告数据。[Core metrics contract](../../../contracts/agents-api/zh/core-metrics.md) 定义了每项度量 | 如果为 `local_only`,或者没有 `public_url`,Add node 将无法签发命令,Clean up the host 也无法提供命令。随后 Overview、Nodes 和 Settings 会显示醒目警告,并通过 Review the public address 前往 Settings → Service and deployment。Nodes 会禁用 Add node 并显示明确原因,Getting started 则将 sandbox 步骤保留为待办项。 @@ -105,11 +105,11 @@ source_hash: 74cbd391bcc6aed0df2aa3b6272689eb75874f88f0c0cf6bc45e22a9b708b905 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 Managed in Settings 前往 Settings → Service and deployment,且无需确认;使用 `resources` 以及 Docker 或 microsandbox 的 `runtime` release 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 Settings → Sandbox and scheduling 和 Sandbox metrics 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 Settings → Sandbox and scheduling 和 Nodes 摘要中 | +| 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 Managed in Settings 前往 Settings → Service and deployment,且无需确认;使用 `resources` 以及 Docker 或 microsandbox 的 `runtime` release 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 Settings → Sandbox and scheduling 和 Sandbox metrics 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 Settings → Sandbox and scheduling 中 | | E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导先列出输入的 E2B 密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes 页面;Overview 上的机群;Sandbox metrics 中的节点容量。在线节点的 `diagnostic`(一个[就绪状态码](../getting-started/nodes.md#readiness-codes);任何其他值均读取为 `provider_unavailable`)会将其标记为降级,并在上述每个页面及节点页面中,紧邻状态的帮助提示里说明原因和修复方法。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,Nodes 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 Old address,而不是健康状态。**Add node** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | -| 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics 节点对话框:主机自最近一次心跳以来的 CPU 忙碌占比和内存使用量,以及页面所选范围内二者的历史记录。**Edit node** 读取 `host.effective_cpu_cores` 和 `host.total_memory_bytes`,用于在每个 Sandbox 大小旁显示主机容量,以及最多可容纳多少个该大小的 Sandbox | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes 页面;Overview 上的机群;Sandbox metrics 中每个托管 Runtime 所在的节点。在线节点的 `diagnostic`(一个[就绪状态码](../getting-started/nodes.md#readiness-codes);任何其他值均读取为 `provider_unavailable`)会将其标记为降级,并在 Nodes 列表及节点页面中,紧邻状态的帮助提示里说明原因和修复方法。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,Nodes 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 Old address,而不是健康状态。**Add node** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | +| 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Nodes 中的节点页面:主机最近一次心跳报告的 CPU 忙碌占比、内存使用量和可用磁盘,以及 1 小时、6 小时或 24 小时内 CPU 与内存的历史记录;页面可见时每 30 秒刷新一次。**Edit node** 读取 `host.effective_cpu_cores` 和 `host.total_memory_bytes`,用于在每个 Sandbox 大小旁显示主机容量,以及最多可容纳多少个该大小的 Sandbox | | 分配 | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes 页面;Sandbox metrics。节点页面根据 `compute_phase_changed_at` 显示每个分配处于计算阶段的时间;时间为 null 时显示短横线。它不会根据阶段时间或部署的 `suspension.retention_seconds` 推算快照到期时间 | | 注册 | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**:管理员先设置节点的 Sandbox 限制(`max_active`;`max_retained` 仅适用于 microsandbox,在 Docker 下等于 `max_active`),然后 Core 才会把一次性令牌放入命令中;该命令会验证安装程序校验和,并包含命令的 `enrollment_id`,节点注册时会报告此 ID。命令使用 sudo 运行安装程序(作为系统服务),并通过标准输入传递令牌;以 root 运行时则直接执行。界面不提供普通用户安装或移除入口,日志提示始终指明系统服务。命令从安装的 `public_url` 下载安装程序。只有成功读取安装信息后才会请求令牌;如果安装信息无法读取、安装为 `local_only`(或其 `public_url` 不是有效的 HTTP 或 HTTPS 来源),或者 `/console/config` 列出的 `node_artifacts` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | | 更新节点 | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**:同时修改名称和 Sandbox 限制(保留数量限制仅适用于 microsandbox;在 Docker 下,Core 会将其设为活动数量限制) | diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index afc15f1c0..7b75d3adf 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "OpenAgentCore Web" source: docs/web/index.md -source_hash: 17ec9dbffb34ae89966259c3e32ea168a20289de42bae1f50d3ceec1ded1c956 +source_hash: 1e5c6491ca2db026042091d5510db1277e2abf9519fda9adf9f89e83ac507251 --- Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看健康状态、容量、用量和失败情况,检查各 Project 的资源与执行历史,并管理 Project、密钥、节点和部署设置。应用不使用 Web;它们通过自己的 Project API 密钥调用 Core 的 Agents API(`/v1`)。 @@ -18,10 +18,10 @@ Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看 | 分组 | 页面 | 用途 | | --- | --- | --- | -| Monitor | Overview | 服务状态、运行中的 Session、沙箱槽位和待处理工作;24 小时 Session 活动;以拓扑展示 Core 与节点,各自提供弹出概览;需要处理:过去 24 小时内活跃的失败或等待调用方的 Session,每行附一行原因;按 Project 统计的用量 | -| Monitor | Core metrics | Core 进程:CPU 与常驻内存及其限制、执行槽位与 Turn 队列、已连接守护进程、数据库延迟与连接池、后台任务 | +| Monitor | Overview | 服务状态、运行中的 Session、沙箱槽位和待处理工作;24 小时 Session 活动;以拓扑展示 Core 与节点,各自打开对应页面;需要处理:过去 24 小时内活跃的失败或等待调用方的 Session,每行附一行原因;按 Project 统计的用量 | +| Monitor | Core metrics | Core 能否运行 Turn:执行槽位、Turn 队列及其 p95 等待时间、被中断的 Turn、已连接守护进程、数据库延迟、CPU 与常驻内存及其限制、后台任务;Go 堆、goroutine 和数据库连接池位于 **Diagnostics** | | Monitor | Agent metrics | 1 小时、6 小时、24 小时或 7 天的请求、错误、耗时、token、模型、工具、Agent 和 API 密钥 | -| Monitor | Sandbox metrics | 节点容量;支持时显示 **Automatic suspension**;**Recorded compute** 生命周期状态与各 Project 的托管 Runtime CPU、内存观测分开展示 | +| Monitor | Sandbox metrics | 各 Project 托管 Runtime 随时间的变化,显示运行中数量与总数及其 CPU、内存观测;支持时另行显示 **Recorded compute** 生命周期状态;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | | Monitor | Request log | 到达 Core 的近期 HTTP 请求、状态、耗时和安全错误码;[范围及保留方式](../../../contracts/agents-api/zh/admin-api.md#request-log) | | Monitor | Session log | 全部 Session;各 Session 的只读对话、跟踪、Turn、失败分类原因及下一步操作;自托管 Session 页面还管理执行器凭据并提供主机连接命令 | | Resources | Agents、Environment templates、Skills、Files、Vaults | 检查和允许的删除操作,显示各资源所属 Project 及创建密钥 | @@ -37,7 +37,7 @@ Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看 | --- | --- | | 为安装设置 HTTPS 地址 | 设置 `OAC_PUBLIC_URL`,并把反向代理指向 Web;参阅[使 Core 可访问](../getting-started/install.md#configure-the-domain-and-https) | | 选择沙箱后端(microsandbox、E2B 或 Docker)、规格和 Runtime,或重置后端 | **Settings → Sandbox and scheduling**;参阅[修改沙箱配置](../getting-started/nodes.md#change-the-sandbox-configuration) | -| 添加或移除执行节点 | **Settings → Sandbox and scheduling → Add node**;移除仍在 **Nodes**;参阅[节点指南](../getting-started/nodes.md) | +| 添加、调整或移除执行节点 | **Nodes**:**Add node**、**Edit node** 和移除;参阅[节点指南](../getting-started/nodes.md) | | 设置 Harness 默认模型 | **Settings → Models and credentials**;参阅[默认模型](../configuration.md#default-models) | | 创建 Project 并签发 API 密钥 | **Projects and keys**;参阅 [Project 和 API 密钥](../getting-started/operations.md#projects-and-api-keys) | | 签发、轮换或撤销自托管执行器凭据,或复制安装命令 | **Session log** 中的 Session 页面;参阅[自托管执行器](../getting-started/self-hosted.md) | From 98c9c86c027f6b037eb6f6e765c56140c534e2bf Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 14 Oct 2026 06:57:44 +0000 Subject: [PATCH 2/2] Address review of health and capacity pages --- apps/web/DESIGN.md | 1 - apps/web/PRODUCT.md | 2 +- apps/web/e2e/monitoring.spec.ts | 3 +- apps/web/e2e/sandbox-generation.spec.ts | 19 ++----- apps/web/e2e/sandbox-lifecycle.spec.ts | 4 ++ apps/web/src/features/metrics/MetricsView.css | 18 +----- .../features/metrics/SandboxMetricsPage.tsx | 15 ++++- .../src/features/metrics/sandbox-runtime.ts | 7 +-- apps/web/src/features/sandbox/NodeDetail.tsx | 2 +- apps/web/src/features/sandbox/NodeList.tsx | 2 +- .../sandbox/SandboxDeploymentPage.tsx | 26 ++++----- .../features/sandbox/SandboxManagerView.css | 11 ---- .../features/sandbox/SuspensionSettings.tsx | 8 +-- .../sandbox/use-sandbox-manager-state.ts | 30 ---------- ...st.tsx => use-sandbox-page-state.test.tsx} | 4 +- .../sandbox/use-sandbox-page-state.ts | 56 +++++++++++++++---- apps/web/src/features/settings/settings.css | 1 - apps/web/src/i18n/locales/en/metrics.ts | 5 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 5 +- apps/web/src/i18n/locales/zh-CN/sandbox.ts | 2 +- docs/web/index.md | 2 +- docs/zh/web/index.md | 6 +- 22 files changed, 100 insertions(+), 129 deletions(-) delete mode 100644 apps/web/src/features/sandbox/use-sandbox-manager-state.ts rename apps/web/src/features/sandbox/{use-sandbox-manager-state.test.tsx => use-sandbox-page-state.test.tsx} (98%) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index aa9a49ca8..b95e7c611 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -80,7 +80,6 @@ rounded: control-inner: "3px" segment: "8px" tooltip: "8px" - popover: "14px" frame: "6px" window: "8px" pill: "999px" diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 76cad846c..5540b974e 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -29,7 +29,7 @@ The console runs beside the administrator's own Core, with execution, files and ## Information Architecture -- **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, where Core opens Core metrics, a node opens its page on Nodes and E2B opens Sandbox metrics, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (whether Core can run Turns: execution slots, the Turn queue, its p95 wait and interrupted Turns, connected daemons, database latency, the Core process's CPU and memory, and background jobs; Go heap, goroutines and the database pool sit in a collapsed Diagnostics area), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (hosted Runtimes across projects over time, running against total; a sandbox opens in a dialog with its figures and CPU and memory charts; an E2B deployment adds what Core holds in E2B's cloud), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. +- **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, where Core opens Core metrics, a node opens its page on Nodes and E2B opens Sandbox metrics, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (whether Core can run Turns: execution slots, the Turn queue, its p95 wait and interrupted Turns, connected daemons, database latency, the Core process's CPU and memory, and background jobs; Go heap, goroutines and the database pool sit in a collapsed Diagnostics area), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (hosted Runtimes across projects over time, active against total; a sandbox opens in a dialog with its figures and CPU and memory charts; an E2B deployment adds what Core holds in E2B's cloud), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. An empty list says, in one sentence, that applications create these resources through the Agents API, and links to that resource's section of the API guide; Agent metrics does the same for Sessions. - **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each harness's default model and shows whether each harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. - A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows. diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 0fb4ee692..a2cf4c31a 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -26,7 +26,8 @@ test("shows the deployment's health on Overview and each monitor page", async ({ await page.getByRole("button", { name: "Agent metrics" }).click(); await expect(page.getByLabel("Agent run summary")).toContainText("Requests"); await page.getByRole("button", { name: "Sandbox metrics" }).click(); - await expect(page.getByRole("table", { name: "Hosted Runtimes" })).toContainText("core-01"); + await page.getByRole("table", { name: "Hosted Runtimes" }).getByRole("button", { name: "Open node core-01" }).first().click(); + await expect(page.getByRole("heading", { name: "core-01", level: 1 })).toBeVisible(); }); test("opens a Session's conversation from the Session log, read-only", async ({ page, request }) => { diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index d280ca9ce..74d3cc846 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -231,7 +231,7 @@ test("a different E2B team leaves the committed configuration intact and require }); -test("uncertain configuration refresh and inventory retry preserve a draft until its generation changes", async ({ page, request }) => { +test("uncertain configuration refresh preserves a draft until its generation changes", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); await expect(rollout(page)).toBeVisible(); const initial = await deploymentRead(page); @@ -246,28 +246,17 @@ test("uncertain configuration refresh and inventory retry preserve a draft until const result = await saveConfiguration(page); expect(result.response.status()).toBe(503); expect(result.input.expected_generation).toBe(initial.generation); - let failInventory = true; - await page.route("**/core/v1/sandbox/nodes", (route) => failInventory - ? route.fulfill({ status: 503, json: { error: { type: "server_error", code: null, message: "Node inventory unavailable.", param: null } } }) - : route.continue()); await expect(edit).toContainText("The sandbox service is unavailable. Refresh to check the current state."); await expect(edit).toContainText("Refresh sandbox state to confirm whether the change was saved before submitting again."); - const failedInventory = page.waitForResponse((response) => response.url().endsWith("/sandbox/nodes") && response.status() === 503); + const refreshed = page.waitForResponse((response) => response.request().method() === "GET" && response.url().endsWith(deploymentPath) && response.ok()); await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); - await failedInventory; - // Configuration stays authoritative even when the independent node inventory fails. + await refreshed; + // The confirmed generation is unchanged, so the draft stays for a deliberate save. await expect(wizard.getByRole("heading", { name: "Review and save", exact: true })).toBeVisible(); await expect(fact(wizard, "Each sandbox")).toHaveText(draftSize); expect((await deploymentRead(page)).generation).toBe(initial.generation); await expect(wizard.getByRole("button", { name: "Save configuration", exact: true })).toBeEnabled(); expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); - failInventory = false; - const inventoryRecovered = page.waitForResponse((response) => response.url().endsWith("/sandbox/nodes") && response.ok()); - await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); - await inventoryRecovered; - await expect(fact(wizard, "Each sandbox")).toHaveText(draftSize); - await expect(wizard.getByRole("button", { name: "Save configuration", exact: true })).toBeEnabled(); - expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); await setDeployment(request, { generation: initial.generation + 1 }); await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await expect(edit).toBeHidden(); diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index 9ef49f1e7..f5a3a81f1 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -42,6 +42,9 @@ test("hides the retained limit when the provider declares no suspension support" test("edits deployment idle time without changing generation", async ({ page, request }) => { + // Settings shows the deployment only; Nodes reads the node inventory. + const nodeReads: string[] = []; + page.on("request", (sent) => { if (new URL(sent.url()).pathname.startsWith("/core/v1/sandbox/nodes")) nodeReads.push(sent.url()); }); await openConsole(page, request, "settings?id=sandbox"); const deployment = await setDeployment(request, { suspension: { idle_seconds: 300, retention_seconds: 86400 } }); await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } }); @@ -62,4 +65,5 @@ test("edits deployment idle time without changing generation", async ({ page, re await page.reload(); await expect(idle).toHaveValue("60"); expect((await writes(request)).filter((write) => write === "PATCH /core/v1/sandbox/deployment")).toHaveLength(1); + expect(nodeReads).toEqual([]); }); diff --git a/apps/web/src/features/metrics/MetricsView.css b/apps/web/src/features/metrics/MetricsView.css index 031695d36..ad07c1929 100644 --- a/apps/web/src/features/metrics/MetricsView.css +++ b/apps/web/src/features/metrics/MetricsView.css @@ -1,16 +1,9 @@ -/* Metrics pages reuse the console grammar; the embedded runtime trend panel - is flattened so it reads as part of the page instead of a nested card. */ +/* Metrics pages reuse the console grammar. */ .metrics-page .console-page-header .segmented { flex: 0 0 auto; } -.runtime-embed { - display: flex; - flex-direction: column; - gap: 16px; -} - /* The Runtime list belongs to the section; its toolbar sits a step below the trends. */ .runtime-list { display: flex; @@ -43,12 +36,7 @@ font-variant-numeric: tabular-nums; } -.data-table .numeric-warning { - color: var(--orange); -} - - -/* A node or sandbox dialog: its facts, then its charts. */ +/* A sandbox dialog: its facts, then its charts. */ .metrics-dialog { display: grid; gap: 24px; @@ -82,8 +70,6 @@ outline-offset: 2px; } -.metrics-node-status { display: grid; gap: 4px; } - /* Core process internals stay collapsed below the operator's figures. */ .core-diagnostics > summary { color: var(--fg); diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 48dcaa880..2692c550a 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -239,7 +239,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {usage?.hosted ? ( {t(fleet?.deployment.suspension ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", { - running: formatInteger(usage.active, locale), + active: formatInteger(usage.active, locale), n: formatInteger(usage.hosted, locale), sleeping: formatInteger(usage.sleeping, locale), cpu: usage.cpuUsageCores === null ? MISSING : t("sandbox.cores", { value: formatCores(usage.cpuUsageCores, locale) }), @@ -316,7 +316,7 @@ function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { ro {showProject ? : null} - {showNode ? {row.node ? row.node.name || row.node.id : {MISSING}} : null} + {showNode ? : null} {showCompute ? {row.allocation ? sandboxStateLabel(row.allocation.compute_phase, locale?.startsWith("zh") ? "zh-CN" : "en") : MISSING} : null} @@ -356,6 +356,15 @@ function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { ro ); } +/** A Runtime's host, opening its page on Nodes. */ +function NodeLink({ node }: { node: HostedRuntimeRow["node"] }) { + const { t } = useTranslation("metrics"); + const { navigate } = useConsoleNavigation(); + if (!node) return {MISSING}; + const name = node.name || node.id; + return ; +} + /** Keeps the last value on screen while a dialog closes. */ function useLast(value: T | null): T | null { const last = useRef(value); @@ -383,7 +392,7 @@ function RuntimeDialog({ row, showNode, showCompute, onClose }: { row: HostedRun
{tCommon("project.column")}
- {showNode ?
{t("sandbox.node")}
{shown.node ? shown.node.name || shown.node.id : MISSING}
: null} + {showNode ?
{t("sandbox.node")}
: null}
{t("sandbox.status")}
{showCompute && shown.allocation ?
{t("sandbox.recordedCompute")}
{sandboxStateLabel(shown.allocation.compute_phase, i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en")}
: null}
{t("sandbox.configurationGeneration")}
{shown.deploymentGeneration ?? MISSING}
diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index b03e28756..025bd32ea 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -74,12 +74,7 @@ export async function loadHostedRuntimes( /** The trend snapshot of one project, or of every project when `projectId` is empty. */ export function runtimeSnapshot(load: HostedRuntimeLoad, projectId: string): RuntimeDashboardSnapshot { - return runtimeSnapshotWhere(load, (observation) => !projectId || observation.project_id === projectId); -} - -/** The snapshot of only the Runtimes `keep` accepts, e.g. those placed on one node. */ -export function runtimeSnapshotWhere(load: HostedRuntimeLoad, keep: (observation: OwnedRuntimeObservation) => boolean): RuntimeDashboardSnapshot { - const observations = load.observations.filter(keep); + const observations = load.observations.filter((observation) => !projectId || observation.project_id === projectId); return { observations, sessions: observations.flatMap((observation) => { diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index d88debfbd..3b53da0f3 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -83,7 +83,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale {suspends ? : null} - {suspends ? : null} + {suspends ? : null} {suspends ? : null} 0 ? "warning" : undefined} value={count(node.cleanup_pending)} /> diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 6b551c767..0dab5e6f8 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -81,7 +81,7 @@ export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspend {t("Status")} {t("Active / limit")} {suspends ? {t("Suspended")}{t("Core records these sandboxes as suspended. Their next Turn requests a resume when compatible capacity is available. They count toward the retained limit, not the active one.")} : null} - {suspends ? {t("Retained / limit")}{t("The most sandboxes this node keeps, running and suspended together. A suspended one resumes here on its next Turn.")} : null} + {suspends ? {t("Retained / limit")}{t("The most sandboxes this node holds at once: starting, running and suspended.")} : null} {t("Last seen")} {t("Added")} {t("Actions")} diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index 31bf31443..bb6f003a1 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -11,7 +11,7 @@ import { useFailureToast, useToast } from "../../components/Toast"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; import { sandboxAdmin } from "./sandbox-queries"; -import { useSandboxPageState } from "./use-sandbox-page-state"; +import { useSandboxDeploymentState } from "./use-sandbox-page-state"; import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; @@ -46,7 +46,7 @@ function DeploymentConfiguration() { const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { navigate } = useConsoleNavigation(); const queryClient = useQueryClient(); - const { deploymentQuery, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh, configurationKey } = useSandboxPageState(); + const { deploymentQuery, deployment, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh, configurationKey } = useSandboxDeploymentState(); const error: unknown = deploymentQuery.error; const toast = useToast(); const [writeFailure, setWriteFailure] = useState<{ error: unknown; open: boolean; editing: boolean } | null>(null); @@ -61,7 +61,7 @@ function DeploymentConfiguration() { if (result.isError && result.data) toast.show(t("Refresh failed; showing the last loaded state."), { tone: "error", detail: sandboxRequestError(result.error, locale), key: "sandbox-read" }); }); }; - useFailureToast(snapshot && deploymentQuery.isError ? sandboxRequestError(deploymentQuery.error, locale) : null, t("Refresh failed; showing the last loaded state."), "sandbox-read"); + useFailureToast(deployment && deploymentQuery.isError ? sandboxRequestError(deploymentQuery.error, locale) : null, t("Refresh failed; showing the last loaded state."), "sandbox-read"); // The shared mutation owner outlives this page. Leaving only suppresses local UI effects. async function changeDeployment(operation: (signal: AbortSignal) => Promise, fromWizard = false, editing = false): Promise { @@ -69,8 +69,8 @@ function DeploymentConfiguration() { if (!controller || busy || loading || setupNeedsRefresh || !fresh) return false; setWriteFailure(null); try { - const deployment = await writeSandboxDeployment(queryClient, operation); - return deployment !== null && !controller.signal.aborted; + const saved = await writeSandboxDeployment(queryClient, operation); + return saved !== null && !controller.signal.aborted; } catch (error) { if (!controller.signal.aborted) { if (fromWizard && sandboxConfigurationRejection(error) !== null) throw error; @@ -84,7 +84,7 @@ function DeploymentConfiguration() { if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && deploymentContract.providers[input.provider].mode === "nodes" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); } async function update(input: UpdateSandboxDeployment) { - return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true, true); + return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: deployment!.generation }, { signal }), true, true); } const startReset = (input: StartSandboxReset) => changeDeployment((signal) => sandboxAdmin.startReset(input, { signal })); const cancelReset = (expectedGeneration: number) => changeDeployment((signal) => sandboxAdmin.cancelReset(expectedGeneration, { signal })); @@ -92,28 +92,28 @@ function DeploymentConfiguration() {

{sandboxRequestError(writeFailure.error, locale)}

{t("Refresh sandbox state to confirm whether the change was saved before submitting again.")}

: null; - const configured = snapshot?.deployment.provider || snapshot?.deployment.reset ? snapshot.deployment : null; + const configured = deployment?.provider || deployment?.reset ? deployment : null; const disabled = busy || loading || !fresh || setupNeedsRefresh; const reset = configured ? : null; const sections = [ configured ? : null, - , + configured?.provider ? : null, ]; return <> - {snapshot?.deployment.mode === "nodes" ? : null} + {deployment?.mode === "nodes" ? : null} } />
- {!snapshot && (loading || deploymentQuery.isPending) ? : null} - {snapshot && deploymentQuery.isError ?

{t("Refresh failed; showing the last loaded state.")}

: null} + {!deployment && (loading || deploymentQuery.isPending) ? : null} + {deployment && deploymentQuery.isError ?

{t("Refresh failed; showing the last loaded state.")}

: null} {setupNeedsRefresh ?

{t("Refresh sandbox state to confirm whether the change was saved before submitting again.")}

: null} {busy ? {t("Saving sandbox change…")} : null} - {!snapshot && error !== null ? : null} - {snapshot && !configured ? : null} + {!deployment && error !== null ? : null} + {deployment && !configured ? : null} {configured?.reset ? [reset, ...sections] : [...sections, reset]}
setWriteFailure((failure) => failure && { ...failure, open: false })}> diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 24eb13177..6aaa8c9e8 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -4,20 +4,13 @@ .sandbox-manager h1 { margin: 0 0 8px; } .sandbox-manager h2 { font-size: 15px; font-weight: 600; margin: 0; } .sandbox-manager .console-page-body p, .sandbox-add-node p { color: var(--fg-muted); line-height: 1.6; margin: 0; } -.sandbox-actions { display: flex; align-items: center; gap: 12px; flex-shrink: 0; flex-wrap: wrap; } .sandbox-status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; } .sandbox-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 20px; margin: 0; } .sandbox-summary > div { min-width: 0; } .sandbox-summary dt { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; } .sandbox-summary dd { margin: 0; color: var(--ink); font-size: 13px; overflow-wrap: anywhere; } .sandbox-error { color: var(--danger) !important; } -.sandbox-empty { display: grid; justify-items: center; gap: 12px; text-align: center; border: 1px dashed var(--line); border-radius: 12px; padding: 56px 24px; color: var(--fg-muted); } -.sandbox-empty h3 { color: var(--fg); font-size: 16px; margin: 0; font-weight: 500; } -.sandbox-empty p { max-width: 320px; font-size: 13px; } .sandbox-enrollment { max-width: 580px; padding: 24px; border: 1px solid var(--line); border-radius: 12px; } -.sandbox-network-settings { font-size: 12px; } -.sandbox-network-settings summary { margin-bottom: 12px; color: var(--fg-muted); } -.sandbox-network-settings p { margin-top: 10px; font-size: 12px; } .sandbox-command { min-width: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--surface); } .sandbox-command-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-bottom: 1px solid var(--line); } .sandbox-command-heading > span { display: inline-flex; align-items: center; gap: 7px; color: var(--fg-muted); font-size: 12px; } @@ -101,9 +94,5 @@ .sandbox-rollout h3 { margin: 0; font-size: 13px; font-weight: 600; } .sandbox-rollout p { margin: 0; font-size: 13px; color: var(--fg-muted); } .sandbox-rollout > .text-action { align-self: flex-start; } -.sandbox-rollout-compact .sandbox-summary { display: flex; flex-wrap: wrap; gap: 8px 24px; } -.sandbox-rollout-compact .sandbox-summary > div { display: flex; align-items: baseline; gap: 6px; } -.sandbox-rollout-compact .sandbox-summary dt { margin: 0; } -.sandbox-rollout-nodes { border-top: 1px solid var(--line); padding-top: 12px; } .node-statuses { display: grid; gap: 4px; } diff --git a/apps/web/src/features/sandbox/SuspensionSettings.tsx b/apps/web/src/features/sandbox/SuspensionSettings.tsx index 8a81d32a7..37404433e 100644 --- a/apps/web/src/features/sandbox/SuspensionSettings.tsx +++ b/apps/web/src/features/sandbox/SuspensionSettings.tsx @@ -9,7 +9,6 @@ import { sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-la import { Fact } from "../system/Fact"; import { sandboxAdmin } from "./sandbox-queries"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; -import { useSandboxPageState } from "./use-sandbox-page-state"; function RetentionHelp({ deployment }: { deployment: SandboxDeployment }) { const { t } = useTranslation("sandbox"); @@ -17,13 +16,11 @@ function RetentionHelp({ deployment }: { deployment: SandboxDeployment }) { } /** Settings edits the deployment-wide idle time; each node's limits are on Nodes. */ -export function SuspensionSettings() { +export function SuspensionSettings({ deployment, disabled }: { deployment: SandboxDeployment; disabled: boolean }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; - const { snapshot, loading, busy, setupNeedsRefresh, fresh } = useSandboxPageState(); const queryClient = useQueryClient(); const toast = useToast(); - const disabled = busy || loading || !fresh || setupNeedsRefresh || Boolean(snapshot?.deployment.reset); async function save(seconds: number) { if (disabled) return false; try { @@ -33,8 +30,7 @@ export function SuspensionSettings() { return false; } } - if (!snapshot?.deployment.provider) return null; - return ; + return ; } /** The idle time form and Core's read-only snapshot retention. */ diff --git a/apps/web/src/features/sandbox/use-sandbox-manager-state.ts b/apps/web/src/features/sandbox/use-sandbox-manager-state.ts deleted file mode 100644 index 953fae27e..000000000 --- a/apps/web/src/features/sandbox/use-sandbox-manager-state.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { sandboxDeploymentQuery, sandboxResetPollInterval, sandboxSnapshotMatchesDeployment, sandboxSnapshotQuery, type SandboxSnapshot } from "./sandbox-queries"; -import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; - -/** All pages share deployment truth. Nodes are supplemental evidence from the same lifecycle. */ -export function useSandboxManagerState({ poll = false }: { poll?: boolean } = {}) { - const ownership = useQuery(sandboxWriteOwnershipQuery); - const deploymentQuery = useQuery({ ...sandboxDeploymentQuery, refetchOnMount: "always" }); - const query = useQuery({ ...sandboxSnapshotQuery, refetchInterval: sandboxResetPollInterval(deploymentQuery.data) || (poll ? 30_000 : false) }); - const inventory = query.data; - const deployment = deploymentQuery.data; - const compatible = Boolean(inventory && deployment && sandboxSnapshotMatchesDeployment(inventory, deployment)); - // An arrival intent must wait for its supplemental read, rather than treating - // absent/previous-lifecycle inventory as a confirmed inability to add a node. - const inventoryLoading = query.isPending || query.isFetching || (!compatible && !query.isError); - const inventoryOlder = compatible && inventory!.deployment.generation !== deployment!.generation; - const { refetch } = query; - useEffect(() => { - if (inventory && deployment && (!compatible || inventoryOlder || inventory.deployment.rollout.state !== deployment.rollout.state)) void refetch(); - }, [compatible, inventoryOlder, deployment?.installation_id, deployment?.owner_epoch, deployment?.generation, deployment?.provider, deployment?.reset?.requested_at, deployment?.reset?.clear, deployment?.reset?.forced_at, deployment?.rollout.state, refetch]); - const snapshot: SandboxSnapshot | null = deployment ? { - deployment, - nodes: compatible ? inventory!.nodes : [], - allocations: compatible ? inventory!.allocations : [], - nodesError: query.isError ? query.error : compatible ? inventory!.nodesError : null, - readAt: compatible ? inventory!.readAt : 0, - } : null; - return { ownership, deploymentQuery, query, deployment, compatible, inventoryOlder, inventoryLoading, snapshot }; -} diff --git a/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx similarity index 98% rename from apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx rename to apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx index 0af322602..8ce16999d 100644 --- a/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx +++ b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx @@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { node } from "../overview/test-fixtures"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; -import { useSandboxManagerState } from "./use-sandbox-manager-state"; +import { useSandboxPageState } from "./use-sandbox-page-state"; const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; const resetting: SandboxDeployment = { ...configured, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; @@ -15,7 +15,7 @@ function cache() { return client; } function Probe() { - const state = useSandboxManagerState(); + const state = useSandboxPageState(); return
{state.snapshot?.deployment.provider || "setup"}/{state.snapshot?.deployment.reset?.clear ?? "none"}/{state.snapshot?.nodes.length ?? 0}
; diff --git a/apps/web/src/features/sandbox/use-sandbox-page-state.ts b/apps/web/src/features/sandbox/use-sandbox-page-state.ts index f1532e5fb..e3beefd27 100644 --- a/apps/web/src/features/sandbox/use-sandbox-page-state.ts +++ b/apps/web/src/features/sandbox/use-sandbox-page-state.ts @@ -1,21 +1,56 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { installationQuery } from "../../lib/installation"; -import { useSandboxManagerState } from "./use-sandbox-manager-state"; +import { sandboxDeploymentQuery, sandboxResetPollInterval, sandboxSnapshotMatchesDeployment, sandboxSnapshotQuery, type SandboxSnapshot } from "./sandbox-queries"; +import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; -/** Shared observations and write admission survive navigation through the query cache. */ -export function useSandboxPageState(options: { poll?: boolean } = {}) { - const state = useSandboxManagerState(options); - const queryClient = useQueryClient(); +/** Deployment truth and write admission survive navigation through the query cache. Settings reads only these. */ +export function useSandboxDeploymentState() { + const ownership = useQuery(sandboxWriteOwnershipQuery); + const deploymentQuery = useQuery({ ...sandboxDeploymentQuery, refetchOnMount: "always" }); const installation = useQuery(installationQuery); - const { ownership, deploymentQuery, deployment, query } = state; + const queryClient = useQueryClient(); + const deployment = deploymentQuery.data; const loading = deploymentQuery.isFetching; const busy = ownership.data.phase === "pending"; const setupNeedsRefresh = ownership.data.phase === "reconcile"; const confirmed = deployment !== undefined && !deploymentQuery.isError && ownership.data.phase === "idle"; const fresh = confirmed && !loading; - const { refetch: refetchDeployment } = deploymentQuery; + const { refetch } = deploymentQuery; + const refresh = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); + return refetch(); + }, [queryClient, refetch]); + const configurationKey = deployment + ? `${deployment.installation_id}:${deployment.owner_epoch}:${deployment.provider}:${deployment.mode}:${deployment.generation}` + : undefined; + return { ownership, deploymentQuery, deployment, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh, configurationKey }; +} + +/** Nodes adds this lifecycle's node inventory, supplemental evidence beside the deployment. */ +export function useSandboxPageState({ poll = false }: { poll?: boolean } = {}) { + const state = useSandboxDeploymentState(); + const queryClient = useQueryClient(); + const { deploymentQuery, deployment } = state; + const query = useQuery({ ...sandboxSnapshotQuery, refetchInterval: sandboxResetPollInterval(deployment) || (poll ? 30_000 : false) }); + const inventory = query.data; + const compatible = Boolean(inventory && deployment && sandboxSnapshotMatchesDeployment(inventory, deployment)); + // An arrival intent must wait for its supplemental read, rather than treating + // absent/previous-lifecycle inventory as a confirmed inability to add a node. + const inventoryLoading = query.isPending || query.isFetching || (!compatible && !query.isError); + const inventoryOlder = compatible && inventory!.deployment.generation !== deployment!.generation; const { refetch: refetchSnapshot } = query; + useEffect(() => { + if (inventory && deployment && (!compatible || inventoryOlder || inventory.deployment.rollout.state !== deployment.rollout.state)) void refetchSnapshot(); + }, [compatible, inventoryOlder, deployment?.installation_id, deployment?.owner_epoch, deployment?.generation, deployment?.provider, deployment?.reset?.requested_at, deployment?.reset?.clear, deployment?.reset?.forced_at, deployment?.rollout.state, refetchSnapshot]); + const snapshot: SandboxSnapshot | null = deployment ? { + deployment, + nodes: compatible ? inventory!.nodes : [], + allocations: compatible ? inventory!.allocations : [], + nodesError: query.isError ? query.error : compatible ? inventory!.nodesError : null, + readAt: compatible ? inventory!.readAt : 0, + } : null; + const { refetch: refetchDeployment } = deploymentQuery; const refetch = useCallback(async () => { const result = await refetchDeployment(); if (!result.isError) await refetchSnapshot(); @@ -25,8 +60,5 @@ export function useSandboxPageState(options: { poll?: boolean } = {}) { void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); return refetch(); }, [queryClient, refetch]); - const configurationKey = deployment - ? `${deployment.installation_id}:${deployment.owner_epoch}:${deployment.provider}:${deployment.mode}:${deployment.generation}` - : undefined; - return { ...state, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refetch, refresh, configurationKey }; + return { ...state, query, compatible, inventoryOlder, inventoryLoading, snapshot, refetch, refresh }; } diff --git a/apps/web/src/features/settings/settings.css b/apps/web/src/features/settings/settings.css index 95d37a6e8..8cf3e019d 100644 --- a/apps/web/src/features/settings/settings.css +++ b/apps/web/src/features/settings/settings.css @@ -158,7 +158,6 @@ .settings-table .data-table tbody tr:last-child > * { border-bottom: 0; } .settings-table .data-table tbody th { font-weight: 500; text-align: left; } -.settings-table .data-table .settings-table-action { width: 1%; text-align: right; white-space: nowrap; } /* The irreversible operation closes the page, apart from the settings above it. */ .settings-danger { diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index f7ae6dbb9..4d1d1013a 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -204,6 +204,7 @@ export const metrics = { build: { ready: "Ready", notReady: "Not ready", unknown: "Unknown" }, }, node: "Node", + openNode: "Open node {{name}}", status: "Status", notSetUpTitle: "Sandboxes are not set up yet", setUp: "Set up sandboxes", @@ -249,8 +250,8 @@ export const metrics = { historyBudget: "Charts cover at most {{limit}} Sessions.", historyMixed: "Sessions returned different bucket widths.", }, - runtimeMeta: "{{running}} of {{n}} running · CPU {{cpu}} · memory {{memory}}", - runtimeMetaSuspended: "{{running}} of {{n}} running · {{sleeping}} suspended · CPU {{cpu}} · memory {{memory}}", + runtimeMeta: "{{active}} of {{n}} active · CPU {{cpu}} · memory {{memory}}", + runtimeMetaSuspended: "{{active}} of {{n}} active · {{sleeping}} suspended · CPU {{cpu}} · memory {{memory}}", reason: { allocation_pending: "Allocation pending", runtime_not_running: "Not running", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 3e1bceb51..057ae2cb2 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -204,6 +204,7 @@ export const metrics = { build: { ready: "就绪", notReady: "未就绪", unknown: "未知" }, }, node: "节点", + openNode: "打开节点 {{name}}", status: "状态", notSetUpTitle: "还没有设置沙箱", setUp: "设置沙箱", @@ -249,8 +250,8 @@ export const metrics = { historyBudget: "图表最多覆盖 {{limit}} 个 Session。", historyMixed: "各 Session 返回的时间桶宽度不一致。", }, - runtimeMeta: "{{n}} 个中 {{running}} 个运行中 · CPU {{cpu}} · 内存 {{memory}}", - runtimeMetaSuspended: "{{n}} 个中 {{running}} 个运行中 · {{sleeping}} 个挂起 · CPU {{cpu}} · 内存 {{memory}}", + runtimeMeta: "{{n}} 个中 {{active}} 个活跃 · CPU {{cpu}} · 内存 {{memory}}", + runtimeMetaSuspended: "{{n}} 个中 {{active}} 个活跃 · {{sleeping}} 个挂起 · CPU {{cpu}} · 内存 {{memory}}", reason: { allocation_pending: "等待分配", runtime_not_running: "未运行", diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index 3045ae9d7..8c097db53 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -117,7 +117,7 @@ export const sandbox = { "Change provider or resources": "修改运行后端或资源", "Sandbox backend": "沙箱后端", "Core records these sandboxes as suspended. Their next Turn requests a resume when compatible capacity is available. They count toward the retained limit, not the active one.": "Core 将这些沙箱记录为已挂起。下一个 Turn 会在兼容容量可用时请求恢复。它们占用保留名额,不占活跃名额。", - "The most sandboxes this node keeps, running and suspended together. A suspended one resumes here on its next Turn.": "这台节点最多保留的沙箱数,运行中的和挂起的合计。挂起的沙箱在下一个 Turn 时在这里恢复。", + "The most sandboxes this node holds at once: starting, running and suspended.": "这台节点同时最多容纳的沙箱数,包括启动中、运行中和已挂起的。", "Edit node": "编辑节点", "Name": "名称", "Save": "保存", diff --git a/docs/web/index.md b/docs/web/index.md index 02d990584..19c1c8a98 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -19,7 +19,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Monitor | Overview | Service status, running Sessions, sandbox slots and work needing attention; 24-hour Session activity; Core and its nodes as a topology, each opening its page; Needs attention: failed Sessions and Sessions waiting for their caller, active in the last 24 hours, each with its one-line reason; usage by Project | | Monitor | Core metrics | Whether Core can run Turns: execution slots, the Turn queue and its p95 wait, interrupted Turns, connected daemons, database latency, CPU and resident memory against their limits, background jobs; Go heap, goroutines and the database pool under **Diagnostics** | | Monitor | Agent metrics | Requests, errors, duration, tokens, models, tools, Agents and API keys over 1 h, 6 h, 24 h or 7 d | -| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, running against total, with their CPU and memory observations and, when supported, **Recorded compute** lifecycle state shown separately; an E2B deployment adds the sandboxes Core holds in E2B's cloud | +| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, active against total, with their CPU and memory observations and, when supported, **Recorded compute** lifecycle state shown separately; an E2B deployment adds the sandboxes Core holds in E2B's cloud | | Monitor | Request log | Recent HTTP requests to Core, their status, elapsed time and safe error codes; [scope and retention](../../contracts/agents-api/admin-api.md#request-log) | | Monitor | Session log | Every Session, and each Session's read-only conversation, trace and Turns with the classified reason of a failure and its next step; a self-hosted Session's page also manages its executor credentials and gives the command that connects a host | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index 7b75d3adf..b4f377b7f 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "OpenAgentCore Web" source: docs/web/index.md -source_hash: 1e5c6491ca2db026042091d5510db1277e2abf9519fda9adf9f89e83ac507251 +source_hash: 6a336cfbec02e451ab41d5addb9744755ee60ef11da5121e0ab7abee61e45244 --- Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看健康状态、容量、用量和失败情况,检查各 Project 的资源与执行历史,并管理 Project、密钥、节点和部署设置。应用不使用 Web;它们通过自己的 Project API 密钥调用 Core 的 Agents API(`/v1`)。 @@ -21,12 +21,12 @@ Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看 | Monitor | Overview | 服务状态、运行中的 Session、沙箱槽位和待处理工作;24 小时 Session 活动;以拓扑展示 Core 与节点,各自打开对应页面;需要处理:过去 24 小时内活跃的失败或等待调用方的 Session,每行附一行原因;按 Project 统计的用量 | | Monitor | Core metrics | Core 能否运行 Turn:执行槽位、Turn 队列及其 p95 等待时间、被中断的 Turn、已连接守护进程、数据库延迟、CPU 与常驻内存及其限制、后台任务;Go 堆、goroutine 和数据库连接池位于 **Diagnostics** | | Monitor | Agent metrics | 1 小时、6 小时、24 小时或 7 天的请求、错误、耗时、token、模型、工具、Agent 和 API 密钥 | -| Monitor | Sandbox metrics | 各 Project 托管 Runtime 随时间的变化,显示运行中数量与总数及其 CPU、内存观测;支持时另行显示 **Recorded compute** 生命周期状态;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | +| Monitor | Sandbox metrics | 各 Project 托管 Runtime 随时间的变化,显示活跃数量与总数及其 CPU、内存观测;支持时另行显示 **Recorded compute** 生命周期状态;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | | Monitor | Request log | 到达 Core 的近期 HTTP 请求、状态、耗时和安全错误码;[范围及保留方式](../../../contracts/agents-api/zh/admin-api.md#request-log) | | Monitor | Session log | 全部 Session;各 Session 的只读对话、跟踪、Turn、失败分类原因及下一步操作;自托管 Session 页面还管理执行器凭据并提供主机连接命令 | | Resources | Agents、Environment templates、Skills、Files、Vaults | 检查和允许的删除操作,显示各资源所属 Project 及创建密钥 | | Platform | Projects and keys | 创建、重命名和归档 Project;签发与撤销密钥;各 Project 的用量、写入历史和 API 调用方法 | -| Platform | Nodes | 查看和移除 microsandbox 或 Docker 节点;各节点的就绪状态、容量和分配,以及支持时的自动挂起策略、活跃与保留名额、各计算阶段的已用时间 | +| Platform | Nodes | 添加、编辑和移除 microsandbox 或 Docker 节点;各节点的就绪状态、活跃与保留上限、主机 CPU 与内存及其历史、可用磁盘,以及分配及其在各计算阶段的已用时间 | | Platform | Settings | **Sandbox and scheduling**、**Workspace storage**、**Models and credentials** 和 **Service and deployment**;参阅[配置](../configuration.md#runtime-settings-web) | 缺失数据展示为缺失(—),不会当作零。[控制台 API 使用](console-api-usage.md)列出各页面读取内容及统计边界。