diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 2070abaa0..7761fe578 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -433,11 +433,11 @@ The hierarchy is page, tab, section, row. The page header loses its rule to an u 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. -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. +Workspace storage shows the selected configuration's adapter and tenant capacity (in bytes units, or No tenant capacity limit) under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. The configuration ID and parameters JSON sit in a closed Configuration details disclosure at the end of the card. Select new configuration accepts an adapter identifier, configuration ID, parameters JSON object and optional write-only credential JSON object. Edit tenant capacity keeps the current configuration identity. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. Never prefill credentials or persist their drafts across navigation. A failed read is not an unconfigured filesystem, and an uncertain write is not a safe automatic retry. -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. +Models and credentials stacks one default-model card per harness. Its header carries the harness name, its readiness (Ready in green; Model provider not set in amber, with Set as the primary button; Disabled in neutral, with a View startup settings text action), 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. +Service and deployment shows installation addresses, an Installation details disclosure, closed by default, with the 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. Each row's label is the setting's environment variable in code style (OAC_LOG_LEVEL), as Core reports it; values show as is, with arrays joined, On or Off for booleans and None for an empty list. How to change a setting in the section header links to the configuration guide. Sensitive settings show only Configured or Not set. Default uses a neutral pill. ### One place for each task 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. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 5eb3443bd..23b0a3e0a 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -22,7 +22,7 @@ The console runs beside the administrator's own Core, with execution, files and ## Operating Context -- Paired console (`services/web`): the administrator signs in with the deployment's [Core key](../../docs/getting-started/operations.md#core-key). Sign-in shows a copyable Docker Compose command to read the key on the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. +- Paired console (`services/web`): the administrator signs in with the deployment's [Core key](../../docs/getting-started/operations.md#core-key). Sign-in shows the copyable `~/.oac/core/oac core-key --show` command that reads the key on the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. - `/console/config` reports the node installer (`node_installer`, `node_installer_sha256`), offered only with a 64-hex digest. Native self-hosted installation does not depend on this endpoint. It also lists the providers it has node files for (`node_artifacts`); without the deployment's provider, Add node says so and issues no command. Signing in grants administration, sandbox administration included. - Chinese and English UI; light and dark themes; reduced motion honored. @@ -30,8 +30,8 @@ The console runs beside the administrator's own Core, with execution, files and ## Information Architecture - **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), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. -- **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. -- **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. +- **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. An empty list says, in one sentence, that applications create these resources through the Agents API, and links to that resource's section of the API guide; Agent metrics does the same for Sessions. +- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity observations, host figures, allocations and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset, idle policy, per-node capacity and enrollment. Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Nodes links to these forms; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each harness's default model and shows whether each harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. - A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows. - A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes list and its page its status is Old address, with "Remove and add again", never Available. - **Sandbox reset** is an explicit administrator operation in Settings → Sandbox and scheduling. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save. @@ -40,13 +40,13 @@ The console runs beside the administrator's own Core, with execution, files and - **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. +- Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, Skill, Vault, Credential, Harness). The sign-in credential is the Core key ("Core 密钥"); keys issued in a project for applications are project API keys ("项目 API 密钥"). Empty lists read "No … yet" ("还没有…"). Provider readiness is "Provider not ready / 提供方未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms. ## Capabilities and Constraints -- **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. 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. +- **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. The list shows active projects by default; the Archived filter, with its count, shows the rest. Archiving a project revokes every key and keeps its assets viewable and deletable; it can't be undone, so its confirmation says so, counts the active keys it revokes and, when there are any, asks for the project's name. Key plaintext is shown once, at issuance, and never stored by the console. Beside it, and without the key on an active project's page, the console tells developers to set `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python samples that list Agents and create a Session. - **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures: Nodes and Sandbox metrics own those counts. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. -- **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. +- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the existing storage API. It shows the adapter and tenant capacity; the configuration ID and adapter parameters stay under Configuration details. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save. - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **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. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 92e684f9f..815ebe0bc 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -18,11 +18,11 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and await page.goto("/"); await expect(page.getByRole("heading", { name: "Sign in to OpenAgentCore" })).toBeVisible(); - await expect(page.getByText('docker compose -f "$HOME/.oac/core/compose.yaml" exec -T web oac-web core-key', { exact: true })).toBeVisible(); + await expect(page.getByText('~/.oac/core/oac core-key --show', { exact: true })).toBeVisible(); await expect(page.getByText("For a custom installation directory, replace the path in this command.")).toBeVisible(); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await page.getByRole("button", { name: "Copy key read command" }).click(); - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('docker compose -f "$HOME/.oac/core/compose.yaml" exec -T web oac-web core-key'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('~/.oac/core/oac core-key --show'); await signIn(page, "not-the-core-key"); await expect(page.getByRole("alert")).toHaveText("This Core key is not correct. Check it and try again."); await signIn(page, FIXTURE_CORE_KEY); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index f3189ffb0..c4200664c 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -44,8 +44,8 @@ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; /** GET /core/v1/installation: the address, the startup settings Core loaded and what is bound to the address. */ function installation() { const local = state.installation === "local"; - const setting = (key, value, fallback, restarts, extra = {}) => ({ key, value, default: fallback, changeable: true, sensitive: false, restarts, ...extra }); - const secret = (key, restarts) => ({ key, value: null, default: null, changeable: true, sensitive: true, restarts, configured: true }); + const setting = (key, value, fallback, restarts) => ({ key, value, default: fallback, sensitive: false, restarts }); + const secret = (key, restarts) => ({ key, value: null, default: null, sensitive: true, restarts, configured: true }); const nodes = state.deployment?.provider && state.deployment.provider !== "e2b" ? state.nodes : []; return { // As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url. @@ -53,13 +53,17 @@ function installation() { local_only: local, source_commit: release.source_commit, configuration: { settings: [ - setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), - setting("listen_address", "127.0.0.1:8091", "127.0.0.1:8091", ["core"]), - setting("web_listen_address", "127.0.0.1:4173", "127.0.0.1:4173", ["web"]), - setting("data_dir", "/var/lib/oac", "/var/lib/oac", [], { changeable: false }), - setting("log_level", "debug", "info", ["core", "web"]), - secret("core_key", ["core", "web"]), - secret("database_url", ["core"]), + // The settings Core reports (services/core/internal/processconfig), with the enabled harnesses of this fixture. + setting("OAC_PUBLIC_URL", publicUrl(), null, ["core", "web"]), + setting("OAC_LOG_LEVEL", "debug", "info", ["core", "web"]), + setting("OAC_LOG_FORMAT", "auto", "auto", ["core", "web"]), + setting("OAC_LOG_ADD_SOURCE", false, false, ["core", "web"]), + setting("OAC_EXECUTION_CONCURRENCY", 4, 4, ["core"]), + setting("OAC_HARNESSES", ["claude_sdk", "codex"], ["claude_sdk", "codex", "mcode"], ["core"]), + setting("OAC_DEFAULT_HARNESS", "codex", "codex", ["core"]), + setting("OAC_WRITE_AUDIT_RETENTION", "2160h", "2160h", ["core"]), + setting("OAC_OAUTH_TRUSTED_ORIGINS", [], [], ["core"]), + secret("OAC_HISTORY_SETTINGS_FILE", ["core"]), ], }, // As Core counts them: the deployment's retained and pending hosted sandboxes, and the unrevoked executor credentials. diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index d5cd9be7f..1d26b0078 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -51,6 +51,18 @@ test("archives a project with active keys only once its name is typed", async ({ await expect(page.getByText("Archived").first()).toBeVisible(); }); +test("lists active projects by default and archived ones on request", async ({ page, request }) => { + await openConsole(page, request, "projects"); + const table = page.getByRole("table", { name: "Projects" }); + await expect(table).toContainText("Production"); + await expect(table).not.toContainText("Legacy"); + const status = page.getByRole("radiogroup", { name: "Project status" }); + await status.getByRole("radio", { name: /^Archived/ }).click(); + await expect(status.getByRole("radio", { name: /^Archived/ })).toContainText("1"); + await expect(table).toContainText("Legacy"); + await expect(table).not.toContainText("Production"); +}); + test("reports an unconfirmed key issue and never replays it", async ({ page, request }) => { await openConsole(page, request, "projects"); await page.getByRole("button", { name: "Open Production" }).click(); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 7a5e2ace2..d5d96e1cf 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -46,13 +46,17 @@ test("lists the startup settings on System", async ({ page, request }) => { await openConsole(page, request, "settings?id=service"); const installation = page.getByRole("region", { name: "Installation" }); await expect(installation).toContainText("https://core.example.com/v1"); + // The installation ID and source commit wait behind a closed disclosure. + await expect(installation.getByText("Installation ID")).toBeHidden(); + await installation.getByText("Installation details").click(); + await expect(installation.getByText("Installation ID")).toBeVisible(); const startup = page.getByRole("region", { name: "Startup settings" }); const settings = startup.getByRole("table", { name: "Startup settings" }); - await expect(settings.getByRole("row", { name: /^log_level/ })).toContainText("debug"); - await expect(settings.getByRole("row", { name: /^listen_address/ })).toContainText("Default"); - await expect(settings.getByRole("row", { name: /^data_dir/ })).toContainText("Fixed after install"); - await expect(settings.getByRole("row", { name: /^core_key/ })).toContainText("Configured"); - await expect(settings.getByRole("row", { name: /^public_url/ })).toHaveCount(0); + // Each setting reads as the variable that sets it; a sensitive one shows only that it is set. + await expect(settings.getByRole("row", { name: /^OAC_LOG_LEVEL/ })).toContainText("debug"); + await expect(settings.getByRole("row", { name: /^OAC_HARNESSES/ })).toContainText("claude_sdk, codex"); + await expect(settings.getByRole("row", { name: /^OAC_HISTORY_SETTINGS_FILE/ })).toContainText("Configured"); + await expect(settings).not.toContainText("OAC_PUBLIC_URL"); }); test("warns on Nodes about a node bound to an old Core address until it is removed", async ({ page, request }) => { diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index 0de4af8a1..d66e89ab7 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -42,3 +42,13 @@ test("reads an Agent saved with a model provider and no harness, without its key for (const text of ["Anthropic Messages", "https://api.minimaxi.com/anthropic", "Configured", "204,800", "65,536"]) await expect(provider).toContainText(text); await expect(page.locator("body")).not.toContainText(/api_key|\bsk-/); }); + +test("explains on a fresh install that applications create resources through the Agents API", async ({ page, request }) => { + await openConsole(page, request, "agents", { fresh: true }); + for (const [view, title, section] of [["agents", "No Agents yet", "agents"], ["templates", "No Environment Templates yet", "environment-templates"], ["skills", "No Skills yet", "skills"], ["files", "No files yet", "source-files"], ["vaults", "No Vaults yet", "vaults"]]) { + await page.goto(`/#${view}`); + await expect(page.getByText(title)).toBeVisible(); + await expect(page.getByText(/through the Agents API\.$/)).toBeVisible(); + await expect(page.getByRole("link", { name: "Read the API guide" })).toHaveAttribute("href", `https://openagentcore.dev/docs/api/public-agent-api#${section}`); + } +}); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index 171e30927..052e2eb98 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -12,10 +12,10 @@ test("sets, replaces and clears a harness's default model configuration, and kee const section = page.getByRole("region", { name: "Default model configuration" }); const codex = section.getByRole("article", { name: "Codex" }); const mcode = section.getByRole("article", { name: "MiniMax Code" }); - await expect(codex).toContainText("Enabled"); + // An enabled harness without a default model provider is not ready; a disabled one says so. + await expect(codex).toContainText("Model provider not set"); await expect(codex).toContainText("Default"); - await expect(codex).toContainText("Not set"); - await expect(section.getByRole("article", { name: "Claude Code" })).toContainText("Not set"); + await expect(section.getByRole("article", { name: "Claude Code" })).toContainText("Model provider not set"); await expect(mcode).toContainText("Disabled"); await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); @@ -62,7 +62,7 @@ test("sets, replaces and clears a harness's default model configuration, and kee await set.getByLabel("Context window").fill("200000"); await set.getByRole("button", { name: "Save" }).click(); await expect(set).toBeHidden(); - for (const text of ["OpenAI Responses", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text); + for (const text of ["Ready", "OpenAI Responses", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text); expect(await page.content()).not.toContain(KEY); // Replacing starts from the saved fields but never from the key; closing the form forgets a typed key. @@ -104,7 +104,7 @@ test("sets, replaces and clears a harness's default model configuration, and kee const confirm = page.getByRole("dialog", { name: "Clear default model configuration" }); await confirm.getByRole("button", { name: "Clear default model configuration" }).click(); await expect(confirm).toBeHidden(); - await expect(codex).toContainText("Not set"); + await expect(codex).toContainText("Model provider not set"); // MiniMax Code needs both limits; the form shows Core's reason. A disabled harness may still be configured. await mcode.getByRole("button", { name: "Set the default model configuration for MiniMax Code" }).click(); @@ -164,7 +164,7 @@ test("requires an explicit supported protocol choice for an older default config const dialog = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); await dialog.getByLabel("API key").fill(KEY); await expect(dialog.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("Select protocol"); - await expect(dialog.getByRole("alert")).toHaveText("This harness does not support OpenAI Chat Completions. Select a supported protocol."); + await expect(dialog.getByRole("alert")).toHaveText("This Harness does not support OpenAI Chat Completions. Select a supported protocol."); await expect(dialog.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); await dialog.getByLabel("API key").press("Enter"); expect(await writes(request)).toEqual([]); diff --git a/apps/web/e2e/unified-settings.spec.ts b/apps/web/e2e/unified-settings.spec.ts index 8b2a860e3..561b64cea 100644 --- a/apps/web/e2e/unified-settings.spec.ts +++ b/apps/web/e2e/unified-settings.spec.ts @@ -18,6 +18,10 @@ test("storage capacity has one settings home and preserves immutable configurati }); await openConsole(page, request, "settings?id=storage"); await expect(page.getByRole("navigation").getByRole("button", { name: "Workspace storage", exact: true })).toHaveAttribute("aria-current", "page"); + // The configuration's identity and raw parameters wait behind Configuration details. + await expect(page.getByText(initial.id)).toBeHidden(); + await page.getByText("Configuration details").click(); + await expect(page.getByText(initial.id)).toBeVisible(); 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); @@ -25,9 +29,9 @@ test("storage capacity has one settings home and preserves immutable configurati 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 expect(page.getByText("8 GiB", { exact: true })).toBeVisible(); await page.reload(); - await expect(page.getByText("8,192", { exact: true })).toBeVisible(); + await expect(page.getByText("8 GiB", { exact: true })).toBeVisible(); }); test("new storage selection validates objects, retains retry identity, and never redisplays server secrets", async ({ page, request }) => { diff --git a/apps/web/src/components/console-ui.tsx b/apps/web/src/components/console-ui.tsx index f821148f1..2bbcc1063 100644 --- a/apps/web/src/components/console-ui.tsx +++ b/apps/web/src/components/console-ui.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from import * as m from "motion/react-m"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { docsUrl } from "../lib/docs"; /** * Shared page grammar for the administrator console. Every page uses the same @@ -292,6 +293,26 @@ export function EmptyState({ ); } +/** The section of the Agents API guide that explains how applications create a resource. */ +export type ApiGuideSection = "agents" | "sessions" | "environment-templates" | "skills" | "source-files" | "vaults"; + +/** + * An empty resource list. Web only reads and deletes these resources, so the + * state says that applications create them and links to the API guide. + */ +export function ApiEmptyState({ icon, title, hint, section }: { icon?: LucideIcon; title: ReactNode; hint?: ReactNode; section: ApiGuideSection }) { + const { t, i18n } = useTranslation(); + return ( + {t("apiEmpty.guide")}} + /> + ); +} + export function SegmentedControl({ label, value, diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index af7e82846..c046ba1b7 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useFailureToast } from "../../components/Toast"; import { ConfirmDialog } from "../../components/ConfirmDialog"; -import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section } from "../../components/console-ui"; +import { ApiEmptyState, EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section } from "../../components/console-ui"; import { CopyableId, ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { useDeleteFlow } from "../../lib/delete-flow"; @@ -71,7 +71,7 @@ function AgentsList() { if (collection.status === "loading" && !collection.items.length) { body = ; } else if (!collection.items.length && !collection.failures.length) { - body = ; + body = ; } else if (!collection.items.length) { body = {tCommon("actions.retry")}} />; } else { diff --git a/apps/web/src/features/api-keys/ProjectsPage.tsx b/apps/web/src/features/api-keys/ProjectsPage.tsx index cd8f91f8e..9f10627dc 100644 --- a/apps/web/src/features/api-keys/ProjectsPage.tsx +++ b/apps/web/src/features/api-keys/ProjectsPage.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, revealInPageBody } from "../../components/console-ui"; +import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, revealInPageBody, SegmentedControl } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { Modal } from "../../components/Modal"; @@ -20,7 +20,6 @@ import { ProjectDetail, useProjectKeys } from "./ProjectDetail"; import { invalidateProjects, projectActivityQuery, projectKeysQuery, projectScope, projectSummaryQuery } from "./project-queries"; import { installationQuery } from "../../lib/installation"; import { projectsQuery } from "../../lib/queries"; -import { ProjectStatus } from "./ProjectStatus"; import { useKeyFlow } from "./use-key-flow"; import "./api-keys.css"; import { TableSkeleton } from "../../components/Skeleton"; @@ -35,9 +34,12 @@ type Dialog = const manageable = (project: AdminProject) => project.archived_at === null; +/** The list shows active or archived projects, never both. */ +type ProjectView = "active" | "archived"; + /** * Platform › Projects and keys. A project owns the assets shared by all of - * its named keys. The list shows every project; the detail shows its keys + * its named keys. The list shows the active or the archived projects; the detail shows its keys * (issue with a one-time plaintext, revoke), assets, usage by key and write * operations. */ @@ -50,6 +52,7 @@ export function ProjectsPage() { const { params, navigate, back } = useConsoleNavigation(); const [selectedId, setSelectedId] = useState(params.id ?? null); const [query, setQuery] = useState(""); + const [view, setView] = useState("active"); const [dialog, setDialog] = useState(null); const [dialogBusy, setDialogBusy] = useState(false); const [dialogError, setDialogError] = useState(null); @@ -72,7 +75,9 @@ export function ProjectsPage() { const projects = state.projects; useFailureToast(refreshError, t("page.refreshFailed"), "projects-refresh"); const names = useMemo(() => projects.map((project) => project.name), [projects]); - const visible = useMemo(() => projects.filter((project) => matchesProject(project, query)), [projects, query]); + const archivedCount = useMemo(() => projects.filter((project) => !manageable(project)).length, [projects]); + const inView = useMemo(() => projects.filter((project) => manageable(project) === (view === "active")), [projects, view]); + const visible = useMemo(() => inView.filter((project) => matchesProject(project, query)), [inView, query]); const selected = selectedId ? byId.get(selectedId) ?? null : null; const keys = useProjectKeys(selected?.id ?? null); // The open project's keys and usage; its write operations show their own progress. @@ -228,13 +233,22 @@ export function ProjectsPage() { if (state.status === "failed" && !projects.length) { body = ; } else if (state.status === "loading" && !projects.length) { - body = ; + body = ; } else if (!projects.length) { body = ; } else { body = ( <> - + + {visible.length ? ( @@ -243,7 +257,6 @@ export function ProjectsPage() { {t("list.name")} - {t("list.status")} {t("list.activeKeys")} {t("list.created")} {t("list.lastActive")}{t("list.lastActiveHelp")} @@ -258,7 +271,6 @@ export function ProjectsPage() { open(project.id)} openLabel={t("list.open", { name: project.name })} /> - {formatInteger(project.active_key_count, locale)} {formatDateTime(epochSeconds(project.created_at), locale)} {formatRelative(lastActive, now, locale)} @@ -276,11 +288,13 @@ export function ProjectsPage() { - ) : ( + ) : query ? ( setQuery("")}>{tCommon("actions.clearSearch")}} /> + ) : ( + )} ); diff --git a/apps/web/src/features/environment-templates/TemplatesPage.tsx b/apps/web/src/features/environment-templates/TemplatesPage.tsx index 1bb8ead31..3f062f07f 100644 --- a/apps/web/src/features/environment-templates/TemplatesPage.tsx +++ b/apps/web/src/features/environment-templates/TemplatesPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useFailureToast } from "../../components/Toast"; import { ConfirmDialog } from "../../components/ConfirmDialog"; -import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, StatusDot } from "../../components/console-ui"; +import { ApiEmptyState, EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, StatusDot } from "../../components/console-ui"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { useDeleteFlow } from "../../lib/delete-flow"; @@ -63,7 +63,7 @@ function TemplatesList() { if (collection.status === "loading" && !collection.items.length) { body = ; } else if (!collection.items.length && !collection.failures.length) { - body = ; + body = ; } else if (!collection.items.length) { body = {tCommon("actions.retry")}} />; } else { diff --git a/apps/web/src/features/files/FilesPage.tsx b/apps/web/src/features/files/FilesPage.tsx index 6b6d6d38a..57b01400e 100644 --- a/apps/web/src/features/files/FilesPage.tsx +++ b/apps/web/src/features/files/FilesPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useFailureToast } from "../../components/Toast"; import { ConfirmDialog } from "../../components/ConfirmDialog"; -import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, SegmentedControl } from "../../components/console-ui"; +import { ApiEmptyState, EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, SegmentedControl } from "../../components/console-ui"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { useDeleteFlow } from "../../lib/delete-flow"; @@ -57,7 +57,7 @@ export function FilesPage() { if (collection.status === "loading" && !collection.items.length) { body = ; } else if (!collection.items.length && !collection.failures.length) { - body = ; + body = ; } else if (!collection.items.length) { body = {tCommon("actions.retry")}} />; } else { diff --git a/apps/web/src/features/first-run/ConsoleAccess.tsx b/apps/web/src/features/first-run/ConsoleAccess.tsx index 5e561f330..e70ce1fc4 100644 --- a/apps/web/src/features/first-run/ConsoleAccess.tsx +++ b/apps/web/src/features/first-run/ConsoleAccess.tsx @@ -140,7 +140,7 @@ function CoreKeyForm({ onAuthenticated }: {

