diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a1567df99..e745d1c76 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -367,10 +367,10 @@ Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header - **ConfirmDialog**: the one grammar for destructive actions. The body states what will be deleted and its consequences; the footer holds Cancel (outline) and the confirm button (danger), whose label changes while busy. Core's reason for a rejection, or an uncertain-outcome warning, appears in red inside the dialog. The Skill page's delete dialogs follow the same grammar; deleting a whole Skill also requires typing its name. Archiving a project says in bold that it can't be undone, then how many active keys it revokes (the project read's count, or more when its loaded key list shows more) and that assets and accepted work stay; with active keys it too requires typing the project's name, shown in mono with its inner spaces kept (surrounding spaces are forgiven, Unicode compared in NFC). While the project list is read again Archive waits; if that read failed, a red line says the count may be out of date and Archive stays disabled. - **Key dialogs**: name fields carry their rules in a help tip and their problem in red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. - **Executor credential dialog** (640px): the shown-once notice, then a prompt to save the JSON privately before Done. Download credential file is primary; Copy credential is secondary. Installation commands are not repeated here. Done forgets the credential; closing preserves it in the pending card. The native installer reads the unchanged JSON file: its absolute path is entered during interactive installation or passed with `--credential-file`; tokens never enter command arguments. -- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to System or the Overview. +- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to Settings → Models and credentials or the Overview. - **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images; the uninstaller prints what it keeps. The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. - **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox its own microVM), root-equivalent access (the node's account joins the docker group) and limited use (trusted workloads, or hosts without KVM). The footer holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; closing or Escape keeps microsandbox too. -- **Edit node**: the name, then the sandbox limit with one 12px Graphite line under it once the node's heartbeat has the host's CPUs and memory: the host, each sandbox's size and at most how many fit. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. +- **Edit node**: the name, then the sandbox limit with a question-mark help tip once the node's heartbeat has the host's CPUs and memory: the host, each sandbox's size and at most how many fit. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then curl and Python (with the pinned SDK), each listing the project's Agents and creating a Session with a first message (`environment`, an inline `agent` with `model: ""`, and `input`). A copy the clipboard refuses selects the sample and says so in red underneath. One Graphite line says to put a model the model provider serves in place of ``, and that running an Agent needs a model provider: in each request, saved on the Agent, or the deployment default. An active project's page shows the same samples as a section without any key: the Shell block exports a quoted placeholder, and a Graphite line above the samples says to use a key issued for this project, shown only once at issuance. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without a public address only a note to set one shows. Before the installation is read, a skeleton holds the first sample's place. ### Navigation @@ -399,16 +399,16 @@ On Overview and Session log, failed reads that leave a section unavailable repla A failed action whose outcome needs a decision (a sandbox change with no answer, a timeout or a 5xx) opens an error dialog with the reason and the next step as its primary button. Failed refreshes and project reads also raise an error toast; other failed actions, Core's clear refusal of a sandbox change among them, are reported there with the reason. A refusal leaves the page usable as it was. Errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline ring, 8px corners, 12.5px Graphite) state bounded aggregation. Standing warnings that need action use an amber-tinted line at the top of the page body. On Nodes, this names nodes still bound to an old Core address; each of those nodes' status reads Old address (amber dot) with "Remove and add again" under it in 12px Graphite. Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key shown once, a destructive consequence) stay visible in body text. -A local-only installation has the same amber notice on Overview, Nodes and System: other machines cannot connect, followed by Review the public address, which leads to System. Add node is disabled with its reason beside the action, and Getting started leaves its first step to do with the address fix visible. A pending or failed installation read cannot complete that step; a failed read shows Unknown and Retry. +A local-only installation has the same amber notice on Overview, Nodes and Settings: other machines cannot connect, followed by Review the public address, which leads to Settings → Service and deployment. Add node is disabled with its reason beside the action, and Getting started leaves its first step to do with the address fix visible. A pending or failed installation read cannot complete that step; a failed read shows Unknown and Retry. ### Onboarding Signing in and the console tour share one frame: a dark stage on the left (always dark, whatever the theme) and the task panel on the right, which follows the theme. The stage is the product's one authored moment: a flickering indigo dot grid under slow light rays (Magic UI's flickering grid and light rays), Core as the OpenAgentCore mark on a tile with a travelling border beam, and two orbits of Agents, Sessions, Skills, Vaults, files, templates and machines around it; the OpenAgentCore mark is itself nodes on a ring. Brand copy sits bottom-left in solid ink; it is a paragraph, not a heading, because the panel's title names the task. Signing in asks for one thing, the deployment's Core key, in a single password field; a copyable Docker Compose command to read the key stays visible beneath it, with a reminder to substitute a custom installation directory. The key’s authority stays in a help tip. A refused key, too many attempts or an unavailable console is an error beside the field. Signing in opens the console on the Overview. The optional tour has three chapters — Monitor, Resources, Platform — whose stage shows a real dark screenshot of those pages, tilted towards the panel; it takes the place of the console until its last button, Skip or Escape, and then returns the focus to the control that opened it. Entering the console or the tour, and leaving the tour, happen inside a View Transition: the old page dissolves forward and the new one is revealed in a circle growing from the pressed button. With reduced motion the orbits hold their places, the grid is a still frame and no transition runs. ### Getting started -The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open System; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open System brings the Default model provider section to the top of the page body and focuses the default harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. +The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open Settings; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open Settings brings the Models and credentials tab to the top of the page body and focuses the default harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in System, which leads to System. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Sandbox lifecycle @@ -426,11 +426,19 @@ 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. -### System page -Four sections; the page help says where sandboxes and startup settings change. Installation: the public address, API base URL, installation ID and source commit as a fact card, with an outline action that opens the Domain and HTTPS secondary page. Default model configuration, the one section changed here: one card per harness in an auto-fill grid, its header holding the harness name and outline actions (Set, or Replace and Clear); fact rows give the harness's read-only startup state (a status dot and a Default pill, its source behind a help tip), then the default model ID, provider protocol, base URL, whether a key is configured, token limits when set and the update time, or Not set. Set and Replace open one form dialog. The model ID is required; advanced settings disclose an optional JSON object editor with formatting and inline syntax errors, plus token limits. The harness list supplies supported protocols, native protocols, JSON support and required limits from one adapter declaration. The form uses those fields without harness-specific branches. Nonempty JSON requires a native protocol; the form explains an incompatible selection beside the editor. Help tips explain the scope of native settings. Changing the model ID, provider URL or protocol clears the native JSON so settings cannot follow an unrelated model by accident. Re-entering the required write-only API key alone does not change model identity. The key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. Sandboxes: one navigation row to the Sandbox configuration secondary page; do not repeat its configuration facts on System. Startup settings: a line saying these are the settings Core loaded, over a table of each setting, its value and the services a change restarts. Sensitive settings show only Configured or Not set; Default and Fixed after install are neutral pills beside the value. +### 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. Keep the page header and tab row consistent. Forms save independently through their resource APIs, never through one cross-resource Save all action. + +Sandbox and scheduling contains sandbox setup, resource edits, the rollout row and explicit reset, followed by Capacity and suspension with the deployment-wide idle policy and Sandbox nodes with per-node limits. 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. + +Workspace storage shows the selected immutable configuration and tenant capacity. 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. + +Models and credentials has one default-model card per harness in an auto-fill grid, with Set, Replace and Clear actions. Facts include read-only startup state, model ID, provider protocol, base URL, key presence, token limits when set and update time. Set and Replace open one form dialog. The adapter declaration supplies supported protocols, native JSON support and required limits without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately. + +Service and deployment shows installation addresses, installation ID and source commit, existing Domain and HTTPS actions, and the startup settings Core loaded. Process settings remain read-only here; their source stays the installation configuration. Sensitive settings show only Configured or Not set. Default and Fixed after install use neutral pills. ### One place for each task -A configuration or operation has one home. Other pages link to it instead of repeating the same panel. System links to the Sandbox configuration secondary page; Nodes contains node management. Keep the configuration page flat: the resource editor is a dialog, and rollout is one status row with a details action. Put low-frequency counts and generation metadata in that dialog. Explanatory prose belongs in help tips, not rows of small print. Keep actionable errors and unresolved state visible without duplicating the whole workflow. +Each configuration has exactly one editor in Settings; other pages link to it. Resource operations such as Project keys, Session executor credentials and node removal stay on their resource pages. Keep the configuration page flat: resource editors are dialogs and rollout is one status row with a details action. Explanations belong in question-mark help tips, not small-print helper paragraphs. Errors, uncertain outcomes and current usage exceeding a new limit remain visible without duplicating the workflow. ### Diagnostic observations Failure reasons belong beside the failed Session or Turn status. Their first read uses a skeleton; an unavailable reason names that state and puts the read retry beside its help tip. Technical classifications are translated through one catalogue, not shown as raw error codes. Trace Timing labels Core receipt times separately from tool-reported duration; explanations of batched delivery, clock differences and historical gaps live in help tips. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 535b42427..0b924e7c5 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -31,21 +31,22 @@ The console runs beside the administrator's own Core, with execution, files and - **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 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 attention table, 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), 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. -- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity, host figures, allocations and individual node operations). Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Removal offers the host's uninstall command. System owns installation facts, the Domain and HTTPS secondary page, each harness's default model configuration, startup settings, and a link to the Sandbox configuration secondary page. That page owns setup, resource edits, rollout details and reset. Setup selects a backend, size and Runtime, then asks for a deliberate save; own-machine setup continues to Add node. +- **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. Service and deployment owns installation facts, Domain and HTTPS actions and read-only startup settings. - 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 System → Sandbox configuration. 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. +- **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 System's sandbox configuration. 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. Nodes owns one **Capacity and suspension** area: deployment-wide idle seconds are editable there alongside per-node active and retained limits; 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. +- **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. - **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, API key). The sign-in credential is the Core key ("Core Key"); keys issued in a project for applications are project API keys ("项目 API Key"). 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. 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.** System owns sandbox configuration through its Sandbox configuration secondary page. This is the only place to set up, update, reset or inspect deployment rollout. Nodes owns capacity and automatic suspension policy, the node list and individual node operations. Overview and metrics link to these owners instead of repeating their configuration or rollout panels. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. +- **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. +- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the existing storage API. 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. - **Secrets stay write-only.** Credential tokens, Template environment variables and setup commands are never returned, to the administrator included. An Agent's saved model provider shows its protocol, base URL, limits and whether a key is configured, never the key. @@ -59,7 +60,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. -- **Local-only address.** Overview, Nodes and System warn when Core reports `local_only` and lead to System to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. +- **Local-only address.** Overview, Nodes and Settings warn when Core reports `local_only` and lead to Settings → Service and deployment to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. - Runtime CPU and memory exist only for Core-managed hosted sandboxes. - Preserve workflow safety: confirmed deletion, no automatic retry of uncertain writes, no secrets in browser storage. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index f8d48a28d..92e684f9f 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -109,13 +109,13 @@ test("leads from Getting started to the default model configuration, and counts await openConsole(page, request, "overview", { fresh: true }); const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Set a default model configuration" }); await expect(step).toContainText("To do"); - await step.getByRole("button", { name: "Open System" }).click(); - await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeVisible(); + await step.getByRole("button", { name: "Open Settings" }).click(); + await expect(page.getByRole("heading", { name: "Models and credentials", level: 1 })).toBeVisible(); // It arrives on the default harness's action. const set = page.getByRole("button", { name: "Set the default model configuration for Codex" }); await expect(set).toBeFocused(); // Only the page body scrolls to it: the page header stays in view. - await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeInViewport(); + await expect(page.getByRole("heading", { name: "Models and credentials", level: 1 })).toBeInViewport(); await set.click(); const form = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); await form.getByLabel("Base URL").fill("https://model.example/v1"); @@ -131,10 +131,10 @@ test("shows every page empty on a fresh install, and Getting started hides, come await openConsole(page, request, "overview", { fresh: true, sandbox: "none", nodes: "none" }); // What each page shows once its reads are done: an empty state, Core's own figures, or sandbox setup. const loaded = (view: string) => view === "core-metrics" ? page.locator(".kpi-strip").first() - : view === "nodes" ? page.getByText("Set up sandbox hosting in System before adding nodes.") - : view === "system" ? page.getByRole("button", { name: "Manage sandbox configuration" }) + : view === "nodes" ? page.getByText("Set up sandbox hosting in Settings before adding nodes.") + : view === "settings" ? page.getByRole("heading", { name: "Where should sandboxes run?" }) : page.locator(".console-empty").first(); - for (const view of ["core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "agents", "templates", "skills", "files", "vaults", "projects", "nodes", "system", "overview"]) { + for (const view of ["core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "agents", "templates", "skills", "files", "vaults", "projects", "nodes", "settings", "overview"]) { await page.goto(`/#${view}`); await expect(loaded(view)).toBeVisible(); await expect(page.locator('[aria-busy="true"]')).toHaveCount(0); @@ -154,7 +154,7 @@ test("shows every page empty on a fresh install, and Getting started hides, come const step = checklist.getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); await expect(step).toContainText("To do"); await step.getByRole("button", { name: "Set up sandboxes" }).click(); - await expect(page).toHaveURL(/#system\?id=sandbox$/); + await expect(page).toHaveURL(/#settings\?id=sandbox$/); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); }); @@ -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: "Nodes", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sandbox and scheduling", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); }); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 71582264d..ef6e161fb 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, "nodes", { installation: "local" }); + await openConsole(page, request, "settings?id=sandbox", { 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…"); @@ -186,7 +186,7 @@ test("gives the host's uninstall command even when the installation must be read }); test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); await page.getByRole("button", { name: "Own machines" }).click(); await page.getByRole("button", { name: "microsandbox Recommended" }).click(); @@ -203,15 +203,14 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist expect(tokenRequested).toBe(false); expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); await add.getByRole("button", { name: "Close dialog" }).click(); - await page.getByRole("button", { name: "System", exact: true }).click(); - await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); // The saved specification carries the Runtime read from the console's manifest. await page.getByRole("button", { name: "Configuration details", exact: true }).click(); await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); test("preselects microsandbox and asks once before switching to Docker", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "Own machines" }).click(); const microsandbox = page.getByRole("button", { name: "microsandbox Recommended" }); const docker = page.getByRole("button", { name: "Docker", exact: true }); @@ -243,7 +242,7 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, page.on("request", (sent) => { if (sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/deployment")) submitted = sent.postDataJSON() as Record; }); - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "E2B cloud" }).click(); await expect(page.getByLabel("E2B provider")).toHaveValue("sandbase"); await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://sandbox.sandbase.ai"); @@ -257,7 +256,7 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Save configuration" }).click(); - await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sandbox and scheduling", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); expect(submitted).toMatchObject({ provider: "e2b", configuration: { template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" } , credential: { api_key: "fixture-private-key" } }); expect(await writes(request)).toEqual([ @@ -268,7 +267,7 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, }); test("uses the official E2B preset and clears a selected build when the key changes", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "E2B cloud" }).click(); await page.getByLabel("E2B provider").selectOption("official"); await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://api.e2b.app"); @@ -289,7 +288,7 @@ test("uses the official E2B preset and clears a selected build when the key chan }); test("shows the retained E2B build while a replacement key is checked", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "e2b" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "e2b" }); await page.getByRole("button", { name: "Change resources" }).click(); const edit = page.getByRole("dialog", { name: "Change resources" }); const saved = "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"; @@ -315,7 +314,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async await route.fulfill({ json: deployment }); }); await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ json: { data: [] } })); - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await page.getByRole("button", { name: "Change resources" }).click(); await expect(page.getByRole("button", { name: "Own machines" })).toHaveCount(0); await expect(page.getByRole("button", { name: "E2B cloud" })).toHaveCount(0); @@ -326,7 +325,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async }); test("keeps the page usable when Core refuses a sandbox change, and shows a safe refusal", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 403, message: "This console is read-only." }); await page.getByRole("button", { name: "Own machines" }).click(); await page.getByRole("button", { name: "microsandbox Recommended" }).click(); @@ -345,24 +344,40 @@ test("keeps the page usable when Core refuses a sandbox change, and shows a safe const added = page.getByRole("dialog", { name: "Add node" }); await expect(added).toBeVisible(); await added.getByRole("button", { name: "Close dialog" }).click(); - await page.getByRole("button", { name: "System", exact: true }).click(); - await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Configuration details", exact: true }).click(); await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => { await openConsole(page, request, "nodes"); - // The Nodes policy area owns all per-node capacity edits. - const capacity = page.getByRole("region", { name: "Capacity and suspension" }); + // Nodes links to the sole capacity editor in Settings. + const capacity = page.getByRole("table", { name: "Sandbox nodes" }); await expect(capacity).toContainText("Active / limit"); await expect(capacity).toContainText("5 / 8"); - await capacity.getByRole("row").filter({ hasText: "core-01" }).getByRole("button", { name: "Edit node" }).click(); + await page.getByRole("row").filter({ hasText: "core-01" }).getByRole("button", { name: "Edit node" }).click(); const edit = page.getByRole("dialog", { name: "Edit node" }); await edit.getByLabel("Name").fill("core-01-large"); await edit.getByLabel("Sandboxes at once").fill("6"); await edit.getByRole("button", { name: "Save" }).click(); await expect(edit).toBeHidden(); - await expect(capacity).toContainText("core-01-large"); - await expect(capacity).toContainText("5 / 6"); + 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"); +}); + + +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/overview-readiness.spec.ts b/apps/web/e2e/overview-readiness.spec.ts index 540a37405..434dcec35 100644 --- a/apps/web/e2e/overview-readiness.spec.ts +++ b/apps/web/e2e/overview-readiness.spec.ts @@ -87,7 +87,8 @@ test("local-only address links to System on other pages and blocks Add node", as await expect(notice).toBeVisible(); await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeDisabled(); await expect(page.getByText("Add node is unavailable while the public address is local only.")).toBeVisible(); - await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Service and deployment", exact: true }).click(); await expect(notice).toBeVisible(); await expect(notice.getByRole("button", { name: "Review the public address" })).toHaveCount(0); expect(await writes(request)).toEqual([]); diff --git a/apps/web/e2e/provider-observations.spec.ts b/apps/web/e2e/provider-observations.spec.ts index c45ab1aca..b6700897c 100644 --- a/apps/web/e2e/provider-observations.spec.ts +++ b/apps/web/e2e/provider-observations.spec.ts @@ -13,7 +13,7 @@ test("default provider observations are optional details and recover only on Cor for (const harness of data.data) if (harness.model_configuration) Object.assign(harness.model_configuration, observation); await route.fulfill({ response, json: data }); }); - await openConsole(page, request, "system"); + await openConsole(page, request, "settings?id=models"); const card = page.getByRole("article", { name: "Codex", exact: true }); await expect(card).not.toContainText("Last successful use"); await card.getByRole("button", { name: "Usage details", exact: true }).click(); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 2b3a36a27..7a5e2ace2 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -7,12 +7,12 @@ test.afterEach(async ({ request }) => expectManagementBoundary(request)); test("sets up sandboxes with Core's address read-only, never sending it", async ({ page, request }) => { const bodies: string[] = []; page.on("request", (sent) => { if (sent.url().includes("/core/v1/sandbox/deployment")) bodies.push(sent.postData() ?? ""); }); - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "Own machines" }).click(); await page.getByRole("button", { name: "microsandbox Recommended" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); const review = page.getByRole("definition").filter({ hasText: "https://core.example.com" }); - await expect(review).toContainText("Managed in System"); + await expect(review).toContainText("Managed in Settings"); await expect(page.getByRole("textbox", { name: "Core address" })).toHaveCount(0); await page.getByRole("button", { name: "Save configuration" }).click(); // Saved: own machines continue straight to Add node. @@ -23,7 +23,7 @@ test("sets up sandboxes with Core's address read-only, never sending it", async }); test("explains an E2B rejection in the wizard, with a link to domain setup", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "none", installation: "local" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "none", installation: "local" }); await page.getByRole("button", { name: "E2B cloud" }).click(); await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); @@ -32,7 +32,7 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy await page.getByRole("button", { name: "Save configuration" }).click(); const rejection = page.locator(".wizard-rejection"); await expect(rejection).toContainText("E2B sandboxes need a public HTTPS address."); - await expect(rejection.getByRole("button", { name: "Managed in System" })).toBeVisible(); + await expect(rejection.getByRole("button", { name: "Managed in Settings" })).toBeVisible(); // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); @@ -43,7 +43,7 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy }); test("lists the startup settings on System", async ({ page, request }) => { - await openConsole(page, request, "system"); + await openConsole(page, request, "settings?id=service"); const installation = page.getByRole("region", { name: "Installation" }); await expect(installation).toContainText("https://core.example.com/v1"); const startup = page.getByRole("region", { name: "Startup settings" }); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index e3d56da3e..d280ca9ce 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -54,7 +54,7 @@ test("online configuration retains existing resources and stops rapid polling wh page.on("request", (sent) => { if (sent.method() === "GET" && sent.url().endsWith(deploymentPath)) pending.add(sent); }); page.on("requestfinished", (sent) => pending.delete(sent)); page.on("requestfailed", (sent) => pending.delete(sent)); - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(rollout(page)).toBeVisible(); const before = await inventory(page); expect(before.allocations.length).toBeGreaterThan(0); @@ -108,7 +108,7 @@ test("online configuration retains existing resources and stops rapid polling wh test("unknown target preparation preserves live old-generation service while an offline pin stays offline", async ({ page, request }, info) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); - await openConsole(page, request, "system?id=sandbox", { fresh: true }); + await openConsole(page, request, "settings?id=sandbox", { fresh: true }); await setDeployment(request, { generation: 2 }); await setNode(request, { id: "node-local", online: true, provider_ready: true, rollout: { state: "unknown", ready_generation: 1 } }); await setNode(request, { id: "node-edge", online: false, provider_ready: true, rollout: { state: "unknown", ready_generation: 1 } }); @@ -132,16 +132,14 @@ test("unknown target preparation preserves live old-generation service while an const setup = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); await expect(setup).toContainText("Done"); await expect(rollout(page)).toHaveCount(0); - await page.getByRole("button", { name: "System", exact: true }).click(); - await expect(rollout(page)).toHaveCount(0); - await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); await inspectRollout(page, { "Target generation": "2", "Target readiness unknown": "2", "Node software incompatible": "1" }); await capture(page, info, "generation-configuration-summary-en", rollout(page)); expect(await writes(request)).toEqual([]); }); test("a generation-only change preserves mixed allocation ownership through failed inventory reads", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(rollout(page)).toBeVisible(); let expected: { session: string; generation: string }[] = []; await page.route("**/core/v1/sandbox/nodes/node-local/allocations", async (route) => { @@ -176,7 +174,7 @@ test("a generation-only change preserves mixed allocation ownership through fail }); test("E2B omitted-key updates keep the saved key while explicit same-key replacements each advance generation", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "e2b" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "e2b" }); await expect(rollout(page)).toBeVisible(); const initial = await deploymentRead(page); await editE2B(page); @@ -208,7 +206,7 @@ test("E2B omitted-key updates keep the saved key while explicit same-key replace }); test("a different E2B team leaves the committed configuration intact and requires a deliberate reset", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox", { sandbox: "e2b" }); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "e2b" }); await expect(rollout(page)).toBeVisible(); const before = await deploymentRead(page); await editE2B(page, "fixture-other-team-key"); @@ -234,7 +232,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 }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(rollout(page)).toBeVisible(); const initial = await deploymentRead(page); const committedSize = await fact(page.getByRole("region", { name: "Deployment provider", exact: true }), "Each sandbox").first().innerText(); @@ -284,7 +282,7 @@ test("uncertain configuration refresh and inventory retry preserve a draft until test("an observed installation change discards the old reset confirmation even at the same generation", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(rollout(page)).toBeVisible(); // A genuine preparing projection enables normal five-second observation while // the confirmation is open; the test never clicks through a modal overlay. @@ -307,7 +305,7 @@ test("an observed installation change discards the old reset confirmation even a test("closing an uncertain edit preserves the shared write block until a successful read", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await page.getByRole("button", { name: "Change resources", exact: true }).click(); const edit = page.getByRole("dialog", { name: "Change resources", exact: true }); await edit.getByRole("button", { name: /^Large/ }).click(); diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index d82164a31..c90563edb 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -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: "Capacity and suspension" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Automatic suspension" })).toBeVisible(); await expect(page.getByText("5 minutes", { exact: true })).toBeVisible(); - await expect(page.getByRole("region", { name: "Capacity and suspension" }).getByRole("columnheader", { name: "Retained / limit" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Automatic suspension" }).getByRole("columnheader", { name: "Retained / limit" })).toBeVisible(); 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" }); @@ -44,7 +44,7 @@ test("hides suspension policy when the provider declares no suspension support", test("edits deployment idle time beside node limits without changing generation", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + 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(); diff --git a/apps/web/e2e/sandbox-reset.spec.ts b/apps/web/e2e/sandbox-reset.spec.ts index 4cda22fc3..cb6c1e802 100644 --- a/apps/web/e2e/sandbox-reset.spec.ts +++ b/apps/web/e2e/sandbox-reset.spec.ts @@ -14,8 +14,7 @@ const observedReset = (clear: "auto" | "force" = "auto"): SandboxReset => ({ }); const progress = (page: Page) => page.getByRole("region", { name: "Reset in progress" }); async function openConfiguration(page: Page) { - await page.getByRole("button", { name: "System", exact: true }).click(); - await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); } async function expectEnrollmentBlocked(page: Page) { await page.getByRole("button", { name: "Nodes", exact: true }).click(); @@ -41,7 +40,7 @@ test.afterEach(async ({ request }) => { test("auto reset requires a bounded deadline, then preserves Core progress past that deadline", async ({ page, request }, info) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await setDeployment(request, { resources: { allocations: 3, pending: 1 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); @@ -83,7 +82,7 @@ test("auto reset requires a bounded deadline, then preserves Core progress past }); test("escalation and cancellation require explicit confirmation and never undo previous clearing", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); await page.reload(); await progress(page).getByRole("button", { name: "Force reset now" }).click(); @@ -110,7 +109,7 @@ test("escalation and cancellation require explicit confirmation and never undo p }); test("force reset requires confirmation and reconfiguration uses the completed generation once", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); @@ -133,7 +132,7 @@ test("force reset requires confirmation and reconfiguration uses the completed g }); test("an applied reset with a lost response stays blocked through failed reads and is never replayed", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); @@ -164,7 +163,7 @@ test("an applied reset with a lost response stays blocked through failed reads a }); test("a stale reset is refused until fresh state is reviewed, with no automatic resubmission", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await setDeployment(request, { generation: 2, resources: { allocations: 3, pending: 1 } }); const dialog = await openReset(page); @@ -195,7 +194,7 @@ test("a stale reset is refused until fresh state is reviewed, with no automatic test("reset progress survives failed node and deployment reads with visible qualifications in Chinese dark mode", async ({ page, request }, info) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "dark" }); - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill(unavailable)); await page.reload(); @@ -218,7 +217,7 @@ test("reset progress survives failed node and deployment reads with visible qual }); test("a pending reset survives navigation and a lost response cannot reopen stale write controls", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); @@ -303,7 +302,7 @@ test("Nodes and configuration adopt reset and completion learned on Overview bef await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.getByRole("heading", { name: "Sandbox reset in progress" })).toHaveCount(0); await page.getByRole("button", { name: "Nodes", exact: true }).click(); - await expect(page.getByText("Set up sandbox hosting in System before adding nodes.")).toBeVisible(); + await expect(page.getByText("Set up sandbox hosting in Settings before adding nodes.")).toBeVisible(); await expect(page.getByRole("button", { name: "Add node", exact: true })).toHaveCount(0); await openConfiguration(page); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); @@ -314,7 +313,7 @@ test("Nodes and configuration adopt reset and completion learned on Overview bef test("signing out clears pending reset ownership and a late response cannot overwrite the new login", async ({ page, request }) => { - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); diff --git a/apps/web/e2e/sandbox-rollout-details.spec.ts b/apps/web/e2e/sandbox-rollout-details.spec.ts index 7736a555a..bf9618dd2 100644 --- a/apps/web/e2e/sandbox-rollout-details.spec.ts +++ b/apps/web/e2e/sandbox-rollout-details.spec.ts @@ -11,7 +11,7 @@ test("keeps rollout summary brief and opens all Core observations in details", a const deployment = await response.json(); await route.fulfill({ response, json: { ...deployment, generation: 4, resources: { allocations: 8, pending: 0 }, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } } }); }); - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); const summary = page.getByRole("region", { name: "Configuration rollout", exact: true }); await expect(summary).toContainText("Needs attention"); await expect(summary.locator("dl")).toHaveCount(0); @@ -37,7 +37,7 @@ test("labels unknown target readiness without declaring settled nodes ready", as const deployment = await response.json(); await route.fulfill({ response, json: { ...deployment, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 1 } } } }); }); - await openConsole(page, request, "system?id=sandbox"); + await openConsole(page, request, "settings?id=sandbox"); const summary = page.getByRole("region", { name: "Configuration rollout", exact: true }); await expect(summary).toContainText("Target readiness unknown"); await expect(summary).not.toContainText("No active preparation"); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index 50a77be5e..171e30927 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -8,7 +8,7 @@ const KEY = "sk-fixture-default-model-canary"; test("sets, replaces and clears a harness's default model configuration, and keeps its key out of the browser", async ({ page, request }) => { // A fresh install: no harness has a default model configuration yet. - await openConsole(page, request, "system", { fresh: true }); + await openConsole(page, request, "settings?id=models", { fresh: true }); const section = page.getByRole("region", { name: "Default model configuration" }); const codex = section.getByRole("article", { name: "Codex" }); const mcode = section.getByRole("article", { name: "MiniMax Code" }); @@ -130,7 +130,7 @@ test("sets, replaces and clears a harness's default model configuration, and kee }); test("reports an unconfirmed save, reads the default model configurations again once and never repeats the write", async ({ page, request }) => { - await openConsole(page, request, "system", { fresh: true }); + await openConsole(page, request, "settings?id=models", { fresh: true }); const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); const set = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); @@ -157,7 +157,7 @@ test("requires an explicit supported protocol choice for an older default config codex.model_configuration.harness_config = { model_reasoning_effort: "high" }; await route.fulfill({ response, json: data }); }); - await openConsole(page, request, "system"); + await openConsole(page, request, "settings?id=models"); const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); await expect(codex).toContainText("OpenAI Chat Completions"); await codex.getByRole("button", { name: "Replace the default model configuration for Codex" }).click(); diff --git a/apps/web/e2e/unified-settings.spec.ts b/apps/web/e2e/unified-settings.spec.ts new file mode 100644 index 000000000..8636c0946 --- /dev/null +++ b/apps/web/e2e/unified-settings.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test"; +import { expectManagementBoundary, openConsole } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +const initial = { id: "ddda6e9f-c15c-423e-9a95-c93b8fbe5a20", adapter: "test-storage", parameters: { root: "/workspaces" }, tenant_capacity_mib: 4096 }; + +test("storage capacity has one settings home and preserves immutable configuration without sending credentials", async ({ page, request }) => { + let selected = { ...initial }; + const saves: Record[] = []; + await page.route("**/core/v1/workspace-storage", async (route) => { + if (route.request().method() === "PUT") { + const body = route.request().postDataJSON(); + saves.push(body); + selected = { ...selected, tenant_capacity_mib: body.tenant_capacity_mib }; + } + await route.fulfill({ json: selected }); + }); + await openConsole(page, request, "settings?id=storage"); + await expect(page.getByRole("navigation").getByRole("button", { name: "Workspace storage", exact: true })).toHaveAttribute("aria-current", "page"); + await page.getByRole("button", { name: "Edit tenant capacity", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Edit tenant capacity" }); + await expect(dialog.getByLabel("Credential", { exact: false })).toHaveCount(0); + await dialog.getByLabel("Tenant capacity (MiB)").fill("8192"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + expect(saves).toEqual([{ ...initial, tenant_capacity_mib: 8192 }]); + await expect(page.getByText("8,192", { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText("8,192", { exact: true })).toBeVisible(); +}); + +test("new storage selection validates objects, retains retry identity, and never redisplays server secrets", async ({ page, request }) => { + const saves: Record[] = []; + const secret = "fixture-write-only-storage-secret"; + let selected = { ...initial }; + await page.route("**/core/v1/workspace-storage", async (route) => { + if (route.request().method() === "PUT") { + const body = route.request().postDataJSON(); + saves.push(body); + if (saves.length === 1) { + await route.fulfill({ status: 500, json: { error: { code: "internal_error", message: secret } } }); + return; + } + const { credential: _credential, ...publicSelection } = body; + selected = publicSelection; + } + await route.fulfill({ json: selected }); + }); + await openConsole(page, request, "settings?id=storage"); + await page.getByRole("button", { name: "Select new configuration" }).click(); + const dialog = page.getByRole("dialog", { name: "Select new configuration" }); + await dialog.getByLabel("Parameters (JSON)").fill("[]"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog.getByRole("alert")).toContainText("valid JSON objects"); + expect(saves).toHaveLength(0); + await dialog.getByLabel("Parameters (JSON)").fill('{"root":"/next"}'); + await dialog.getByLabel("Credential (JSON, write-only)").fill(JSON.stringify({ token: secret })); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog.getByRole("alert")).toBeVisible(); + await expect(page.locator("body")).not.toContainText(secret); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + expect(saves).toHaveLength(2); + expect(saves[1]).toEqual(saves[0]); + expect(saves[0].id).not.toBe(initial.id); + expect(saves[0].credential).toEqual({ token: secret }); + await page.getByRole("button", { name: "Select new configuration" }).click(); + await expect(dialog.getByLabel("Credential (JSON, write-only)")).toHaveValue(""); +}); + +test("capacity editor refuses to select an old configuration after observing a replacement", async ({ page, request }) => { + let selected = { ...initial }; + const saves: Record[] = []; + await page.route("**/core/v1/workspace-storage", async (route) => { + if (route.request().method() === "PUT") { + saves.push(route.request().postDataJSON()); + await route.fulfill({ status: 500, json: { error: { code: "internal_error", message: "Try again" } } }); + return; + } + await route.fulfill({ json: selected }); + }); + await openConsole(page, request, "settings?id=storage"); + await page.getByRole("button", { name: "Edit tenant capacity", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Edit tenant capacity" }); + await dialog.getByLabel("Tenant capacity (MiB)").fill("8192"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog.getByRole("alert")).toBeVisible(); + selected = { ...initial, id: "11a66e9f-c15c-423e-9a95-c93b8fbe5a20" }; + await dialog.getByRole("button", { name: "Refresh current selection" }).click(); + await expect(dialog.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + expect(saves).toHaveLength(1); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("button", { name: "Edit tenant capacity", exact: true }).click(); + await dialog.getByLabel("Tenant capacity (MiB)").fill("8192"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog.getByRole("alert")).toBeVisible(); + expect(saves[1].id).toBe(selected.id); +}); diff --git a/apps/web/e2e/wording.spec.ts b/apps/web/e2e/wording.spec.ts index 3c342b83a..2b5db74c6 100644 --- a/apps/web/e2e/wording.spec.ts +++ b/apps/web/e2e/wording.spec.ts @@ -41,9 +41,8 @@ test("keeps English Session filters and actions visible at 1280 pixels", async ( }); test("names the E2B backend consistently and explains its two count sources", async ({ page, request }) => { - await openConsole(page, request, "system", { sandbox: "e2b" }); - await page.getByRole("button", { name: "Manage sandbox configuration" }).click(); - await expect(page.getByRole("heading", { name: "Sandbox configuration", exact: true })).toBeVisible(); + await openConsole(page, request, "settings?id=sandbox", { sandbox: "e2b" }); + await expect(page.getByRole("heading", { name: "Sandbox and scheduling", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Sandbox metrics", exact: true }).click(); await expect(page.getByText(/These sources have different coverage and refresh separately/)).toBeVisible(); }); diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index debc7e9f8..a896e3262 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -12,12 +12,11 @@ import { SandboxMetricsPage } from "./features/metrics/SandboxMetricsPage"; import { ConsoleTourContext, ConsoleTourScreen } from "./features/onboarding/ConsoleTour"; import { withTransition } from "./features/onboarding/view-transition"; import { OverviewPage } from "./features/overview/OverviewPage"; -import { SandboxDeploymentPage } from "./features/sandbox/SandboxDeploymentPage"; import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; import { SessionLogPage } from "./features/sessions/SessionLogPage"; import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; -import { SystemPage } from "./features/system/SystemPage"; +import { SettingsPage } from "./features/settings/SettingsPage"; import { VaultsPage } from "./features/vaults/VaultsPage"; import { consoleDepth, ConsoleNavigationContext, useConsoleNavigation, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; import { consoleHashForView, consoleNavParent, consoleViewFromHash, type ConsoleView } from "./lib/console-routes"; @@ -41,7 +40,6 @@ function readLocation(): { view: ConsoleView; params: RouteParams; intent: Conso } function ConsolePage({ view }: { view: ConsoleView }) { - const { params } = useConsoleNavigation(); switch (view) { case "overview": return ; case "core-metrics": return ; @@ -56,7 +54,7 @@ function ConsolePage({ view }: { view: ConsoleView }) { case "vaults": return ; case "projects": return ; case "nodes": return ; - case "system": return params.id === "sandbox" ? : ; + case "settings": return ; } } diff --git a/apps/web/src/components/ConsoleSidebar.tsx b/apps/web/src/components/ConsoleSidebar.tsx index 8141f8063..fa66ee5d7 100644 --- a/apps/web/src/components/ConsoleSidebar.tsx +++ b/apps/web/src/components/ConsoleSidebar.tsx @@ -36,7 +36,7 @@ const viewIcons: Record = { vaults: Vault, projects: FolderKanban, nodes: Server, - system: Settings2, + settings: Settings2, }; export function ConsoleSidebar({ diff --git a/apps/web/src/components/InstallationNotice.tsx b/apps/web/src/components/InstallationNotice.tsx index 9000908de..580353576 100644 --- a/apps/web/src/components/InstallationNotice.tsx +++ b/apps/web/src/components/InstallationNotice.tsx @@ -10,6 +10,6 @@ export function InstallationNotice({ installation }: { installation: CoreInstall if (!installation?.local_only) return null; return ; } diff --git a/apps/web/src/features/fleet/SandboxResetNotice.tsx b/apps/web/src/features/fleet/SandboxResetNotice.tsx index ac852e227..ab82e4f7f 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.tsx @@ -15,7 +15,7 @@ export function SandboxResetNotice({ deployment, failed, onRetry }: { const { navigate } = useConsoleNavigation(); return <> {failed ? : null} - {deployment?.reset ?
navigate("system", { id: "sandbox" })}>{t("reset.view")}}> + {deployment?.reset ?
navigate("settings", { id: "sandbox" })}>{t("reset.view")}}>

