From cdd2ba67edca172f00a5ea8337a19c9c80625b7d Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Tue, 13 Oct 2026 08:13:12 +0000 Subject: [PATCH] Organize settings hierarchy --- apps/web/DESIGN.md | 12 +- apps/web/PRODUCT.md | 2 +- apps/web/e2e/unified-settings.spec.ts | 42 ++++- .../sandbox/SandboxCapacitySettings.tsx | 13 +- .../sandbox/SandboxDeploymentPage.tsx | 15 +- .../sandbox/SandboxDeploymentSettings.tsx | 108 +++++------ .../sandbox/SandboxLifecycle.test.tsx | 11 +- .../src/features/sandbox/SandboxLifecycle.tsx | 52 ++++-- .../features/sandbox/SandboxManagerView.css | 19 +- .../features/sandbox/SandboxResetControls.tsx | 49 ++--- .../sandbox/SandboxRolloutSummary.css | 3 - .../sandbox/SandboxRolloutSummary.test.tsx | 2 +- .../sandbox/SandboxRolloutSummary.tsx | 10 +- .../src/features/settings/SettingsPage.tsx | 2 +- .../src/features/settings/SettingsTabs.tsx | 2 +- .../settings/WorkspaceStorageSettings.tsx | 13 +- apps/web/src/features/settings/settings.css | 176 +++++++++++++++++- apps/web/src/features/system/Fact.tsx | 10 +- apps/web/src/features/system/HarnessCard.tsx | 41 ++-- .../features/system/ProviderObservations.tsx | 4 +- .../src/features/system/StartupSettings.tsx | 3 +- apps/web/src/features/system/SystemPage.tsx | 14 +- apps/web/src/features/system/system.css | 110 +++-------- apps/web/src/i18n/locales/en/system.ts | 2 - .../src/i18n/locales/en/workspace-storage.ts | 2 +- apps/web/src/i18n/locales/zh-CN/sandbox.ts | 3 + apps/web/src/i18n/locales/zh-CN/system.ts | 2 - .../i18n/locales/zh-CN/workspace-storage.ts | 2 +- 28 files changed, 433 insertions(+), 291 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index e745d1c76..2070abaa0 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -311,7 +311,7 @@ Page structure comes from typography, spacing and dividers. Reserve elevation fo ### Shadow Vocabulary - **Page surface**: flat and full-height, with no outer radius or shadow. -- **Card** (no shadow; a 1px `frame-line` ring at 11% ink): KPI strips, chart grids, Overview cards, the Session transcript and the deployment panel. Cards are flat; no page surface is translucent or blurred. +- **Card** (no shadow; a 1px `frame-line` ring at 11% ink): KPI strips, chart grids, Overview cards, the Session transcript and Settings row cards. Cards are flat; no page surface is translucent or blurred. - **Control ring** (a 1px Firm Rule ring with an extra-small shadow): outline buttons, the active segment, and search fields and selects in toolbars. Inputs inside cards and dialogs carry only a Hairline ring. - **Overlay** (a 1px Hairline ring with a large soft shadow): anchored popovers, menus, dialogs, help tips and chart tooltips. @@ -427,13 +427,15 @@ A persistent progress panel uses Core's busy, idle and cleanup counts, deadline Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state. ### Settings page -Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes, metrics and Getting started open the owning tab and, where appropriate, its node editor or enrollment flow. Keep the page header and tab row consistent. Forms save independently through their resource APIs, never through one cross-resource Save all action. +Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes, metrics and Getting started open the owning tab and, where appropriate, its node editor or enrollment flow. Forms save independently through their resource APIs, never through one cross-resource Save all action. -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. +The hierarchy is page, tab, section, row. The page header loses its rule to an underline tab row: 40px text tabs in Graphite 13px/500, the current one Ink with a 2px Ink underline. The body is a left-aligned column at most 880px wide with 32px between sections. A section is the shared Section heading (14px/600, help tip, section-wide outline actions on the right) over one card of label/value rows (`settings.css`): a 200px Graphite label column with its help tip and an Ink value column, 8px by 16px padding, 32px minimum line height, Faint Rules between rows; below 640px the label sits above the value. An action that changes one value sits at the end of its row (Edit tenant capacity; Save beside the idle-seconds field, whose error appears beneath it). Read-only status is a row too (Configuration rollout, Snapshot retention); tables sit in one framed card. Settings never repeats the KPI strips of Nodes or the metrics pages. The irreversible operation closes the page in its own section: a red-ringed card with one Ink line stating the consequence and a red-text outline button. -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. +Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Each sandbox, the E2B connection rows and the rollout row, with Change resources in the section header), then Capacity and suspension (the deployment-wide idle policy and read-only snapshot retention, shown only when the deployment declares suspension), then Sandbox nodes (Add node in the header, per-node limits in a table), then Reset. While a reset runs, its progress section leads the page with Force reset now and Cancel reset in its header. Add node opens the enrollment form here. Nodes remains an operational inventory: status, allocations, host figures and removal, with links to the canonical settings forms. Resource changes never imply rewriting existing allocations. Lower limits do not imply forced termination or snapshot deletion. -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. +Workspace storage shows the selected immutable configuration and tenant capacity under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. 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 stacks one default-model card per harness. Its header carries the harness name, its read-only startup state (Enabled or Disabled, a Default pill, a help tip) and the Set, Replace and Clear actions; rows hold model ID, provider protocol, base URL, key presence, token limits when set and update time, and a foot holds Usage details. 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. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 903a28def..5eb3443bd 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -45,7 +45,7 @@ The console runs beside the administrator's own Core, with execution, files and ## 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.** 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. +- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures: Nodes and Sandbox metrics own those counts. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. - **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. diff --git a/apps/web/e2e/unified-settings.spec.ts b/apps/web/e2e/unified-settings.spec.ts index 8636c0946..6f608f234 100644 --- a/apps/web/e2e/unified-settings.spec.ts +++ b/apps/web/e2e/unified-settings.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { expectManagementBoundary, openConsole, setDeployment } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -97,3 +97,43 @@ test("capacity editor refuses to select an old configuration after observing a r await expect(dialog.getByRole("alert")).toBeVisible(); expect(saves[1].id).toBe(selected.id); }); + +test("settings content width does not constrain reset and node dialog backdrops", async ({ page, request }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await openConsole(page, request, "settings?id=sandbox"); + for (const action of ["Reset deployment", "Edit node"]) { + await page.getByRole("button", { name: action, exact: true }).first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + const backdrop = page.locator(".modal-backdrop"); + const bounds = await backdrop.boundingBox(); + expect(bounds?.x).toBe(0); + expect(bounds?.y).toBe(0); + expect(bounds?.width).toBe(1440); + expect(bounds?.height).toBe(1000); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + } +}); + + +test("reset progress actions stay inside settings on narrow screens", async ({ page, request }) => { + await page.setViewportSize({ width: 375, height: 844 }); + await openConsole(page, request, "settings?id=sandbox"); + await setDeployment(request, { resources: { allocations: 1, pending: 0 }, reset: { clear: "auto", requested_at: "2026-10-13T08:00:00Z", deadline_at: "2026-10-13T09:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + const region = page.getByRole("region", { name: "Reset in progress" }); + await expect(region).toBeVisible(); + const bounds = (await region.boundingBox())!; + for (const name of ["Force reset now", "Cancel reset"]) { + const action = region.getByRole("button", { name, exact: true }); + const box = (await action.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(bounds.x); + expect(box.x + box.width).toBeLessThanOrEqual(bounds.x + bounds.width + 1); + await action.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + } +}); diff --git a/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx b/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx index ef595d536..1511ab880 100644 --- a/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx +++ b/apps/web/src/features/sandbox/SandboxCapacitySettings.tsx @@ -9,7 +9,7 @@ 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 { SuspensionSettings } from "./SandboxLifecycle"; import { sandboxSize } from "./deployment-specification"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; @@ -79,15 +79,14 @@ export function SandboxCapacitySettings() { if (!snapshot?.deployment.provider) return null; const nodes = snapshot.nodes; return <> - {hostedNodes || snapshot.deployment.suspension ? : null} - {hostedNodes ?
-
+ + {hostedNodes ?
setAdding(true)}>{t("Add node")}}> {configQuery.isError ?

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

: null} {snapshot.nodesError ?

{sandboxRequestError(snapshot.nodesError, locale)}

: null} - {localOnly ?

{tCommon("installationNotice.addBlocked")}

: null} + {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}
{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 b463d62b1..f595621a5 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -16,6 +16,7 @@ import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; +import { SandboxResetControls } from "./SandboxResetControls"; import "./SandboxManagerView.css"; import { SettingsTabs } from "../settings/SettingsTabs"; import { SandboxCapacitySettings } from "./SandboxCapacitySettings"; @@ -91,6 +92,13 @@ function DeploymentConfiguration() {

{sandboxRequestError(writeFailure.error, locale)}

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

: null; + const configured = snapshot?.deployment.provider || snapshot?.deployment.reset ? snapshot.deployment : null; + const disabled = busy || loading || !fresh || setupNeedsRefresh; + const reset = configured ? : null; + const sections = [ + configured ? : null, + , + ]; return <> @@ -98,16 +106,15 @@ function DeploymentConfiguration() { } /> -
+
{!snapshot && (loading || deploymentQuery.isPending) ? : null} {snapshot && deploymentQuery.isError ?

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

: null} {setupNeedsRefresh ?

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

: null} {busy ? {t("Saving sandbox change…")} : null} {!snapshot && error !== null ? : null} - {snapshot && !snapshot.deployment.provider && !snapshot.deployment.reset ? : null} - {snapshot?.deployment.provider || snapshot?.deployment.reset ? : null} - + {snapshot && !configured ? : null} + {configured?.reset ? [reset, ...sections] : [...sections, reset]}
setWriteFailure((failure) => failure && { ...failure, open: false })}> {unknownWrite} diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index b5a6491ea..6b3c6400c 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -1,25 +1,23 @@ import { useState, type ReactNode } from "react"; -import type { UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; +import type { UpdateSandboxDeployment, SandboxDeployment } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { HelpTip, RefreshButton } from "../../components/console-ui"; import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; import type { ParseKeys } from "i18next"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; +import { Fact } from "../system/Fact"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; -import { SandboxResetControls } from "./SandboxResetControls"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; const MIB = 2 ** 20; const buildStatusLabel: Record, ParseKeys<"sandbox">> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; -export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset, onCancelReset, onUpdate, writeFailure, onRefresh, refreshing, pending }: { +export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdate, writeFailure, onRefresh, refreshing, pending }: { deployment: SandboxDeployment; disabled: boolean; fresh: boolean; - onReset: (input: StartSandboxReset) => Promise; - onCancelReset: (expectedGeneration: number) => Promise; onUpdate: (input: UpdateSandboxDeployment) => Promise; writeFailure?: ReactNode; onRefresh: () => void; @@ -38,60 +36,52 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset const buildSize = templateBuildSize(deployment); const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); const canEdit = fresh && !deployment.reset; - return
-
-

{t("Deployment provider")}

- - {t("One provider serves this deployment. Reset it before choosing a different backend.")} - {deployment.mode === "direct" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""} -
- {spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} - {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null} -
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
-
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
-
-
-
-
-
{t("Provider")}
{sandboxProviderLabel(deployment.provider, locale)}
-
{t("Each sandbox")}
{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${spec?.workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING}
-
- {spec?.workspace && !spec.workspace.capacity_quota ?

{t("Workspace storage is configured by the operator; no capacity quota is enforced.")}

: null} - {deployment.provider === "e2b" ?
-
-
{t("Sandbox API URL")}
{deployment.configuration?.api_url}
-
{t("Sandbox data-plane domain")}
{deployment.configuration?.domain}
-
-
{t("Template build")}{deployment.configuration?.template || t("Unknown state")}
-
{[ - t(buildStatusLabel[templateBuildStatus(build)]), - ...(buildSize ? [sizeLabel(buildSize)] : []), - ...(build?.resources.root_disk_mib != null ? [t("{{disk}} disk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })] : []), - ].join(" · ")}
-
-
{t("E2B credential")}
{t(deployment.credential_configured ? "Configured" : "Not configured")}
-
-
: null} - - {!deployment.reset ? <> -
- - {t("Changes keep this backend and existing Sessions and nodes. Core prepares the new target for future work; placement may continue on qualified earlier generations.")} + return
+
+
+

{t("Deployment provider")}

+ + {t("One provider serves this deployment. Reset it before choosing a different backend.")} + {deployment.mode === "direct" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""} +
+ {spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} + {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null} +
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
+
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
+
+
- - { if (!pending) setChanging(false); }} footer={<>}> - { if (await onUpdate(input)) setChanging(false); }} - /> - {writeFailure} - - : null} - + {!deployment.reset ?
+ {t("Changes keep this backend and existing Sessions and nodes. Core prepares the new target for future work; placement may continue on qualified earlier generations.")} + +
: null} +
+
+ {sandboxProviderLabel(deployment.provider, locale)} + {size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${spec?.workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING} + {deployment.provider === "e2b" ? <> + {deployment.configuration?.api_url} + {deployment.configuration?.domain} + {[ + t(buildStatusLabel[templateBuildStatus(build)]), + ...(buildSize ? [sizeLabel(buildSize)] : []), + ...(build?.resources.root_disk_mib != null ? [t("{{disk}} disk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })] : []), + ].join(" · ")} + {t(deployment.credential_configured ? "Configured" : "Not configured")} + : null} + +
+ {!deployment.reset ? { if (!pending) setChanging(false); }} footer={<>}> + { if (await onUpdate(input)) setChanging(false); }} + /> + {writeFailure} + : null}
; } diff --git a/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx b/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx index f9487c68d..68f5bdac4 100644 --- a/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx +++ b/apps/web/src/features/sandbox/SandboxLifecycle.test.tsx @@ -2,7 +2,7 @@ import type { SandboxDeployment } from "@oac/agents-client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { node } from "../overview/test-fixtures"; -import { SandboxLifecycle } from "./SandboxLifecycle"; +import { SandboxLifecycle, SuspensionSettings } from "./SandboxLifecycle"; const deployment = { suspension: { idle_seconds: 300, retention_seconds: 86400 } } as SandboxDeployment; describe("sandbox lifecycle evidence", () => { @@ -16,17 +16,18 @@ describe("sandbox lifecycle evidence", () => { expect(html).toContain("5 minutes"); expect(html).not.toContain("3 / 14"); }); - it("offers policy editing only when the owner supplies controls", () => { + it("offers policy editing only in the settings owner, without repeating the evidence strip", () => { const nodes = [node("online", { active: 2, max_active: 4, retained: 5, max_retained: 8 })]; - const html = renderToStaticMarkup( true }} />); + const html = renderToStaticMarkup( true} />); expect(html).toContain("Capacity and suspension"); expect(html).toContain('id="sandbox-idle-seconds"'); expect(html).toContain('value="300"'); + expect(html).toContain("24 hours"); + expect(html).not.toContain("kpi-strip"); const readonly = renderToStaticMarkup(); expect(readonly).not.toContain("sandbox-idle-seconds"); expect(readonly).not.toContain("Edit node"); - const unsupported = renderToStaticMarkup( true }} />); - expect(unsupported).not.toContain("sandbox-idle-seconds"); + expect(renderToStaticMarkup( true} />)).toBe(""); }); it("keeps node inventory visible when suspension is unsupported", () => { const html = renderToStaticMarkup(
node-inventory
); diff --git a/apps/web/src/features/sandbox/SandboxLifecycle.tsx b/apps/web/src/features/sandbox/SandboxLifecycle.tsx index a71c13969..ef3827062 100644 --- a/apps/web/src/features/sandbox/SandboxLifecycle.tsx +++ b/apps/web/src/features/sandbox/SandboxLifecycle.tsx @@ -4,38 +4,52 @@ import { useTranslation } from "react-i18next"; import { HelpTip, Kpi, KpiStrip, Section } from "../../components/console-ui"; import { formatInteger, formatSpan, MISSING } from "../../lib/format"; import { capacitySummary } from "../fleet/fleet-model"; +import { Fact } from "../system/Fact"; + +function RetentionHelp({ deployment }: { deployment: SandboxDeployment }) { + const { t } = useTranslation("sandbox"); + return <>{t("Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.")}{deployment.specification?.workspace ? <> {t("Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.")} : null}; +} /** Core accounting and the declared policy, separate from host CPU and memory observations. */ -export function SandboxLifecycle({ deployment, nodes, stale, controls, children }: { deployment: SandboxDeployment; nodes: readonly SandboxNode[] | null; stale: boolean; controls?: { disabled: boolean; onSave: (seconds: number) => Promise; }; children?: ReactNode }) { +export function SandboxLifecycle({ deployment, nodes, stale, children }: { deployment: SandboxDeployment; nodes: readonly SandboxNode[] | null; stale: boolean; children?: ReactNode }) { const { t, i18n } = useTranslation("sandbox"); const policy = deployment.suspension; - 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 <>{children}; + if (!policy) return <>{children}; const count = (value: number) => formatInteger(value, i18n.resolvedLanguage); const capacity = nodes ? capacitySummary(nodes) : null; - return
+ return
{stale ?

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

: null} - {policy ? <> + - {t("Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.")}{deployment.specification?.workspace ? <> {t("Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.")} : null}} /> - : null} + } /> - {controls ? <> - {policy ?
{ event.preventDefault(); if (valid && !controls.disabled && await controls.onSave(Number(idle))) setDraft(null); }}> -
- {t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.")} - setDraft(event.target.value)} aria-invalid={!valid} aria-describedby={!valid ? "sandbox-idle-error" : undefined} /> + {children} +
; +} + +/** Settings edits the deployment-wide idle time; snapshot retention is Core's read-only policy. */ +export function SuspensionSettings({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (seconds: number) => Promise }) { + const { t, i18n } = useTranslation("sandbox"); + const policy = deployment.suspension; + const [draft, setDraft] = useState(null); + if (!policy) return null; + const idle = draft ?? String(policy.idle_seconds); + const valid = /^\d+$/.test(idle) && Number(idle) >= 30 && Number(idle) <= 86400; + return
+
+ { event.preventDefault(); if (valid && !disabled && await onSave(Number(idle))) setDraft(null); }}> +
{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.")}
+
+ setDraft(event.target.value)} aria-invalid={!valid} aria-describedby={!valid ? "sandbox-idle-error" : undefined} /> + {!valid ? {t("Enter a whole number from 30 to 86,400.")} : null}
-
- : null} - - : null} - {children} + + }>{formatSpan(policy.retention_seconds, i18n.resolvedLanguage)} +
; } diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 27b0ad8c4..24eb13177 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -37,18 +37,8 @@ .sandbox-manager-page { display: flex; padding: 0; overflow: hidden; } .sandbox-manager-page > .console-page-body { max-width: none; margin: 0; } .sandbox-manager-page .page-header h1 { margin: 0; } -.sandbox-provider-settings { padding: 16px; background: var(--surface); border: 0; border-radius: var(--frame-radius); box-shadow: var(--card-shadow); gap: 16px; } -.sandbox-provider-settings h2 { margin: 0 0 8px; } -.sandbox-provider-settings p, .sandbox-enrollment > p { font-size: 13px; line-height: 1.6; color: var(--fg-muted); } -.sandbox-provider-settings > .button, .sandbox-enrollment > .button { align-self: flex-start; } -.sandbox-cloud-summary { display: grid; gap: 16px; } -.sandbox-provider-settings .sandbox-enrollment { max-width: none; } - -.sandbox-provider-title, -.sandbox-provider-status, -.sandbox-provider-actions { display: flex; align-items: center; gap: 6px; } -.sandbox-provider-title h2 { margin: 0; } -.sandbox-provider-actions { align-self: flex-start; } +.sandbox-enrollment > p { font-size: 13px; line-height: 1.6; color: var(--fg-muted); } +.sandbox-enrollment > .button { align-self: flex-start; } /* Node list and node detail. */ .nodes-nowrap { white-space: nowrap; } @@ -96,9 +86,8 @@ border-color: color-mix(in srgb, var(--warning) 32%, var(--line)); } -/* Reset stays in the provider panel; only irreversible decisions interrupt with a dialog. */ -.sandbox-reset { border-top: 1px solid var(--line); padding-top: 16px; } -.sandbox-reset h3 { margin: 0; font-size: 15px; font-weight: 600; } +/* Reset progress is Core's report; only irreversible decisions interrupt with a dialog. */ +.sandbox-reset { display: grid; gap: 12px; padding: 16px; } .sandbox-reset-offline { display: grid; gap: 6px; } .sandbox-reset-offline ul { margin: 0; padding-left: 20px; overflow-wrap: anywhere; } .sandbox-reset-options { display: grid; gap: 10px; border: 0; padding: 0; margin: 0; } diff --git a/apps/web/src/features/sandbox/SandboxResetControls.tsx b/apps/web/src/features/sandbox/SandboxResetControls.tsx index c384c9299..105157055 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.tsx @@ -1,7 +1,7 @@ import type { SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; -import { HelpTip } from "../../components/console-ui"; +import { HelpTip, Section } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { formatDateTime, formatInteger } from "../../lib/format"; import "./reset-confirmation.css"; @@ -48,28 +48,33 @@ export function SandboxResetControls({ deployment, disabled, stale, onStart, onC const title = t(dialog?.action === "cancel" ? "Cancel reset?" : dialog?.action === "force" ? "Force reset now?" : "Reset sandbox deployment?"); const label = t(dialog?.action === "cancel" ? "Cancel reset" : dialog?.action === "force" ? "Force reset now" : "Reset deployment"); return <> - {reset ?
-

{t("Reset in progress")}

{t("Progress comes from Core. Offline resources still need confirmed cleanup, even in force mode.")}
-

{t(reset.clear === "auto" ? "Idle sandboxes are being archived. Busy work can finish until the deadline." : "Remaining hosted Sessions are being archived and their work cancelled.")}

- {stale ?

{t("Reset progress could not be refreshed. These are the last confirmed counts.")}

: null} -
-
{t("Busy")}
{formatInteger(reset.remaining.busy, i18n.resolvedLanguage)}
-
{t("Idle")}
{formatInteger(reset.remaining.idle, i18n.resolvedLanguage)}
-
{t("Awaiting cleanup")}
{formatInteger(reset.remaining.cleanup, i18n.resolvedLanguage)}
-
{t("On offline nodes")}
{formatInteger(reset.remaining.on_offline_nodes, i18n.resolvedLanguage)}
- {reset.clear === "auto" && reset.deadline_at ?
{t("Force deadline")}
{formatDateTime(Date.parse(reset.deadline_at) / 1000, i18n.resolvedLanguage)}
: null} -
- {reset.remaining.offline_nodes.length ?
-

{t("Bring these nodes back online so Core can confirm cleanup. Force reset does not bypass them.")}

-
    {reset.remaining.offline_nodes.map((node) =>
  • {node.name || node.node_id}{" · "}{t("{{count}} resources", { count: node.resources })}
  • )}
-

{t("Remove a node only after Core confirms it holds no resources.")}

-
: null} -

{t("New hosted Sessions and node enrollment are paused. Setup becomes available only after Core completes the reset.")}

-
- {reset.clear === "auto" ? : null} - + {reset ?
+ {reset.clear === "auto" ? : null} + + }> +
+

{t(reset.clear === "auto" ? "Idle sandboxes are being archived. Busy work can finish until the deadline." : "Remaining hosted Sessions are being archived and their work cancelled.")}

+ {stale ?

{t("Reset progress could not be refreshed. These are the last confirmed counts.")}

: null} +
+
{t("Busy")}
{formatInteger(reset.remaining.busy, i18n.resolvedLanguage)}
+
{t("Idle")}
{formatInteger(reset.remaining.idle, i18n.resolvedLanguage)}
+
{t("Awaiting cleanup")}
{formatInteger(reset.remaining.cleanup, i18n.resolvedLanguage)}
+
{t("On offline nodes")}
{formatInteger(reset.remaining.on_offline_nodes, i18n.resolvedLanguage)}
+ {reset.clear === "auto" && reset.deadline_at ?
{t("Force deadline")}
{formatDateTime(Date.parse(reset.deadline_at) / 1000, i18n.resolvedLanguage)}
: null} +
+ {reset.remaining.offline_nodes.length ?
+

{t("Bring these nodes back online so Core can confirm cleanup. Force reset does not bypass them.")}

+
    {reset.remaining.offline_nodes.map((node) =>
  • {node.name || node.node_id}{" · "}{t("{{count}} resources", { count: node.resources })}
  • )}
+

{t("Remove a node only after Core confirms it holds no resources.")}

+
: null} +

{t("New hosted Sessions and node enrollment are paused. Setup becomes available only after Core completes the reset.")}

+
+
:
+
+

{t("Clear this configuration to choose a different backend. Hosted Sessions are archived permanently.")}

+
-
: } +
} { if (!submitting) setDialog(null); }} footer={<> diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.css b/apps/web/src/features/sandbox/SandboxRolloutSummary.css index d391aa1bf..da58c44a7 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.css +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.css @@ -1,6 +1,3 @@ -.sandbox-rollout-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-width: 0; } -.sandbox-rollout-row h3 { margin: 0; font-size: 13px; font-weight: 600; } -.sandbox-rollout-row > .text-action { margin-left: auto; } .sandbox-rollout-detail { display: grid; gap: 16px; } .sandbox-rollout-detail-status { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .sandbox-rollout-facts { margin: 0; } diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index 58cf365b9..3fa0d84d1 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -51,7 +51,7 @@ describe("authoritative configuration rollout", () => { }); it("allows same-provider online editing with held resources but keeps reset active as a boundary", () => { - const render = (value: SandboxDeployment) => renderToStaticMarkup( true} onCancelReset={async () => true} onUpdate={async () => true} onRefresh={() => undefined} refreshing={false} pending={false} />); + const render = (value: SandboxDeployment) => renderToStaticMarkup( true} onRefresh={() => undefined} refreshing={false} pending={false} />); const html = render(deployment); expect(html).toMatch(/]*class="button outline"[^>]*>Change resources<\/button>/); expect(html).not.toMatch(/]*disabled[^>]*>Change resources<\/button>/); diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx index 5a1f99de7..67f3c521f 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx @@ -19,10 +19,12 @@ export function SandboxRolloutSummary({ deployment, stale = false, onOpen }: { d const tone: Tone = stale || needsAttention || unknown ? "warning" : rollout.state === "preparing" ? "pending" : "neutral"; const count = (value: number) => formatInteger(value, i18n.resolvedLanguage); return <> -
-

{t("Configuration rollout")}

- - +
+

{t("Configuration rollout")}

+
+ + +
setOpen(false)} footer={onOpen ? : undefined}>
diff --git a/apps/web/src/features/settings/SettingsPage.tsx b/apps/web/src/features/settings/SettingsPage.tsx index 48f099e12..c41c16f78 100644 --- a/apps/web/src/features/settings/SettingsPage.tsx +++ b/apps/web/src/features/settings/SettingsPage.tsx @@ -20,6 +20,6 @@ export function SettingsPage() { return
void models.refetch()} label={tSystem("refresh")} /> : undefined} /> - {section === "models" ? : } + {section === "models" ? : }
; } diff --git a/apps/web/src/features/settings/SettingsTabs.tsx b/apps/web/src/features/settings/SettingsTabs.tsx index 8977597c4..1a84757c4 100644 --- a/apps/web/src/features/settings/SettingsTabs.tsx +++ b/apps/web/src/features/settings/SettingsTabs.tsx @@ -8,6 +8,6 @@ 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 index 86e3c3f70..11ba702d0 100644 --- a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx +++ b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx @@ -77,19 +77,16 @@ export function WorkspaceStorageSettings() { const query = useQuery(workspaceStorageQuery); const [editor, setEditor] = useState<"configuration" | "capacity" | null>(null); const current = query.data; - return
+ const blocked = query.isError || query.isFetching; + return
setEditor("configuration")}>{t("configure")} : undefined}> {query.isError ? void query.refetch()} /> : null} {current === undefined && !query.isError ? : null} - {current ?
+ {current ?
{current.adapter}
{JSON.stringify(current.parameters, null, 2)}
- {current.tenant_capacity_mib === 0 ? t("noQuota") : current.tenant_capacity_mib.toLocaleString()} -
: current === null ?

{t("notConfigured")}

: null} - {current !== undefined ?
- {current ? : null} - -
: null} + setEditor("capacity")}>{t("editCapacity")}}>{current.tenant_capacity_mib === 0 ? t("noQuota") : current.tenant_capacity_mib.toLocaleString()} +
: current === null ?