{t("On the Core host, run this command to read the Core key:")}

- +

{t("For a custom installation directory, replace the path in this command.")}

{error ? : null} diff --git a/apps/web/src/features/metrics/AgentMetricsPage.tsx b/apps/web/src/features/metrics/AgentMetricsPage.tsx index 02be0e27f..d8e303e6a 100644 --- a/apps/web/src/features/metrics/AgentMetricsPage.tsx +++ b/apps/web/src/features/metrics/AgentMetricsPage.tsx @@ -8,6 +8,7 @@ import type { TFunction } from "i18next"; import { LiveNumber } from "../../components/live-number"; import { TimeSeriesChart, type TimeSeries } from "../../components/charts/TimeSeriesChart"; import { + ApiEmptyState, EmptyState, HelpTip, Kpi, @@ -224,7 +225,7 @@ function AgentMetricsContent({ return ( <> {failedReads} - + ); } diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index adc43a39c..48e6be964 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -3,6 +3,7 @@ import type { AdminProject, CoreHarness } from "@oac/agents-client"; import { nodeServingReady } from "../fleet/fleet-model"; import { type FleetState } from "../fleet/use-sandbox-fleet"; import { templateBuildStatus } from "../sandbox/deployment-specification"; +import { harnessReady } from "../system/harness-queries"; /** * Getting started on the Overview: four steps to a working deployment, @@ -74,15 +75,11 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false }; } -/** - * Done once the default harness has a deployment default model provider; - * without a default harness, once any enabled harness has one. - */ +/** Done once the default harness is ready; without a default harness, once any harness is. */ export function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { if (!harnesses || harnesses === "failed") return harnesses ? "unknown" : null; const target = harnesses.find((harness) => harness.default); - const set = target ? target.model_configuration !== null : harnesses.some((harness) => harness.enabled && harness.model_configuration !== null); - return set ? "done" : "todo"; + return (target ? harnessReady(target) : harnesses.some(harnessReady)) ? "done" : "todo"; } /** The newest of the projects, most likely the one just created. */ diff --git a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx index 11ba702d0..f884e5977 100644 --- a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx +++ b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx @@ -8,6 +8,8 @@ import { CopyableId } from "../../components/list-ui"; import { Modal } from "../../components/Modal"; import { TableSkeleton } from "../../components/Skeleton"; import { knownCoreError } from "../../lib/core-error"; +import { formatBytes } from "../../lib/format"; +import { docsUrl } from "../../lib/docs"; import { admin } from "../../lib/projects"; import { Fact } from "../system/Fact"; import { storageCapacity, storageObject, workspaceStorageQuery } from "./workspace-storage"; @@ -59,12 +61,12 @@ function StorageEditor({ current, replacement, onClose }: { current: WorkspaceSt
{ event.preventDefault(); void save(); }}> {replacement ? <>

{t("newSelection")}

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