{t("reset.body")}

: null} diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 62fb5c945..8401fd815 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -185,13 +185,13 @@ export function SandboxMetricsPage() { icon={Server} title={t("sandbox.noNodesTitle")} hint={t("sandbox.noNodesDescription")} - action={} + action={} /> ) : ( navigate("system", { id: "sandbox" })}>{t("sandbox.setUp")}} + action={} /> ) ) : fleetState.status === "loading" @@ -245,7 +245,7 @@ function CloudSection({ deployment }: { deployment: SandboxDeployment }) { headingId="cloud-heading" title={t("sandbox.cloud.title")} help={t("sandbox.cloud.help")} - actions={} + actions={} >

{t("sandbox.cloud.counts")}

diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx index 6589102b3..d2f940263 100644 --- a/apps/web/src/features/overview/GettingStarted.tsx +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -115,16 +115,16 @@ export function GettingStarted({ fleet, sessions, localOnly, sandboxReset, onRet const nextStep = states.findIndex((state) => state === "todo") + 1; const sandbox = steps.sandboxes; const sandboxAction = sandboxReset === true - ? { label: t("reset.view"), run: () => navigate("system", { id: "sandbox" }) } + ? { label: t("reset.view"), run: () => navigate("settings", { id: "sandbox" }) } : localOnly === "failed" ? { label: t("actions.retry", { ns: "common" }), run: onRetryInstallation } : localOnly === true - ? { label: t("installationNotice.configure", { ns: "common" }), run: () => navigate("system") } + ? { label: t("installationNotice.configure", { ns: "common" }), run: () => navigate("settings", { id: "service" }) } : sandbox.action === "setup" - ? { label: t("gettingStarted.sandboxes.setup"), run: () => navigate("system", { id: "sandbox" }) } + ? { label: t("gettingStarted.sandboxes.setup"), run: () => navigate("settings", { id: "sandbox" }) } : sandbox.action === "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("system", { id: "sandbox" }) : navigate("nodes") }; + ? { label: t("gettingStarted.sandboxes.addNode"), run: () => navigate("settings", { id: "sandbox" }, "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 ? { label: t("gettingStarted.key.issue"), run: () => navigate("projects", { id: keyProject.id }, "issue-key") } @@ -151,7 +151,7 @@ export function GettingStarted({ fleet, sessions, localOnly, sandboxReset, onRet
    - navigate("system", {}, "default-model") }} /> + navigate("settings", { id: "models" }, "default-model") }} />
diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 0036ee4ef..54c505fd9 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -334,10 +334,10 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co {fleetState.status === "ready" ? ( !fleet?.deployment.provider - ? + ? : cloud || hosts.length - ? - : + ? + : ) : null}
@@ -345,7 +345,7 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co nodes={hosts} cloud={cloud} suspends={Boolean(fleet?.deployment.suspension)} - onOpenBackend={() => navigate("system", { id: "sandbox" })} + onOpenBackend={() => navigate("settings", { id: "sandbox" })} coreLabel={t(`coreStatus.${core}`)} coreTone={coreTone[core]} stale={fleetObservationStale(fleetState)} diff --git a/apps/web/src/features/sandbox/NodeCapacityFields.test.ts b/apps/web/src/features/sandbox/NodeCapacityFields.test.ts new file mode 100644 index 000000000..0d43aae2d --- /dev/null +++ b/apps/web/src/features/sandbox/NodeCapacityFields.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { nodeCapacity } from "./NodeCapacityFields"; + +describe("node capacity admission input", () => { + it("uses the same bounds for enrollment and later edits", () => { + for (const value of ["", "1.5", "-1", "0", "1000001", "1e2"]) expect(nodeCapacity(value, "8").activeError).not.toBeNull(); + expect(nodeCapacity("1000000", "1000000")).toEqual({ active: 1000000, retained: 1000000, activeError: null, retainedError: null }); + expect(nodeCapacity("4", "3").retainedError).toBe("Enter at least the number of sandboxes at once."); + }); + it("permits small limits without deriving them from existing usage", () => { + expect(nodeCapacity("1", "1")).toEqual({ active: 1, retained: 1, activeError: null, retainedError: null }); + }); +}); diff --git a/apps/web/src/features/sandbox/NodeCapacityFields.tsx b/apps/web/src/features/sandbox/NodeCapacityFields.tsx new file mode 100644 index 000000000..67b21e05f --- /dev/null +++ b/apps/web/src/features/sandbox/NodeCapacityFields.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { HelpTip } from "../../components/console-ui"; + +export function nodeCapacity(active: string, retained: string) { + const whole = (value: string) => /^\d+$/.test(value.trim()) ? Number(value.trim()) : null; + const valid = (value: number | null): value is number => value !== null && value >= 1 && value <= 1_000_000; + const maxActive = whole(active), maxRetained = whole(retained); + return { active: maxActive, retained: maxRetained, + activeError: valid(maxActive) ? null : "Enter a whole number from 1 to 1,000,000." as const, + retainedError: !valid(maxRetained) ? "Enter a whole number from 1 to 1,000,000." as const : valid(maxActive) && maxRetained < maxActive ? "Enter at least the number of sandboxes at once." as const : null }; +} + +export function NodeCapacityFields({ id, active, retained, suspends, activeError, retainedError, onActive, onRetained, autoFocus, help }: { + id: string; active: string; retained: string; suspends: boolean; activeError: string | null; retainedError: string | null; + onActive: (value: string) => void; onRetained: (value: string) => void; autoFocus?: boolean; help?: ReactNode; +}) { + const { t } = useTranslation("sandbox"); + return <> +
+ {t("The most sandboxes Core places on this node at the same time.")}{help} + onActive(event.target.value)} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} /> + {activeError ? {activeError} : null} +
+ {suspends ?
+ {t("Sandboxes kept on this node for resuming, the running ones included. At least the number at once.")} + onRetained(event.target.value)} aria-invalid={Boolean(retainedError)} aria-errormessage={retainedError ? `${id}-retained-error` : undefined} /> + {retainedError ? {retainedError} : null} +
: null} + ; +} diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 074857727..554bbb41c 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -3,7 +3,7 @@ import type { SandboxAdminClient, SandboxNode, SandboxResources } from "@oac/age import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { HelpTip } from "../../components/console-ui"; +import { nodeCapacity, NodeCapacityFields } from "./NodeCapacityFields"; import { Modal } from "../../components/Modal"; import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; @@ -38,13 +38,12 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved const [retained, setRetained] = useState(String(node?.max_retained ?? 8)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); - const activeLimit = whole(active); - const retainedLimit = suspends ? whole(retained) : Math.max(node?.max_retained ?? 0, activeLimit ?? 0); - // Core counts the name in UTF-8 bytes and caps both limits at a million. + const limits = nodeCapacity(active, suspends ? retained : String(Math.max(node?.max_retained ?? 0, Number(active) || 0))); + const activeLimit = limits.active; + const retainedLimit = limits.retained; const nameProblem = !name.trim() || new TextEncoder().encode(name.trim()).length > 128 ? t("Enter a shorter name.") : null; - const activeProblem = activeLimit === null || activeLimit < 1 || activeLimit > 1_000_000 ? t("Enter a whole number from 1 to 1,000,000.") : null; - const retainedProblem = suspends && (retainedLimit === null || activeLimit === null || retainedLimit < activeLimit || retainedLimit > 1_000_000) ? t("Enter at least the number of sandboxes at once.") : null; + const activeProblem = limits.activeError ? t(limits.activeError) : null; + const retainedProblem = suspends && limits.retainedError ? t(limits.retainedError) : null; const nameError = coreFieldError(error, "name", tCommon, "bytes") ?? (name ? nameProblem : null); const activeError = coreFieldError(error, "max_active", tCommon) ?? activeProblem; const retainedError = coreFieldError(error, "max_retained", tCommon) ?? retainedProblem; @@ -93,18 +92,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved { setName(event.target.value); setError(null); }} autoComplete="off" aria-invalid={Boolean(nameError)} aria-describedby={nameError ? `${id}-name-error` : undefined} /> {nameError ? {nameError} : null} -
- {t("The most sandboxes Core places on this node at the same time.")}{hostFacts ? <> {hostFacts} : null} - { setActive(event.target.value); setError(null); }} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} /> - {activeError ? {activeError} : null} -
- {suspends ? ( -
- {t("Sandboxes kept on this node for resuming, the running ones included. At least the number at once.")} - { setRetained(event.target.value); setError(null); }} aria-invalid={Boolean(retainedError)} aria-errormessage={retainedError ? `${id}-retained-error` : undefined} /> - {retainedError ? {retainedError} : null} -
- ) : null} + {hostFacts} : null} onActive={(value) => { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> {error !== null ?

{sandboxRequestError(error, locale)}

: null} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 86d0dbe2b..164a65e2b 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,3 +1,4 @@ +import { nodeCapacity, NodeCapacityFields } from "./NodeCapacityFields"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@oac/agents-client"; @@ -110,14 +111,11 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : null; // Core takes whole numbers from 1 to a million, with the retained limit at least the active one. const suspends = deployment.suspension !== null; - const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); - const inRange = (limit: number | null): limit is number => limit !== null && limit >= 1 && limit <= 1_000_000; - const activeLimit = whole(active); - const retainedLimit = suspends ? whole(retained) : activeLimit; - const activeProblem = inRange(activeLimit) ? null : t("Enter a whole number from 1 to 1,000,000."); - const retainedProblem = !suspends ? null - : !inRange(retainedLimit) ? t("Enter a whole number from 1 to 1,000,000.") - : inRange(activeLimit) && retainedLimit < activeLimit ? t("Enter at least the number of sandboxes at once.") : null; + const limits = nodeCapacity(active, suspends ? retained : active); + const activeLimit = limits.active; + const retainedLimit = limits.retained; + const activeProblem = limits.activeError ? t(limits.activeError) : null; + const retainedProblem = suspends && limits.retainedError ? t(limits.retainedError) : null; const activeError = coreFieldError(error, "max_active", tCommon) ?? activeProblem; const retainedError = coreFieldError(error, "max_retained", tCommon) ?? retainedProblem; const limitsReady = !activeProblem && !retainedProblem; @@ -273,18 +271,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : !enrollment ? (
{ event.preventDefault(); void generate(); }}>

{t("Set the sandbox limits for the host you want to add.")}

-
- {t("The most sandboxes Core places on this node at the same time.")} - { setActive(event.target.value); setError(null); }} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} /> - {activeError ? {activeError} : null} -
- {suspends ? ( -
- {t("Sandboxes kept on this node for resuming, the running ones included. At least the number at once.")} - { setRetained(event.target.value); setError(null); }} aria-invalid={Boolean(retainedError)} aria-errormessage={retainedError ? `${id}-retained-error` : undefined} /> - {retainedError ? {retainedError} : null} -
- ) : null} + { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> {error !== null ?

{sandboxRequestError(error, locale)}

: null} {requirements} @@ -305,8 +292,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open,
{next ?

{t(next === "default-model" ? "Next: set a default model provider." : "Next: finish Getting started.")} -

: null} {problem && !ready ?
diff --git a/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx b/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx new file mode 100644 index 000000000..ef595d536 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx @@ -0,0 +1,95 @@ +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 { SandboxLifecycle } 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 || snapshot.deployment.suspension ? : null} + {hostedNodes ?
+
+ {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 b92c4b3d5..b463d62b1 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type StartSandboxReset } from "@oac/agents-client"; import { useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; import { TableSkeleton } from "../../components/Skeleton"; import { HelpTip, RefreshButton } from "../../components/console-ui"; @@ -18,20 +17,21 @@ import { writeSandboxDeployment } from "./sandbox-deployment-write"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; import "./SandboxManagerView.css"; +import { SettingsTabs } from "../settings/SettingsTabs"; +import { SandboxCapacitySettings } from "./SandboxCapacitySettings"; function DeploymentHeader({ actions }: { actions?: ReactNode }) { const { t } = useTranslation("sandboxNavigation"); - const { navigate } = useConsoleNavigation(); + const { t: tNav } = useTranslation("navigation"); return
- -

{t("title")}

{t("help")} +

{tNav("settings.sandbox")}

{t("help")}
{actions ?
{actions}
: null}
; } -/** System's secondary page is the sole owner of deployment configuration controls. */ +/** Settings owns deployment changes, lifecycle policy and node capacity. */ export function SandboxDeploymentPage() { const { i18n } = useTranslation("sandbox"); return
@@ -80,7 +80,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("nodes", {}, "add-node"); + 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"); } async function update(input: UpdateSandboxDeployment) { return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true, true); @@ -97,6 +97,7 @@ function DeploymentConfiguration() { {snapshot?.deployment.mode === "nodes" ? : null} } /> +
{!snapshot && (loading || deploymentQuery.isPending) ? : null} @@ -106,6 +107,7 @@ function DeploymentConfiguration() { {!snapshot && error !== null ? : null} {snapshot && !snapshot.deployment.provider && !snapshot.deployment.reset ? : null} {snapshot?.deployment.provider || snapshot?.deployment.reset ? : null} +
setWriteFailure((failure) => failure && { ...failure, open: false })}> {unknownWrite} diff --git a/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx b/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx index 680287b85..f9487c68d 100644 --- a/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx +++ b/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx @@ -28,6 +28,11 @@ describe("sandbox lifecycle evidence", () => { const unsupported = renderToStaticMarkup( true }} />); expect(unsupported).not.toContain("sandbox-idle-seconds"); }); + 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"); diff --git a/apps/web/src/features/sandbox/SandboxLifecycle.tsx b/apps/web/src/features/sandbox/SandboxLifecycle.tsx index 8fa98bd9b..a71c13969 100644 --- a/apps/web/src/features/sandbox/SandboxLifecycle.tsx +++ b/apps/web/src/features/sandbox/SandboxLifecycle.tsx @@ -12,7 +12,7 @@ export function SandboxLifecycle({ deployment, nodes, stale, controls, children const [draft, setDraft] = useState(null); const idle = draft ?? String(policy?.idle_seconds ?? 300); const valid = /^\d+$/.test(idle) && Number(idle) >= 30 && Number(idle) <= 86400; - if (!policy && !controls) return null; + if (!policy && !controls) return <>{children}; const count = (value: number) => formatInteger(value, i18n.resolvedLanguage); const capacity = nodes ? capacitySummary(nodes) : null; return
diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 33710ef71..d9c4e88f6 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { 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,24 +7,20 @@ 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 { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; +import { useConsoleNavigation } from "../../lib/console-navigation"; import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; -import { sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; +import { sandboxRequestError } from "../../lib/sandbox-labels"; import type { SandboxConsoleConfig } from "./console-config"; import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; -import { NodeEnrollment } from "./NodeEnrollment"; import { NodeList, onOldAddress } from "./NodeList"; import { SandboxLifecycle } from "./SandboxLifecycle"; import { NodeDetail } from "./NodeDetail"; -import { NodeEditDialog } from "./NodeEditDialog"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; -import { writeSandboxDeployment } from "./sandbox-deployment-write"; -import { sandboxSize } from "./deployment-specification"; import "./SandboxManagerView.css"; -/** Nodes owns node enrollment, the list and individual node management. */ +/** Nodes owns fleet observations, node details and removal; configuration lives in Settings. */ export function SandboxManagerView() { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; @@ -59,31 +55,20 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const { params, navigate, back: goBack } = useConsoleNavigation(); const client = sandboxAdmin; const queryClient = useQueryClient(); - const { deploymentQuery, query, compatible, inventoryOlder, inventoryLoading, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refetch, refresh } = useSandboxPageState({ poll: true }); + const { deploymentQuery, query, compatible, inventoryOlder, 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"); const error: unknown = deploymentQuery.error; const [removeTarget, setRemoveTarget] = useState(null); - const [editTarget, setEditTarget] = useState(null); - // The Add node dialog; it stays mounted with the page so its command survives closing. - const [adding, setAdding] = useState(false); const [removing, setRemoving] = useState(false); const [removeError, setRemoveError] = 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); - // The enrollment dialog's reads: the node list alone, every few seconds while it waits. It settles - // when the read does, which the dialog waits for before calling a command expired. - const { refetch: refetchInventory } = query; - const refreshNodes = useCallback(async () => { - const result = await refetchInventory(); - return result.isSuccess && !result.data.nodesError ? result.data.nodes : null; - }, [refetchInventory]); // Where focus goes once a dialog about a node that is gone closes. const heading = useRef(null); const toast = useToast(); - // A refresh the administrator asks for reports its failure even while an earlier one is still unconfirmed; - // the enrollment dialog's own repeated refreshes do not. + // A user-requested refresh reports failure while retaining the last observation. const refreshByUser = () => { void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); void refetch().then((result) => { @@ -129,13 +114,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const hostedNodes = snapshot?.deployment.mode === "nodes"; // Nodes share the deployment's Provider, which declares whether sandboxes suspend. const suspends = Boolean(snapshot?.deployment.suspension); - // 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)); // 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; @@ -172,47 +150,16 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig // The removed node's button is gone, so focus returns to the page, after the dialog restores its own. window.requestAnimationFrame(() => heading.current?.focus()); }} />; - // Rendered first in both the list and a node's page, so an open command outlives the navigation. - const enrollment = hostedNodes && snapshot ? setAdding(false)} onRefresh={refreshNodes} /> : null; - - const editDialog = setEditTarget(null)} - onSaved={() => { - const saved = editTarget; - setEditTarget(null); - 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; - } - } - if (params.id && hostedNodes) { const back = () => goBack("nodes"); return <> - {enrollment} {refreshButton} + {selected ? : null} {selected ? : null} } /> @@ -224,36 +171,33 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{dialog} {cleanupDialog} - ; } const actions = <> {localOnly && hostedNodes ? {tCommon("installationNotice.addBlocked")} : null} - {hostedNodes ? : null} + {hostedNodes ? : null} {refreshButton} - {hostedNodes && snapshot ? : null} + {hostedNodes && snapshot ? : null} ; return <> - {enrollment}
{status} - {snapshot && !hostedNodes ? navigate("system", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} + {snapshot && !hostedNodes ? navigate("settings", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} {snapshot?.deployment.reset && hostedNodes ?

{tSandboxNav("reset")}

: null} {snapshot?.deployment.provider ? <> {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} controls={{ disabled: busy || loading || !fresh || !nodesConfirmed || setupNeedsRefresh || Boolean(snapshot.deployment.reset), onSave: saveIdle }}> + {hostedNodes ? 0) ? nodes : null} stale={!nodesConfirmed}> {nodes.length - ? navigate("nodes", { id: node.id })} onEdit={setEditTarget} onRemove={askRemove} /> + ? navigate("nodes", { id: node.id })} onEdit={(node) => navigate("settings", { id: "sandbox", node: node.id })} onRemove={askRemove} /> : nodesConfirmed ? : null} : null} : null}
{dialog} {cleanupDialog} - {editDialog} ; } diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 5c1e432eb..b61508872 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -387,7 +387,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
{address ? {address} : "—"} - {t("Managed in System")} + {t("Managed in Settings")} {installation.data?.local_only ? {t("Set a public address before connecting remote nodes; E2B sandboxes need an HTTPS one.")} : null}
@@ -396,7 +396,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab

{rejection}

{resetRequired ?

{t("Cancel editing to use Reset deployment. This change requires an explicit reset; the saved configuration is unchanged.")}

: null} - {addressRejected ? : null} + {addressRejected ? : null}
) : null} {/* Initial setup needs the cleared key re-entered; updates may keep the saved key. */} 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 4931b8c91..df5653eec 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -30,15 +30,15 @@ function cache(provider: SandboxDeployment["provider"]) { const render = (page: ReactElement, client: QueryClient) => renderToStaticMarkup({page}); describe("sandbox page ownership", () => { - it("keeps setup in System's secondary page and sends empty Nodes there", () => { + it("keeps setup in Settings and sends empty Nodes there", () => { const client = cache(""); const nodes = render(, client); expect(nodes).not.toContain("setup-flow"); - expect(nodes).toContain("Set up sandbox hosting in System before adding nodes."); + expect(nodes).toContain("Set up sandbox hosting in Settings before adding nodes."); expect(nodes).toContain("Manage sandbox configuration"); const configuration = render(, client); expect(configuration).toContain("setup-flow"); - expect(configuration).toContain("Back to System"); + expect(configuration).toContain("Sandbox and scheduling"); expect(configuration).not.toContain("node-enrollment"); }); @@ -48,7 +48,7 @@ describe("sandbox page ownership", () => { expect(cloud).toContain("There are no nodes to manage."); expect(cloud).not.toContain("Change resources"); const hosts = render(, cache("docker")); - expect(hosts).toContain("node-enrollment"); + expect(hosts).not.toContain("node-enrollment"); expect(hosts).toContain("Add your first node"); expect(hosts).not.toContain("Reset deployment"); }); @@ -60,7 +60,7 @@ describe("sandbox page ownership", () => { expect(configuration).toContain("Reset deployment"); expect(configuration).not.toContain("setup-flow"); const system = render(, client); - expect(system).toContain("Manage sandbox configuration"); + expect(system).toContain("Service and deployment"); expect(system).not.toContain("Change resources"); expect(system).not.toContain("Reset deployment"); }); diff --git a/apps/web/src/features/settings/SettingsPage.tsx b/apps/web/src/features/settings/SettingsPage.tsx new file mode 100644 index 000000000..48f099e12 --- /dev/null +++ b/apps/web/src/features/settings/SettingsPage.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { harnessesQuery } from "../system/harness-queries"; +import { PageBody, PageHeader, RefreshButton } from "../../components/console-ui"; +import { useConsoleNavigation } from "../../lib/console-navigation"; +import { SandboxDeploymentPage } from "../sandbox/SandboxDeploymentPage"; +import { DefaultModelsSection } from "../system/DefaultModelsSection"; +import { SystemPage } from "../system/SystemPage"; +import { SettingsTabs } from "./SettingsTabs"; +import { WorkspaceStorageSettings } from "./WorkspaceStorageSettings"; + +export function SettingsPage() { + const { params } = useConsoleNavigation(); + const { t } = useTranslation("navigation"); + const { t: tSystem } = useTranslation("system"); + const models = useQuery({ ...harnessesQuery, enabled: params.id === "models" }); + if (params.id === "service") return ; + if (params.id !== "models" && params.id !== "storage") return ; + const section = params.id; + return
+ void models.refetch()} label={tSystem("refresh")} /> : undefined} /> + + {section === "models" ? : } +
; +} diff --git a/apps/web/src/features/settings/SettingsTabs.tsx b/apps/web/src/features/settings/SettingsTabs.tsx new file mode 100644 index 000000000..8977597c4 --- /dev/null +++ b/apps/web/src/features/settings/SettingsTabs.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from "react-i18next"; +import { useConsoleNavigation } from "../../lib/console-navigation"; +import "./settings.css"; + +export type SettingsSection = "sandbox" | "storage" | "models" | "service"; +const sections: SettingsSection[] = ["sandbox", "storage", "models", "service"]; +export function SettingsTabs({ active }: { active: SettingsSection }) { + const { t } = useTranslation("navigation"); + const { navigate } = useConsoleNavigation(); + return ; +} diff --git a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx new file mode 100644 index 000000000..86e3c3f70 --- /dev/null +++ b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx @@ -0,0 +1,95 @@ +import { type WorkspaceStorageSelection } from "@oac/agents-client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { HelpTip, Section } from "../../components/console-ui"; +import { ErrorState } from "../../components/ErrorState"; +import { CopyableId } from "../../components/list-ui"; +import { Modal } from "../../components/Modal"; +import { TableSkeleton } from "../../components/Skeleton"; +import { knownCoreError } from "../../lib/core-error"; +import { admin } from "../../lib/projects"; +import { Fact } from "../system/Fact"; +import { storageCapacity, storageObject, workspaceStorageQuery } from "./workspace-storage"; + +function StorageEditor({ current, replacement, onClose }: { current: WorkspaceStorageSelection | null; replacement: boolean; onClose: () => void }) { + const { t, i18n } = useTranslation("workspaceStorage"); + const { t: common } = useTranslation(); + const client = useQueryClient(); + const formId = useId(); + // Retain this identity across failed or uncertain saves. Credentials never enter the query cache. + const [selection] = useState(current); + const [configurationId] = useState(() => replacement ? crypto.randomUUID() : selection?.id ?? ""); + const selectionChanged = !replacement && (!selection || current?.id !== selection.id); + const [adapter, setAdapter] = useState(current?.adapter ?? ""); + const [parameters, setParameters] = useState(JSON.stringify(current?.parameters ?? {}, null, 2)); + const [credential, setCredential] = useState(""); + const [capacity, setCapacity] = useState(String(current?.tenant_capacity_mib ?? 0)); + const [busy, setBusy] = useState(false); + const writing = useRef(false); + const [error, setError] = useState(""); + const [observed, setObserved] = useState(null); + const refresh = async () => { + try { + const latest = await client.fetchQuery({ ...workspaceStorageQuery, staleTime: 0 }); + setObserved(latest ? `${latest.id} · ${latest.tenant_capacity_mib.toLocaleString()} MiB` : t("notConfigured")); + } catch { setError(t("readFailed")); } + }; + const close = () => { if (!writing.current) { setCredential(""); onClose(); } }; + const save = async () => { + if (writing.current || selectionChanged) return; + const quota = storageCapacity(capacity); + const parsed = replacement ? storageObject(parameters) : selection?.parameters; + const secret = credential.trim() ? storageObject(credential) : undefined; + if (quota === null || !parsed || !adapter.trim() || secret === null) { setError(t("invalid")); return; } + writing.current = true; setBusy(true); setError(""); + try { + const result = await admin.configureWorkspaceStorage({ id: configurationId, adapter: replacement ? adapter.trim() : selection!.adapter, parameters: parsed, tenant_capacity_mib: quota, ...(secret === undefined ? {} : { credential: secret }) }, { signal: AbortSignal.timeout(30_000) }); + setCredential(""); + await client.cancelQueries({ queryKey: workspaceStorageQuery.queryKey }); + client.setQueryData(workspaceStorageQuery.queryKey, result); + onClose(); + } catch (cause) { + // Never render raw server messages: adapter credentials may appear in a malformed response. + setError(knownCoreError(cause, common) ?? t("uncertain")); + void client.invalidateQueries({ queryKey: workspaceStorageQuery.queryKey }); + } finally { writing.current = false; setBusy(false); } + }; + return }> +
{ event.preventDefault(); void save(); }}> + {replacement ? <> +

{t("newSelection")}

+ {t("instructions")} +
{t("adapterHelp")}
setAdapter(event.target.value)} required disabled={busy} />
+
{t("parametersHelp")}