{t("notConfigured")}

: null} {editor && current !== undefined ? setEditor(null)} /> : null}
; } diff --git a/apps/web/src/features/settings/settings.css b/apps/web/src/features/settings/settings.css index e7b0ae210..39fef8a77 100644 --- a/apps/web/src/features/settings/settings.css +++ b/apps/web/src/features/settings/settings.css @@ -1,2 +1,174 @@ -.settings-tabs { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 24px; border-bottom: 1px solid var(--line); } -.settings-tabs [aria-current="page"] { background: var(--surface-muted); color: var(--ink); font-weight: 600; } +/* Settings: page › group tab › section › label/value row, in one bounded column. */ + +.page-header:has(+ .settings-tabs) { border-bottom: 0; } + +.settings-tabs { + display: flex; + gap: 24px; + padding: 0 var(--page-gutter); + overflow-x: auto; + border-bottom: 1px solid var(--line); + scrollbar-width: none; +} + +.settings-tab { + position: relative; + flex: none; + height: 40px; + padding: 0; + color: var(--ink-2); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + background: none; + border: 0; + cursor: pointer; + transition: color 120ms var(--ease-out-strong); +} + +.settings-tab:hover, +.settings-tab[aria-current="page"] { color: var(--ink); } + +.settings-tab[aria-current="page"]::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + content: ""; + background: var(--ink); + border-radius: 1px; +} + +.settings-tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + border-radius: var(--radius-control); +} + +.console-page-body.settings-body { gap: 32px; } +.settings-body > :not(.modal-backdrop) { max-width: 880px; } +.settings-body > .sandbox-wizard { max-width: none; } +.settings-body .console-section { gap: 12px; } +.settings-body .console-section-header, +.settings-body .console-section-actions { flex-wrap: wrap; } +.settings-body .console-section-header h2 { font-size: 14px; line-height: 20px; } + +/* One card per section; rows divided by hairlines. */ +.settings-rows { + min-width: 0; + margin: 0; + background: var(--surface); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); +} + +.settings-row { + display: grid; + grid-template-columns: 200px minmax(0, 1fr); + align-items: start; + column-gap: 24px; + margin: 0; + padding: 8px 16px; + font-size: 13px; + line-height: 18px; + border-bottom: 1px solid var(--grid-line); +} + +.settings-row:last-child { border-bottom: 0; } + +.settings-row > dt, +.settings-row > .settings-row-label { + display: flex; + min-width: 0; + min-height: 32px; + align-items: center; + gap: 4px; + margin: 0; + color: var(--ink-2); + font-size: 13px; + font-weight: 400; + line-height: 18px; + letter-spacing: normal; +} + +.settings-row > dd, +.settings-row > .settings-row-control { + display: flex; + min-width: 0; + min-height: 32px; + flex-flow: row wrap; + align-items: center; + gap: 4px 8px; + margin: 0; + color: var(--ink); + overflow-wrap: anywhere; +} + +.settings-row-content { min-width: 0; flex: 1; } +.settings-row-action { display: flex; flex: none; gap: 8px; margin-left: auto; } + +.settings-row pre { + margin: 0; + padding: 7px 0; + white-space: pre-wrap; +} + +/* An editable value: a sized field, its local Save, and its error underneath. */ +.settings-row .settings-row-control input { + width: 128px; + flex: none; + font-variant-numeric: tabular-nums; +} + +.settings-row .settings-row-control .field-error { flex-basis: 100%; } + +.settings-empty { + margin: 0; + padding: 16px; + color: var(--ink-2); + font-size: 13px; +} + +/* A table in a section: one framed card, no extra rule above it. */ +.settings-table { + min-width: 0; + overflow-x: auto; + background: var(--surface); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); +} + +.settings-table .data-table tbody tr:last-child > * { border-bottom: 0; } +.settings-table .data-table tbody th { font-weight: 500; text-align: left; } +.settings-table .data-table .settings-table-action { width: 1%; text-align: right; white-space: nowrap; } + +/* The irreversible operation closes the page, apart from the settings above it. */ +.settings-danger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + background: var(--surface); + border-radius: var(--radius-card); + box-shadow: 0 0 0 1px color-mix(in oklch, var(--red) 32%, transparent); +} + +.console-page-body.settings-body .settings-danger p { + max-width: 64ch; + margin: 0; + color: var(--ink); + font-size: 13px; + line-height: 20px; +} + +.settings-danger .button { flex: none; } + +@media (max-width: 640px) { + .settings-tabs { gap: 20px; padding: 0 16px; } + .settings-row { grid-template-columns: minmax(0, 1fr); padding: 8px 12px; } + .settings-row > dt, + .settings-row > .settings-row-label { min-height: 24px; } + .settings-danger { flex-direction: column; align-items: flex-start; } +} diff --git a/apps/web/src/features/system/Fact.tsx b/apps/web/src/features/system/Fact.tsx index 47da25aef..6a00a4bff 100644 --- a/apps/web/src/features/system/Fact.tsx +++ b/apps/web/src/features/system/Fact.tsx @@ -2,15 +2,15 @@ import type { ReactNode } from "react"; import { HelpTip } from "../../components/console-ui"; -/** One label/value row of a System page ledger. */ -export function Fact({ label, help, children }: { label: string; help?: string; children: ReactNode }) { +/** One label/value row of a Settings section; an action beside the value changes only that value. */ +export function Fact({ label, help, action, children }: { label: string; help?: ReactNode; action?: ReactNode; children: ReactNode }) { return ( -
+
{label} {help ? {help} : null}
-
{children}
-
+
{action ? <>
{children}
{action}
: children}
+ ); } diff --git a/apps/web/src/features/system/HarnessCard.tsx b/apps/web/src/features/system/HarnessCard.tsx index 550b8a2ec..0da70e124 100644 --- a/apps/web/src/features/system/HarnessCard.tsx +++ b/apps/web/src/features/system/HarnessCard.tsx @@ -1,7 +1,7 @@ import type { CoreHarness } from "@oac/agents-client"; import { useId } from "react"; import { useTranslation } from "react-i18next"; -import { StatusDot } from "../../components/console-ui"; +import { HelpTip, StatusDot } from "../../components/console-ui"; import { formatDateTime, formatInteger } from "../../lib/format"; import { harnessNames, protocolNames } from "../../lib/harness-labels"; import { Fact } from "./Fact"; @@ -15,9 +15,14 @@ export function HarnessCard({ harness, busy, stale, onEdit, onClear }: { harness const configuration = harness.model_configuration; const provider = configuration?.model_provider; return ( -
+
-

{name}

+
+

{name}

+ + {harness.default ? {t("models.default")} : null} + {t("models.startupHelp")} +
{/* A disabled harness may still be configured; Core keeps the provider until it is enabled. */}
-
- - - - {harness.default ? {t("models.default")} : null} - - - {provider && configuration ? <> - {configuration.model} - {protocolNames[provider.protocol]} - {provider.base_url} - {provider.api_key_configured ? t("models.keyConfigured") : t("models.keyNotConfigured")} - {provider.context_window !== undefined ? {formatInteger(provider.context_window, locale)} : null} - {provider.max_output_tokens !== undefined ? {formatInteger(provider.max_output_tokens, locale)} : null} - {formatDateTime(Math.floor(Date.parse(configuration.updated_at) / 1000), locale)} - : ( - {t("models.notSet")} - )} -
+ {provider && configuration ? <> + {configuration.model} + {protocolNames[provider.protocol]} + {provider.base_url} + {provider.api_key_configured ? t("models.keyConfigured") : t("models.keyNotConfigured")} + {provider.context_window !== undefined ? {formatInteger(provider.context_window, locale)} : null} + {provider.max_output_tokens !== undefined ? {formatInteger(provider.max_output_tokens, locale)} : null} + {formatDateTime(Math.floor(Date.parse(configuration.updated_at) / 1000), locale)} + : ( + {t("models.notSet")} + )} {configuration ? : null}
); diff --git a/apps/web/src/features/system/ProviderObservations.tsx b/apps/web/src/features/system/ProviderObservations.tsx index 2ba5a18a7..6a7b9b4d5 100644 --- a/apps/web/src/features/system/ProviderObservations.tsx +++ b/apps/web/src/features/system/ProviderObservations.tsx @@ -24,12 +24,12 @@ export function ProviderObservations({ provider, name, stale }: { provider: Harn
setOpen(false)}> -
+
{date(provider.last_used_at)} {activeError && provider.last_error_code ? <>{diagnosticFailure({ code: provider.last_error_code, params: {}, failed_at: provider.last_error_at }, t)} {date(provider.last_error_at)} : t("providerNoError")} -
+
{stale ?

{t("providerStaleHelp")}

: null} ; diff --git a/apps/web/src/features/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx index 3ceb8c649..be8052a27 100644 --- a/apps/web/src/features/system/StartupSettings.tsx +++ b/apps/web/src/features/system/StartupSettings.tsx @@ -21,8 +21,7 @@ export function StartupSettings({ configuration }: { configuration: CoreInstalla const { t } = useTranslation("system"); return (
-

{t("startup.effective")}

-
+
diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 00d29215c..e8cc9863d 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -17,14 +17,14 @@ import "./system.css"; /** A facts card before its first read; the page announces its loading once. */ function FactsSkeleton({ facts }: { facts: number }) { return ( - ))} - + ); } @@ -38,14 +38,14 @@ export function SystemPage() { const about = installation.data; const facts = about ? (
-
+
{about.public_url} {about.source_commit ? {about.source_commit.slice(0, 12)} : {t("installation.unknown")}} -
+
) : installation.isError && !installation.isFetching ? void installation.refetch()} /> @@ -62,7 +62,7 @@ export function SystemPage() { actions={} /> - + {reading ?

{t("loading")}

: null} {facts} diff --git a/apps/web/src/features/system/system.css b/apps/web/src/features/system/system.css index 07f783f17..655548401 100644 --- a/apps/web/src/features/system/system.css +++ b/apps/web/src/features/system/system.css @@ -1,52 +1,4 @@ -/* Platform › System: read-only deployment facts, each ledger one card. */ - -.system-facts { - margin: 0; - overflow: hidden; - background: var(--surface); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); -} - -.system-fact { - display: grid; - min-height: 44px; - grid-template-columns: 240px minmax(0, 1fr); - align-items: center; - font-size: 13px; - line-height: 18px; - border-bottom: 1px solid var(--grid-line); -} - -.system-fact > dt, -.system-fact > dd { - padding: 6px 12px; -} - -.system-fact:last-child { - border-bottom: 0; -} - -.system-fact dt { - display: flex; - min-width: 0; - align-items: center; - gap: 4px; - color: var(--ink-2); -} - -.system-fact dd { - min-width: 0; - margin: 0; - color: var(--fg); -} - -/* A second line under a fact, such as microsandbox's disks. */ -.system-sub { - display: block; - color: var(--ink-2); - font-size: 12.5px; -} +/* Settings › Service and deployment, Models and credentials: values, code and harness cards. */ .system-code { color: var(--fg); @@ -65,12 +17,6 @@ font-size: 12px; } -.system-note { - margin: 0; - color: var(--ink-2); - font-size: 13px; -} - .system-setting-value { display: inline-flex; flex-wrap: wrap; @@ -78,69 +24,55 @@ gap: 4px 6px; } -/* Default model: one card per harness, its startup state and provider facts. */ +/* Default model: one card per harness, stacked; its header carries the startup state and the actions. */ .system-models { display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - align-items: start; gap: 16px; } -.system-model { - display: flex; - min-width: 0; - flex-direction: column; - overflow: hidden; - background: var(--surface); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); -} - .system-model-header { display: flex; - min-height: 48px; + min-height: 56px; + flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 8px; - padding: 8px 12px; + gap: 8px 16px; + padding: 12px 16px; border-bottom: 1px solid var(--grid-line); } +.system-model-title { + display: flex; + min-width: 0; + align-items: center; + gap: 4px 10px; + flex-wrap: wrap; +} + .system-model-header h3 { margin: 0; - color: var(--fg); + color: var(--ink); font-size: 13px; font-weight: 600; - line-height: 18px; + line-height: 20px; } .system-model-actions { display: flex; flex-shrink: 0; - gap: 6px; + gap: 8px; } .system-model-observations { display: flex; + min-height: 40px; align-items: center; - justify-content: space-between; gap: 12px; - padding: 8px 12px; - border-top: 1px solid var(--grid-line); + padding: 4px 16px; } -.system-model-facts { - margin: 0; -} - -.system-model-facts .system-fact { - grid-template-columns: 136px minmax(0, 1fr); -} - -.system-model-state { - display: inline-flex; - align-items: center; - gap: 2px; +.system-model-observations > .text-action { + margin-left: auto; } /* The two token limits side by side in the provider form. */ diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 39d3591be..72a349793 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -18,7 +18,6 @@ export const system = { startup: { title: "Startup settings", help: "Core reports the process settings it loaded. A sensitive setting shows only whether it is set.", - effective: "These are the settings this Core process loaded.", columns: { key: "Setting", value: "Value", @@ -40,7 +39,6 @@ export const system = { disabled: "Disabled", default: "Default", startupHelp: "Which harnesses are enabled, and the default one, are managed in the installation configuration.", - harness: "Harness", provider: "Model provider", notSet: "Not set", model: "Default model ID", diff --git a/apps/web/src/i18n/locales/en/workspace-storage.ts b/apps/web/src/i18n/locales/en/workspace-storage.ts index 9989df663..8c7e83bc2 100644 --- a/apps/web/src/i18n/locales/en/workspace-storage.ts +++ b/apps/web/src/i18n/locales/en/workspace-storage.ts @@ -3,7 +3,7 @@ export const workspaceStorage = { refresh: "Refresh current selection", observed: "Current selection: {{value}}", selectionChanged: "The selected storage configuration changed. Close this dialog and reopen Edit tenant capacity to edit the current selection.", - title: "Workspace storage", + title: "Storage configuration", help: "Storage selection and tenant admission policy have one home here. Existing workspaces retain their original storage binding and admitted capacity. Saving a selection does not migrate files or rebuild sandboxes.", id: "Configuration ID", adapter: "Adapter", diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index c296c49f9..747c1908f 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -2,6 +2,9 @@ import { firstRun } from "./first-run"; export const sandbox = { "Current usage exceeds a saved limit. Existing sandboxes are preserved; new placements wait for capacity.": "当前用量超过已保存上限。已有沙箱保留;新的分配需等待容量。", "Capacity and suspension": "容量与挂起", + "The idle policy applies to the whole deployment. Active and retained limits are set for each node under Sandbox nodes.": "空闲策略对整个部署生效;活跃与保留上限在“沙箱节点”中按节点设置。", + "Reset": "重置", + "Clear this configuration to choose a different backend. Hosted Sessions are archived permanently.": "清除当前配置后才能选择其他后端。托管 Session 将被永久归档。", "Idle before suspension (seconds)": "空闲多久后挂起(秒)", "Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.": "整个部署统一生效:30–86,400 秒。适用于现有和新建的运行中沙箱,无需重启。已经开始的挂起和快照保留期不变。容量紧张时,沙箱空闲 15 秒后可能提前挂起。", "Enter a whole number from 30 to 86,400.": "请输入 30 到 86,400 之间的整数。", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 889e9fa93..668e0e9c5 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -20,7 +20,6 @@ export const system: TranslationShape = { startup: { title: "启动设置", help: "Core 报告它加载的进程设置。敏感设置只显示是否已设置。", - effective: "这些是这个 Core 进程加载的设置。", columns: { key: "设置", value: "值", @@ -42,7 +41,6 @@ export const system: TranslationShape = { disabled: "未启用", default: "默认", startupHelp: "启用哪些执行框架、默认用哪个,由安装配置管理。", - harness: "执行框架", provider: "模型服务", notSet: "未设置", model: "默认模型 ID", diff --git a/apps/web/src/i18n/locales/zh-CN/workspace-storage.ts b/apps/web/src/i18n/locales/zh-CN/workspace-storage.ts index 71e87b061..2cbfafa51 100644 --- a/apps/web/src/i18n/locales/zh-CN/workspace-storage.ts +++ b/apps/web/src/i18n/locales/zh-CN/workspace-storage.ts @@ -3,7 +3,7 @@ export const workspaceStorage = { refresh: "刷新当前选择", observed: "当前选择:{{value}}", selectionChanged: "所选存储配置已变更。请关闭此对话框,再次打开“修改租户容量”,以编辑当前配置。", - title: "工作区存储", + title: "存储配置", help: "存储选择和租户容量准入策略只在这里配置。已有工作区保留原存储绑定和已分配容量。保存配置不会迁移文件或重建沙箱。", id: "配置 ID", adapter: "适配器",