diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 875db00db..aba2dd85d 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -344,8 +344,8 @@ Selects and inputs are 32px fields filled Well Gray with a Hairline ring inside ### List grammar Every resource list, the Session log and the project list share one grammar: -- **ListToolbar**: on project-scoped lists the project filter first, then the SearchField (280px, search icon, Paper with the control ring), then any further filters (segmented status or order, selects); the count sits on the right in 12.5px Pencil ("12 total", "3 of 12", "40 loaded" when more exist). -- **Project column**: shown only while All projects is selected, right after the name (in the Session log, after the status, so a phone shows each Session's status beside it); archived projects are muted. +- **ListToolbar**: on project-scoped lists the project filter first, then the SearchField (280px, search icon, Paper with the control ring), then any further filters (segmented status or order, selects); the count sits on the right in 12.5px Pencil ("12 total", "3 of 12", "40 loaded" when more exist). While a project is selected the toolbar stays, even when that project has nothing to list. +- **Project column**: shown only while All projects is selected, right after the name (in the Session log, Overview's Needs attention and Sandbox metrics' Hosted Runtimes, after the status, so a phone shows each Session's status beside it); archived projects are muted. - **NameCell**: the first column. The name at 500 weight (a link that turns indigo on hover when the row opens a detail page; a muted fallback such as "Untitled" when the resource has no name) with the compact ID underneath in 11.5px mono. The ID's copy button appears on row hover or focus; the full ID lives in its tooltip. - **Creator column**: the last column before the actions, headed "Creator" with a help tip. It shows the creating key's name (its prefix when unnamed) with a small "Revoked" flag for revoked keys, "Unknown" in Graphite when Core has no record, and "—" while loading or when the lookup failed. - **RowActions**: text actions right-aligned at the end of the row, 16px apart, ending with Delete (red on hover). A row click opens the detail page; action clicks do not. @@ -411,7 +411,7 @@ Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and sc ### Sandbox lifecycle -For deployments with a suspension policy, the Nodes list adds Suspended and Retained / limit columns and a node's Capacity strip adds Suspended, Retained / limit and Snapshots; one-line help tips say what each limit counts. Overview adds the suspended total of all registered nodes under its sandbox slots. The idle threshold is edited in Settings → Sandbox and scheduling, where snapshot retention is read-only. Read failures preserve known counts with a visible unconfirmed notice, and absent evidence stays a dash. Allocation rows show elapsed time in the recorded compute phase without estimating expiry. Sandbox metrics places recorded compute beside the separate Runtime status, retaining unavailable diagnostics. Supporting persistence and resume conditions belong in help tips. +For deployments with a suspension policy, the Nodes list adds Suspended and Retained / limit columns and a node's Capacity strip adds Suspended, Retained / limit and Snapshots; one-line help tips say what each limit counts. Overview adds the suspended total of all registered nodes under its sandbox slots. The idle threshold is edited in Settings → Sandbox and scheduling, where snapshot retention is read-only. Read failures preserve known counts with a visible unconfirmed notice, and absent evidence stays a dash. Allocation rows show elapsed time in the recorded compute phase without estimating expiry. Sandbox metrics shows one Status column, Core's lifecycle record; when an active sandbox has no sample, the reason reads in its CPU and Memory cells, never as a second status. Supporting persistence and resume conditions belong in help tips. ### Configuration generations A single rollout row opens a details dialog for Core's target generation, previous-generation sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or while the independent reset is active. Settled is preparation state, not proof that all nodes are ready or all older Sessions have ended. Retained old resources alone must not keep rapid polling alive. Render failed, update-required and unknown target states distinctly. Keep offline/live-provider status separate from a node's durable serving-generation pin; the pin alone never means the node is online or ready. Node detail shows the serving generation and target preparation; allocation detail shows the owned configuration generation. Do not calculate rollout completion from these rows or promise immediate placement on the target. @@ -442,7 +442,7 @@ Service and deployment shows installation addresses, an Installation details dis Each configuration has exactly one editor in Settings; other pages link to it. Resource operations such as Project keys, Session executor credentials and node removal stay on their resource pages. Keep the configuration page flat: resource editors are dialogs and rollout is one status row with a details action. Explanations belong in question-mark help tips, not small-print helper paragraphs. Errors, uncertain outcomes and current usage exceeding a new limit remain visible without duplicating the workflow. ### Diagnostic observations -Failure reasons belong beside the failed Session or Turn status. Their first read uses a skeleton; an unavailable reason names that state and puts the read retry beside its help tip. Technical classifications are translated through one catalogue, not shown as raw error codes. Trace Timing labels Core receipt times separately from tool-reported duration; explanations of batched delivery, clock differences and historical gaps live in help tips. +Failure reasons belong beside the failed Session or Turn status. On the Session page the Status fact stays short, and the reason and its next step read as one block under the facts, at reading width. Their first read uses a skeleton; an unavailable reason names that state and puts the read retry beside its help tip. Technical classifications are translated through one catalogue, not shown as raw error codes. Trace Timing labels Core receipt times separately from tool-reported duration; explanations of batched delivery, clock differences and historical gaps live in help tips. The self-hosted connection panel names Core's observed state, the bound key and last heartbeat. The bound-key action uses the rotation confirmation and the one-time credential flow. Stale observations cannot complete Run on host. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index c56384893..fd56dc204 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -38,7 +38,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Online sandbox configuration** changes the same backend's resources, Runtime or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. - **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. -- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics retains Runtime observation status separately from the matching allocation's recorded phase. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. +- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. 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 and docs (Agent, Session, Turn, Item, Runtime, Environment, Skill, Vault, Credential, Harness); providers are 提供商 (沙箱提供商, 模型提供商). 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" ("还没有…"). Sandbox 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. @@ -54,13 +54,13 @@ The console runs beside the administrator's own Core, with execution, files and - **Waiting for results.** Overview, Session log and Session details name the function whose result the calling application must submit. The console cannot submit that result; environment connection waits stay distinct from function waits. - **Session names.** Wherever Sessions are listed, and on the Session page, a Session is named by its metadata `title`, else its Agent's name, else Untitled Session. - **Session history is read-only.** A Session page reads the Session, its Items and Turns and polls while work is in flight; there is no live event stream. -- **Failure diagnostics.** Failed Session and Turn rows read Core diagnostics and translate its classified reason. The Session page adds one next step where the reason has one, such as checking the model shown on the Session page and the Session's model provider; lists show the reason alone, with the next step in its tooltip. A next step must hold for every Session kind and deployment: a self-hosted Session never uses the default model provider, and an E2B deployment has no nodes. Provider observations in Settings show the reason alone. Core never returns the Harness's or provider's own message, so an unclassified failure says so. The console never infers a cause from raw logs. Unavailable or mismatched diagnostics offer an explicit read retry; refreshing does not replay execution. Trace Timing keeps each Item's Core receipt interval separate from public Turn times and native tool duration. Historical missing timestamps stay unknown, negative clock intervals stay missing, and bounded response truncation remains visible. +- **Failure diagnostics.** Failed Session and Turn rows read Core diagnostics and translate its classified reason. On the Session page the Status fact shows the status alone, and the reason and its next step, where the reason has one, read as one block under the facts, such as checking the model shown on the Session page and the Session's model provider; lists show the reason alone, with the next step in its tooltip. A next step must hold for every Session kind and deployment: a self-hosted Session never uses the default model provider, and an E2B deployment has no nodes. Provider observations in Settings show the reason alone. Core never returns the Harness's or provider's own message, so an unclassified failure says so. The console never infers a cause from raw logs. Unavailable or mismatched diagnostics offer an explicit read retry; refreshing does not replay execution. Trace Timing keeps each Item's Core receipt interval separate from public Turn times and native tool duration. Historical missing timestamps stay unknown, negative clock intervals stay missing, and bounded response truncation remains visible. - **Executor credentials.** Core issues executor credentials; the console does so with the deployment's Core key. A Session page whose environment is self-hosted has an Executor credentials section: issue a credential (shown once as one line of JSON, to copy or download, never stored), rotate it (the old one stops working immediately) or revoke it (the executor disconnects; installed Runtime state and workspace contents are not deleted). The file lets one executor connect for that environment only; it cannot call the Agents API. - **Default provider observations.** Each configured Harness offers Usage details for Core's last successful use and any newer classified provider error. Missing records remain unknown; an error at or before the last success is no longer actionable. These are best-effort observations, not readiness checks. Failed refreshes qualify retained records, and replacing the provider starts a new observation history. - **Host connection.** Core's connection observation and credential metadata share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks Host connected. Stale or failed reads withhold completion. Recovery rotates the bound key, stops the installed daemon, replaces the host credential file and starts the daemon again. - **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. -- **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes stay form-level and are never retried automatically. -- **Read failures.** Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. +- **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; uncertain write outcomes stay form-level and are never retried automatically. +- **Read failures.** A read is tried again once after a 5xx answer or a failed connection, never after a 4xx answer. Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; a first read that fails never mentions earlier data; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. - **Local-only address.** Overview, Nodes and Settings warn when Core reports `local_only` and lead to Settings → Service and deployment to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. - Runtime CPU and memory exist only for Core-managed hosted sandboxes. diff --git a/apps/web/e2e/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts index 2b9c0c528..d70bb1b4d 100644 --- a/apps/web/e2e/diagnostics.spec.ts +++ b/apps/web/e2e/diagnostics.spec.ts @@ -22,7 +22,7 @@ test("reads classified failures and separate Item receipts without execution wri await page.getByRole("radio", { name: /^Failed/ }).click(); const row = page.getByRole("row").filter({ hasText: "The model provider rejected authentication" }).first(); await row.getByRole("button", { name: /^Open Session / }).click(); - await expect(page.getByLabel("Session facts")).toContainText("Check the API key of the Session's model provider"); + await expect(page.getByRole("region", { name: "Failure details" })).toContainText("Check the API key of the Session's model provider"); await page.getByRole("radio", { name: "Trace", exact: true }).click(); await page.locator(".trace-ledger-row-tools").first().click(); await page.getByRole("tab", { name: "Timing", exact: true }).click(); @@ -49,12 +49,13 @@ test("shows diagnostics failure honestly and can retry in Chinese", async ({ pag await row.getByRole("button", { name: /^Open Session / }).click(); await page.getByRole("button", { name: "Language and appearance" }).click(); await page.getByRole("menuitemradio", { name: "简体中文" }).click(); - await expect(page.getByLabel("Session 信息")).toContainText("失败详情不可用"); + const failure = page.getByRole("region", { name: "失败详情" }); + await expect(failure).toContainText("失败详情不可用"); await expect(page.locator("body")).not.toContainText("RAW_SENTINEL"); failed = false; - await page.getByLabel("Session 信息").getByRole("button", { name: "失败详情", exact: true }).click(); - await page.getByLabel("Session 信息").getByRole("button", { name: "重试诊断读取", exact: true }).click(); - await expect(page.getByLabel("Session 信息")).toContainText("模型提供商认证失败"); + await failure.getByRole("button", { name: "失败详情", exact: true }).click(); + await failure.getByRole("button", { name: "重试诊断读取", exact: true }).click(); + await expect(failure).toContainText("模型提供商认证失败"); expect(await writes(request)).toEqual([]); }); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index a2cf4c31a..015cd367f 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -72,7 +72,9 @@ test("keeps a failed Session's reason in sight in the Session log and on its pag // The reason never widens the table: its last column, Delete, stays in view without scrolling sideways. await expect(row.getByRole("button", { name: /^Delete Session / })).toBeInViewport(); await row.getByRole("button", { name: /^Open Session / }).click(); - await expect(page.getByLabel("Session facts")).toContainText(reason); + // The Status fact stays short; the reason reads under the facts. + await expect(page.getByLabel("Session facts")).not.toContainText(reason); + await expect(page.getByRole("region", { name: "Failure details" })).toContainText(reason); await page.getByRole("button", { name: /^Jump to (the|a) failed Turn/ }).click(); await expect(page.locator("li.chat-turn:focus")).toContainText("Failed"); }); @@ -107,7 +109,7 @@ test("shows a self-hosted Session's install command, issues its credential once, // Web displays Core-provided commands with short-lived installation authority. const install = section.getByRole("region", { name: "Connect a host" }); await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText("bash fixture-bootstrap --authorization fixture-short-lived"); - await expect(install.getByRole("link")).toHaveAttribute("href", /docs\/getting-started\/self-hosted.md$/); + await expect(install.getByRole("link")).toHaveAttribute("href", "https://openagentcore.dev/docs/getting-started/self-hosted"); await install.getByRole("combobox", { name: "Host platform" }).click(); await page.getByRole("option", { name: "Windows · PowerShell" }).click(); await expect(install.locator("pre")).toContainText("fixture-bootstrap.ps1 -Authorization fixture-short-lived"); diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index 8ba0c99a1..b836d265e 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -27,6 +27,15 @@ test("goes back to the page a resource was opened from", async ({ page, request await expect(page.getByRole("heading", { name: "Report builder", level: 1 })).toBeVisible(); }); +test("keeps the project filter on a list whose selected project holds nothing", async ({ page, request }) => { + // The archived Legacy project holds no Skills. + await openConsole(page, request, "skills?project=proj_0b533e99"); + await expect(page.getByText("No Skills yet")).toBeVisible(); + await page.getByRole("combobox", { name: "Project", exact: true }).click(); + await page.getByRole("option", { name: "All projects" }).click(); + await expect(page.getByRole("button", { name: /^Open / }).first()).toBeVisible(); +}); + test("reads an Agent saved with a model provider and no harness, without its key", async ({ page, request }) => { // Production holds Spec drafter, saved with only an x_agents_core.model_provider. await openConsole(page, request, "agents?project=proj_7f3a91c2"); diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index f5a3a81f1..db98679e0 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -26,11 +26,14 @@ test("shows each node's retained limit and elapsed phase time without inventing await expect(allocations).not.toContainText("reclaim"); await expect(allocations).not.toContainText("due"); await page.getByRole("button", { name: "Sandbox metrics", exact: true }).click(); - await expect(page.getByRole("columnheader", { name: "Recorded compute" })).toBeVisible(); + // One Status column answers whether each sandbox uses compute, is suspended or is gone. + const runtimes = page.getByRole("table", { name: "Hosted Runtimes" }); + await expect(runtimes.getByRole("columnheader", { name: "Recorded compute" })).toHaveCount(0); + await expect(runtimes.getByRole("row").filter({ hasText: "Suspended" }).first()).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("sandbox-metrics-en.png"), fullPage: true }); await page.getByRole("button", { name: "Language and appearance" }).click(); await page.getByRole("menuitemradio", { name: "简体中文" }).click(); - await expect(page.getByRole("columnheader", { name: "记录的计算状态" })).toBeVisible(); + await expect(page.getByRole("row").filter({ hasText: "已挂起" }).first()).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("sandbox-metrics-zh.png"), fullPage: true }); }); diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index c046ba1b7..d5fb989fa 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -67,6 +67,13 @@ function AgentsList() { // Projects that could not be read are reported in a toast; the list shows the rest. const failedNames = collection.failures.map((failure) => failure.project.name).join(", "); useFailureToast(collection.items.length > 0 && collection.failures.length > 0, tCommon("project.partial", { names: failedNames }), "agents-partial"); + // A selected project keeps its filter in view, even when it holds nothing. + const toolbar = collection.items.length || filter ? ( + + + + + ) : null; let body; if (collection.status === "loading" && !collection.items.length) { body = ; @@ -77,10 +84,6 @@ function AgentsList() { } else { body = ( <> - - - - {rows.length ? (
@@ -141,7 +144,7 @@ function AgentsList() { help={<>{tPages("agents.subtitle")} {t("view.usageHelp")}} actions={} /> - {body} + {toolbar}{body} failure.project.name).join(", "); useFailureToast(collection.items.length > 0 && collection.failures.length > 0, tCommon("project.partial", { names: failedNames }), "templates-partial"); + // A selected project keeps its filter in view, even when it holds nothing. + const toolbar = collection.items.length || filter ? ( + + + + + ) : null; let body; if (collection.status === "loading" && !collection.items.length) { body = ; @@ -69,10 +76,6 @@ function TemplatesList() { } else { body = ( <> - - - - {rows.length ? (
@@ -152,7 +155,7 @@ function TemplatesList() { help={<>{tPages("templates.subtitle")} {t("boundary")}} actions={} /> - {body} + {toolbar}{body} failure.project.name).join(", "); useFailureToast(collection.items.length > 0 && collection.failures.length > 0, tCommon("project.partial", { names: failedNames }), "files-partial"); + // A selected project keeps its filter in view, even when it holds nothing. + const toolbar = collection.items.length || filter ? ( + + + + + + ) : null; let body; if (collection.status === "loading" && !collection.items.length) { body = ; @@ -63,16 +76,6 @@ export function FilesPage() { } else { body = ( <> - - - - - {rows.length ? (
@@ -137,7 +140,7 @@ export function FilesPage() { help={t("help")} actions={} /> - {body} + {toolbar}{body} (runtimeState.load ? hostedRuntimeRows(runtimeState.load, "", fleet) : []), [fleet, runtimeState.load]); // A direct Provider runs sandboxes in its cloud: no machines, so no node column. const cloud = fleet?.deployment.mode === "direct"; - // Only a Provider that declares checkpoint support records a sandbox's compute phase. - const suspends = Boolean(fleet?.deployment.suspension); return (
@@ -127,7 +125,7 @@ export function SandboxMetricsPage() { } - row.observation.session_id === openRuntime) ?? null} showNode={!cloud} showCompute={suspends} onClose={() => setOpenRuntime(null)} /> + row.observation.session_id === openRuntime) ?? null} showNode={!cloud} onClose={() => setOpenRuntime(null)} />
); } @@ -197,7 +195,7 @@ function useRuntimeHistory(snapshot: RuntimeDashboardSnapshot | null, range: Run /** Core's typed errors and Web's own read bound, in the console's language. */ function historyFailure(error: unknown, t: TFunction<"metrics">, tCommon: TFunction<"common">): string { - if (!(error instanceof RuntimeDurableHistoryIncompleteError)) return readError(error, tCommon); + if (!(error instanceof RuntimeDurableHistoryIncompleteError)) return coreError(error, tCommon); return error.limit === null ? t("sandbox.charts.historyMixed") : t("sandbox.charts.historyBudget", { limit: error.limit }); } @@ -226,7 +224,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {durable ? : history.isError ?

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

: history.isFetched ?

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

: null} - + ); } @@ -262,7 +260,6 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R } function lifecycleTone(row: HostedRuntimeRow): Tone { - if (row.observation.status === "unavailable") return "warning"; switch (row.observation.lifecycle_state) { case "active": return "ok"; case "transitioning": @@ -271,12 +268,12 @@ function lifecycleTone(row: HostedRuntimeRow): Tone { } } +/** Status is Core's record of the sandbox's compute; a missing sample only explains the empty figures. */ function lifecycleLabel(row: HostedRuntimeRow, t: TFunction<"metrics">): string { - if (row.observation.status === "unavailable") return t(`sandbox.reason.${row.observation.reason}`); return t(`sandbox.lifecycle.${row.observation.lifecycle_state ?? "stopped"}`); } -function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { rows: HostedRuntimeRow[]; showProject: boolean; showNode: boolean; showCompute: boolean; onOpen: (sessionId: string) => void }) { +function RuntimeTable({ rows, showProject, showNode, onOpen }: { rows: HostedRuntimeRow[]; showProject: boolean; showNode: boolean; onOpen: (sessionId: string) => void }) { const { t, i18n } = useTranslation("metrics"); const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; @@ -293,10 +290,9 @@ function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { ro
+ {showProject ? : null} {showNode ? : null} - - {showCompute ? : null} @@ -307,6 +303,7 @@ function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { ro {visible.map((row) => { const { observation, session } = row; const observed = observation.status === "observed"; + const sampleGap = runtimeSampleGap(observation); const cpu = observed ? observation.cpu : null; const memory = observed ? observation.memory : null; const open = () => onOpen(observation.session_id); @@ -315,10 +312,10 @@ function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { ro + {showProject ? : null} {showNode ? : null} - - {showCompute ? : null} + {sampleGap ? : <> + } - + @@ -503,11 +503,11 @@ function AttentionTable({ sessions, older, unreadNames, truncatedNames, now, onO - + ); diff --git a/apps/web/src/features/resources/detail-queries.ts b/apps/web/src/features/resources/detail-queries.ts index 506fb73ed..e43e8d312 100644 --- a/apps/web/src/features/resources/detail-queries.ts +++ b/apps/web/src/features/resources/detail-queries.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type EnvironmentTemplateResource, type SavedAgent, type Skill, type Vault, type VaultCredential } from "@oac/agents-client"; +import { type EnvironmentTemplateResource, type SavedAgent, type Skill, type Vault, type VaultCredential } from "@oac/agents-client"; import { queryOptions, useQuery, useQueryClient, type QueryClient, type QueryKey, type UseQueryOptions } from "@tanstack/react-query"; import { useCallback } from "react"; @@ -16,12 +16,6 @@ type SkillVersionsPage = Awaited>; * show. */ -/** A definite 4xx answer (a missing resource above all) does not change on a retry. */ -export function retryTransient(failureCount: number, error: unknown): boolean { - if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500) return false; - return failureCount < 1; -} - /** The row the project's cached list holds for `id`, if the list was read. */ function listedRow(spec: CollectionSpec, projectId: string, id: string): T | undefined { return queryClient.getQueryData(collectionQuery(spec, projectId).queryKey)?.find((row) => row.id === id); @@ -38,7 +32,6 @@ export function agentQuery(projectId: string, agentId: string) { queryKey: ["agent", projectId, agentId], queryFn: () => projectClient(projectId).retrieveAgent(agentId), placeholderData: () => listedRow(collections.agents, projectId, agentId), - retry: retryTransient, }); } @@ -47,7 +40,6 @@ export function templateQuery(projectId: string, templateId: string) { queryKey: ["template", projectId, templateId], queryFn: ({ signal }) => projectClient(projectId).retrieveEnvironmentTemplate(templateId, { signal }), placeholderData: () => listedRow(collections.templates, projectId, templateId), - retry: retryTransient, }); } @@ -56,7 +48,6 @@ export function vaultQuery(projectId: string, vaultId: string) { queryKey: ["vault", projectId, vaultId], queryFn: ({ signal }) => projectClient(projectId).retrieveVault(vaultId, { signal }), placeholderData: () => listedRow(collections.vaults, projectId, vaultId), - retry: retryTransient, }); } @@ -67,7 +58,6 @@ export function vaultCredentialsQuery(projectId: string, vaultId: string) { const client = projectClient(projectId); return readAllPages((after) => client.listVaultCredentials(vaultId, { after, limit: 100, signal })); }, - retry: retryTransient, }); } @@ -79,7 +69,6 @@ export function skillQuery(projectId: string, skillId: string, core: SkillReader queryKey: ["skill", projectId, skillId], queryFn: ({ signal }) => core.retrieveSkill(skillId, { signal }), placeholderData: () => seed ?? listedRow(collections.skills, projectId, skillId), - retry: retryTransient, }); } @@ -88,7 +77,6 @@ export function skillVersionsQuery(projectId: string, skillId: string, core: Ski return queryOptions({ queryKey: ["skill", projectId, skillId, "versions"], queryFn: ({ signal }) => readSkillVersionsPage(core, skillId, [], undefined, signal), - retry: retryTransient, }); } diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 6d12b4143..767dff3d5 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -5,6 +5,7 @@ import { HelpTip } from "../../components/console-ui"; import { ConsoleSelect } from "../../components/console-select"; import { useCopy } from "../api-keys/IssuedKey"; import { useQuery } from "@tanstack/react-query"; +import { docsUrl } from "../../lib/docs"; import { admin } from "../../lib/projects"; import type { ExecutorInstall, HostShell } from "./executor-install"; @@ -25,7 +26,7 @@ export function useExecutorInstall(projectId: string, environmentId: string, arc /** One home for native installation instructions; issuing a credential does not establish a connection. */ export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstall; archived: boolean; connected?: boolean }) { - const { t } = useTranslation("sessions"); + const { t, i18n } = useTranslation("sessions"); const headingId = useId(); const [shell, setShell] = useState("posix"); return
@@ -38,7 +39,7 @@ export function ExecutorInstallPanel({ install, archived, connected = false }: {

{t(archived ? "executor.install.archived" : "executor.install.steps")}

- {t("executor.install.guide")} + {t("executor.install.guide")} {install.kind === "ready" ? <> { if (value === "posix" || value === "powershell") setShell(value); }} /> diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index b23ff8ed8..9e8bf4a7a 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -15,7 +15,7 @@ import { sessionTitle } from "../../lib/session-title"; import { forgetDeleted } from "../resources/detail-queries"; import { ExecutorCredentialsSection } from "./ExecutorCredentialsSection"; import { SessionDeleteDialog, type SessionDeleteTarget } from "./SessionDeleteDialog"; -import { DiagnosticScope } from "./session-diagnostics"; +import { DiagnosticScope, SessionFailureDetails } from "./session-diagnostics"; import { sessionKey } from "./session-queries"; import { isSessionActive, turnAnchorId } from "./session-history"; import { environmentKind, isDeletable } from "./session-log"; @@ -161,6 +161,7 @@ export function SessionPage() { ) : null} + {session.status === "failed" ? : null} diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx index 371af9363..f5a663aca 100644 --- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx +++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx @@ -5,10 +5,11 @@ import { useTranslation } from "react-i18next"; import { failedLast, useFailureToast } from "../../components/Toast"; import { DashboardSkeleton } from "../../components/Skeleton"; -import { Kpi, KpiStrip, Section, SegmentedControl, type Tone } from "../../components/console-ui"; +import { Kpi, KpiStrip, Section, SegmentedControl } from "../../components/console-ui"; import { formatBytes, formatCores, formatDateTime, formatPercent, formatRelative, MISSING } from "../../lib/format"; -import { readError } from "../../lib/read-error"; +import { coreError } from "../../lib/core-error"; import { RuntimeCharts } from "../metrics/RuntimeCharts"; +import { runtimeSampleGap } from "../metrics/sandbox-runtime"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; import { SESSION_RUNTIME_RANGES, type SessionRuntimeHistory, type SessionRuntimeRange } from "./session-runtime"; @@ -58,6 +59,7 @@ export function SessionRuntimeSection({ }) { const { t, i18n } = useTranslation("sessions"); const { t: tCommon } = useTranslation("common"); + const { t: tMetrics } = useTranslation("metrics"); const locale = i18n.resolvedLanguage; const [range, setRange] = useState(SESSION_RUNTIME_RANGES[0]); @@ -76,16 +78,15 @@ export function SessionRuntimeSection({ const history: { state: "loading" | "ready" | "unavailable" | "failed"; value: SessionRuntimeHistory | null; error: string | null } = { state: historyRead.isError ? "failed" : historyRead.data === undefined ? "loading" : historyRead.data === null ? "unavailable" : "ready", value: historyRead.data ?? null, - error: historyRead.isError ? readError(historyRead.error, tCommon) : null, + error: historyRead.isError ? coreError(historyRead.error, tCommon) : null, }; // Both reads repeat while the Session runs; a failure is reported once while it lasts. useFailureToast(failedLast(observationRead), t("runtime.observationFailed", { reason: observationError ?? "" }), "session-runtime-observation"); useFailureToast(history.value !== null && failedLast(historyRead), t("runtime.historyFailed", { reason: history.error ?? "" }), "session-runtime-history"); - let stateLabel = MISSING; - let stateTone: Tone | undefined; - if (observation?.status === "observed") stateLabel = t(`runtime.lifecycle.${observation.lifecycle_state}`); - else if (observation?.reason) { stateLabel = t(`runtime.reason.${observation.reason}`); stateTone = observation.status === "unavailable" ? "warning" : undefined; } + // The same state and sample reason as Sandbox metrics' Hosted Runtimes. + const stateLabel = observation?.lifecycle_state ? tMetrics(`sandbox.lifecycle.${observation.lifecycle_state}`) : MISSING; + const sampleGap = observation ? runtimeSampleGap(observation) : null; const cpu = observation?.status === "observed" ? observation.cpu : null; const memory = observation?.status === "observed" ? observation.memory : null; const cpuValue = cpu?.usage_cores != null && cpu.capacity_cores != null @@ -111,10 +112,10 @@ export function SessionRuntimeSection({ )} > - + - {formatRelative(observedAt, Math.floor(Date.now() / 1000), locale)}} /> + {formatRelative(observedAt, Math.floor(Date.now() / 1000), locale)}} tone={sampleGap ? "warning" : undefined} /> {history.state === "failed" && !history.value ?

{t("runtime.historyFailed", { reason: history.error ?? "" })}

: null} {history.state === "unavailable" ?

{t("runtime.historyUnavailable")}

: null} diff --git a/apps/web/src/features/sessions/SessionStatus.tsx b/apps/web/src/features/sessions/SessionStatus.tsx index ddacc237c..353138cac 100644 --- a/apps/web/src/features/sessions/SessionStatus.tsx +++ b/apps/web/src/features/sessions/SessionStatus.tsx @@ -16,26 +16,28 @@ export function useWaitingFor() { } /** - * Failures stay visible under the status, truncated only in lists. Required - * actions appear here in lists and in their own facts on the Session page. + * Lists add one truncated line under the status: a failure's reason or what + * the Session waits for. The Session page shows the status alone; its failure + * reads under the facts and its required actions in their own fact. */ -export function SessionStatus({ session, truncate = false, projectId }: { session: AgentSession; truncate?: boolean; projectId?: string }) { +export function SessionStatus({ session, truncate = false, projectId }: { session: AgentSession; truncate?: boolean; projectId: string }) { const { t } = useTranslation("sessions"); const waitingFor = useWaitingFor()(session); const key = statusKey(session.status); - const reason = session.status === "failed" ? session.error : session.status === "requires_action" && truncate ? waitingFor.join(" · ") : null; const status = ( {truncate && session.required_actions.some((action) => action.type === "function_call") ? {t("detail.applicationAction")} : null} ); - if (session.status === "failed" && projectId) return {status}; + if (!truncate) return status; + if (session.status === "failed") return {status}; + const reason = session.status === "requires_action" ? waitingFor.join(" · ") : ""; if (!reason) return status; return ( - + {status} - {reason} + {reason} ); } diff --git a/apps/web/src/features/sessions/session-diagnostics.test.tsx b/apps/web/src/features/sessions/session-diagnostics.test.tsx index b629d8479..f30a93f41 100644 --- a/apps/web/src/features/sessions/session-diagnostics.test.tsx +++ b/apps/web/src/features/sessions/session-diagnostics.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import i18n from "../../i18n"; import { diagnosticFailure } from "../../lib/diagnostic-failure"; -import { DiagnosticScope, SessionFailure, TurnFailure, sessionDiagnosticsQuery, turnDiagnosticsQuery } from "./session-diagnostics"; +import { DiagnosticScope, SessionFailure, SessionFailureDetails, TurnFailure, sessionDiagnosticsQuery, turnDiagnosticsQuery } from "./session-diagnostics"; import { ItemReceiptTiming } from "./trace/ItemReceiptTiming"; const scope = { projectId: "project", sessionId: "session" }; @@ -23,7 +23,7 @@ function render(kind: "session" | "turn", data: SessionDiagnostics | TurnDiagnos client.setQueryData(key, data); if (error) client.getQueryCache().find({ queryKey: key })!.setState({ status: "error", error: new Error("PRIVATE_READ_SENTINEL") }); const html = renderToStaticMarkup( - {kind === "session" ? : } + {kind === "turn" ? : truncate ? : } ); client.clear(); return html; @@ -38,6 +38,10 @@ describe("Core diagnostics in the console", () => { expect(render("turn", turnData)).not.toContain("PRIVATE_NATIVE_SENTINEL"); }); + it("gives the Session page the reason, then its next step, in one block", () => { + expect(render("session", sessionData)).toContain('

The model provider rejected authentication.

Check the API key'); + }); + it("withholds old causes after a failed read or mismatched status", () => { expect(render("session", sessionData, true)).toContain("Failure details unavailable"); expect(render("turn", { ...turnData, status: "completed", failure: null })).not.toContain("rejected authentication"); diff --git a/apps/web/src/features/sessions/session-diagnostics.tsx b/apps/web/src/features/sessions/session-diagnostics.tsx index cfd9b9ac5..c2bc01e63 100644 --- a/apps/web/src/features/sessions/session-diagnostics.tsx +++ b/apps/web/src/features/sessions/session-diagnostics.tsx @@ -4,7 +4,7 @@ import { createContext, useContext } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; -import { diagnosticFailure, diagnosticReason } from "../../lib/diagnostic-failure"; +import { diagnosticFailure, diagnosticNext, diagnosticReason } from "../../lib/diagnostic-failure"; import { admin } from "../../lib/projects"; import { sessionKey } from "./session-queries"; @@ -39,20 +39,33 @@ export function DiagnosticsUnavailable({ pending, retry }: { pending: boolean; r ; } +function useSessionFailure(projectId: string, session: AgentSession) { + const query = useQuery(sessionDiagnosticsQuery(projectId, session)); + return { query, failure: !query.isError && query.data?.status === session.status ? query.data.failure : null }; +} + /** * Mounted for failed rows on the current page only; no fan-out over hidden * Sessions. Lists show the reason alone and keep its next step in the title. */ -export function SessionFailure({ projectId, session, truncate }: { projectId: string; session: AgentSession; truncate: boolean }) { +export function SessionFailure({ projectId, session }: { projectId: string; session: AgentSession }) { const { t } = useTranslation("diagnostics"); - const query = useQuery(sessionDiagnosticsQuery(projectId, session)); - const failure = !query.isError && query.data?.status === session.status ? query.data.failure : null; - const full = failure ? diagnosticFailure(failure, t) : null; - return - {failure ? (truncate ? diagnosticReason(failure, t) : full) : void query.refetch()} />} + const { query, failure } = useSessionFailure(projectId, session); + return + {failure ? diagnosticReason(failure, t) : void query.refetch()} />} ; } +/** The Session page's failure, under its facts: the reason, then its next step. */ +export function SessionFailureDetails({ projectId, session }: { projectId: string; session: AgentSession }) { + const { t } = useTranslation("diagnostics"); + const { query, failure } = useSessionFailure(projectId, session); + const next = failure ? diagnosticNext(failure, t) : ""; + return

+ {failure ? <>

{diagnosticReason(failure, t)}

{next ?

{next}

: null} : void query.refetch()} />} +
; +} + function ScopedTurnFailure({ turn, scope }: { turn: AgentTurn; scope: Scope }) { const { t } = useTranslation("diagnostics"); const query = useQuery(turnDiagnosticsQuery(scope, turn)); diff --git a/apps/web/src/features/sessions/session-queries.test.ts b/apps/web/src/features/sessions/session-queries.test.ts index cb08875e6..4c9976453 100644 --- a/apps/web/src/features/sessions/session-queries.test.ts +++ b/apps/web/src/features/sessions/session-queries.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AgentCoreError, type AgentSession, type AgentTurn, type ListPage, type SessionItem } from "@oac/agents-client"; -import { retryTransient } from "../resources/detail-queries"; +import { queryClient, retryTransient } from "../../lib/queries"; import { SESSION_POLL_MS } from "./session-history"; import { requestFullHistoryRead, sessionHistoryQuery, sessionPollInterval, type SessionHistoryRead } from "./session-queries"; @@ -99,8 +99,9 @@ describe("Session history cache", () => { }); }); -describe("Detail read retries", () => { - it("does not retry a definite 4xx answer and retries anything else once", () => { +describe("Read retries", () => { + it("retries every read once after a 5xx answer or a failed fetch, never after a 4xx answer", () => { + expect(queryClient.getDefaultOptions().queries?.retry).toBe(retryTransient); expect(retryTransient(0, new AgentCoreError("missing", 404))).toBe(false); expect(retryTransient(0, new AgentCoreError("down", 503))).toBe(true); expect(retryTransient(1, new AgentCoreError("down", 503))).toBe(false); diff --git a/apps/web/src/features/sessions/session-queries.ts b/apps/web/src/features/sessions/session-queries.ts index c1515a01c..7bb8fe83c 100644 --- a/apps/web/src/features/sessions/session-queries.ts +++ b/apps/web/src/features/sessions/session-queries.ts @@ -2,7 +2,6 @@ import { AgentCoreError, type AgentSession, type RuntimeObservation } from "@oac import { queryOptions } from "@tanstack/react-query"; import { projectClient } from "../../lib/projects"; -import { retryTransient } from "../resources/detail-queries"; import { loadSessionHistory, nextPollDelay, type SessionHistory } from "./session-history"; import { loadSessionRuntimeHistory, type SessionRuntimeHistory, type SessionRuntimeRange } from "./session-runtime"; @@ -88,7 +87,6 @@ export function sessionObservationQuery(projectId: string, sessionId: string) { return queryOptions({ queryKey: [...sessionKey(projectId, sessionId), "runtime", "observation"], queryFn: ({ signal }) => projectClient(projectId).retrieveRuntimeObservation(sessionId, { signal }), - retry: retryTransient, }); } @@ -97,6 +95,5 @@ export function sessionRuntimeHistoryQuery(projectId: string, session: AgentSess return queryOptions({ queryKey: [...sessionKey(projectId, session.id), "runtime", "history", range], queryFn: ({ signal }) => loadSessionRuntimeHistory(projectClient(projectId), session, range, signal), - retry: retryTransient, }); } diff --git a/apps/web/src/features/sessions/sessions.css b/apps/web/src/features/sessions/sessions.css index d0abc05e2..816e66d06 100644 --- a/apps/web/src/features/sessions/sessions.css +++ b/apps/web/src/features/sessions/sessions.css @@ -557,9 +557,9 @@ margin-right: 8px; } -/* A failed Session's reason, as Core sent it, under its status: in full on the - Session page, its line breaks kept; one truncated line in the Session log, - with the full text in its tooltip. */ +/* A failure's reason under its status: in full under a failed Turn, its line + breaks kept; one truncated line in the Session log, with the full text in + its tooltip. */ .session-status { display: inline-flex; max-width: 100%; @@ -595,6 +595,29 @@ line-clamp: 1; } +/* The Session page's failure, under its facts: the reason, then its next step, + at reading width. */ +.session-failure { + display: grid; + gap: 4px; + max-width: 880px; + padding: 10px 14px; + font-size: 13px; + line-height: 20px; + background: color-mix(in srgb, var(--danger) 5%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--danger) 22%, var(--line)); + border-radius: var(--frame-radius); +} + +.session-failure p { + margin: 0; +} + +.session-failure-reason { + color: var(--danger); + font-weight: 500; +} + /* A jump to a failed Turn focuses it; only keyboard focus draws a ring. */ .chat-turn:focus, .session-turns-table tr:focus { diff --git a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx index f884e5977..78c55c1af 100644 --- a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx +++ b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx @@ -35,7 +35,7 @@ function StorageEditor({ current, replacement, onClose }: { current: WorkspaceSt try { const latest = await client.fetchQuery({ ...workspaceStorageQuery, staleTime: 0 }); setObserved(latest ? `${latest.id} · ${latest.tenant_capacity_mib.toLocaleString()} MiB` : t("notConfigured")); - } catch { setError(t("readFailed")); } + } catch { setError(t("refreshFailed")); } }; const close = () => { if (!writing.current) { setCredential(""); onClose(); } }; const save = async () => { @@ -81,7 +81,7 @@ export function WorkspaceStorageSettings() { const current = query.data; const blocked = query.isError || query.isFetching; return
setEditor("configuration")}>{t("configure")} : undefined}> - {query.isError ? void query.refetch()} /> : null} + {query.isError ? void query.refetch()} /> : null} {current === undefined && !query.isError ? : null} {current ?
{current.adapter} diff --git a/apps/web/src/features/settings/workspace-storage-view.test.tsx b/apps/web/src/features/settings/workspace-storage-view.test.tsx index dbd88ca7f..00191e86d 100644 --- a/apps/web/src/features/settings/workspace-storage-view.test.tsx +++ b/apps/web/src/features/settings/workspace-storage-view.test.tsx @@ -6,9 +6,11 @@ import { WorkspaceStorageSettings } from "./WorkspaceStorageSettings"; import { workspaceStorageQuery } from "./workspace-storage"; const selection = { id: "11111111-1111-4111-8111-111111111111", adapter: "independent-adapter", parameters: { root: "/srv/workspaces" }, tenant_capacity_mib: 4096 }; -function render(value: typeof selection | null) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); - client.setQueryData(workspaceStorageQuery.queryKey, value); +function render(value: typeof selection | null | undefined, failed = false) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, retryOnMount: false, staleTime: Infinity } } }); + if (value === undefined) client.getQueryCache().build(client, { queryKey: workspaceStorageQuery.queryKey }); + else client.setQueryData(workspaceStorageQuery.queryKey, value); + if (failed) client.getQueryCache().find({ queryKey: workspaceStorageQuery.queryKey })!.setState({ status: "error", error: new Error("unavailable") }); const result = renderToStaticMarkup(); client.clear(); return result; @@ -29,6 +31,10 @@ describe("workspace storage settings", () => { expect(html).toContain("Select new configuration"); expect(html).not.toContain("Edit tenant capacity"); }); + it("mentions earlier settings only when a refresh fails after a read", () => { + expect(render(undefined, true)).toContain("Workspace storage could not be read."); + expect(render(selection, true)).toContain("Previously displayed settings may be out of date."); + }); it("renders the same actions in Chinese", async () => { await i18n.changeLanguage("zh-CN"); const html = render(selection); diff --git a/apps/web/src/features/skills/SkillsPage.tsx b/apps/web/src/features/skills/SkillsPage.tsx index fa46c2787..b3aea2b5c 100644 --- a/apps/web/src/features/skills/SkillsPage.tsx +++ b/apps/web/src/features/skills/SkillsPage.tsx @@ -70,6 +70,13 @@ function SkillsList() { // Projects that could not be read are reported in a toast; the list shows the rest. const failedNames = collection.failures.map((failure) => failure.project.name).join(", "); useFailureToast(collection.items.length > 0 && collection.failures.length > 0, tCommon("project.partial", { names: failedNames }), "skills-partial"); + // A selected project keeps its filter in view, even when it holds nothing. + const toolbar = collection.items.length || filter ? ( + + + + + ) : null; let body; if (collection.status === "loading" && !collection.items.length) { body = ; @@ -80,10 +87,6 @@ function SkillsList() { } else { body = ( <> - - - - {rows.length ? (
{t("sandbox.session")}{t("sandbox.status")}{t("sandbox.statusHelp")}{tCommon("project.column")}{t("sandbox.node")}{t("sandbox.status")}{t("sandbox.recordedCompute")}{t("sandbox.recordedComputeHelp")}{t("sandbox.cpu")} {t("sandbox.memory")} {t("sandbox.uptime")} {row.allocation ? sandboxStateLabel(row.allocation.compute_phase, locale?.startsWith("zh") ? "zh-CN" : "en") : MISSING}{t(`sandbox.reason.${sampleGap}`)} {cpu?.usage_cores != null ? ( @@ -341,6 +338,7 @@ function RuntimeTable({ rows, showProject, showNode, showCompute, onOpen }: { ro ) : {MISSING}} {formatDuration(row.uptimeSeconds)} {session?.usage ? formatCompact(session.usage.total_tokens, locale) : MISSING} @@ -373,8 +371,8 @@ function useLast(value: T | null): T | null { } /** A hosted sandbox in a dialog: where it runs, and its Session's Runtime state and history. */ -function RuntimeDialog({ row, showNode, showCompute, onClose }: { row: HostedRuntimeRow | null; showNode: boolean; showCompute: boolean; onClose: () => void }) { - const { t, i18n } = useTranslation("metrics"); +function RuntimeDialog({ row, showNode, onClose }: { row: HostedRuntimeRow | null; showNode: boolean; onClose: () => void }) { + const { t } = useTranslation("metrics"); const { t: tCommon } = useTranslation("common"); const { navigate } = useConsoleNavigation(); const { byId } = useProjects(); @@ -394,7 +392,6 @@ function RuntimeDialog({ row, showNode, showCompute, onClose }: { row: HostedRun
{tCommon("project.column")}
{showNode ?
{t("sandbox.node")}
: null}
{t("sandbox.status")}
- {showCompute && shown.allocation ?
{t("sandbox.recordedCompute")}
{sandboxStateLabel(shown.allocation.compute_phase, i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en")}
: null}
{t("sandbox.configurationGeneration")}
{shown.deploymentGeneration ?? MISSING}
{t("sandbox.uptime")}
{formatDuration(shown.uptimeSeconds)}
{/* Only E2B reports a sandbox's disk. */} diff --git a/apps/web/src/features/metrics/sandbox-runtime.test.ts b/apps/web/src/features/metrics/sandbox-runtime.test.ts index 7f1e68670..876da9605 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.test.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { SandboxAllocation } from "@oac/agents-client"; import { hostedObservation, node, session } from "../overview/test-fixtures"; -import { hostedRuntimeRows, hostedRuntimeUsage, loadHostedRuntimes, matchesRuntime, runtimeSnapshot } from "./sandbox-runtime"; +import { hostedRuntimeRows, hostedRuntimeUsage, loadHostedRuntimes, matchesRuntime, runtimeSampleGap, runtimeSnapshot } from "./sandbox-runtime"; const none = { ...hostedObservation("plain", "p1"), mode: "none", instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", cpu: null, memory: null } as unknown as ReturnType; @@ -69,7 +69,6 @@ describe("hosted Runtime projections", () => { const rows = hostedRuntimeRows(load, "", { nodes: [node("n1", { name: "core-01" })], allocations: [allocation] }); expect(rows.map((row) => [row.observation.session_id, row.node?.name ?? null, row.uptimeSeconds])).toEqual([["a", "core-01", 600], ["b", null, 600], ["c", null, null]]); expect(hostedRuntimeRows(load, "", null)[0]?.node).toBeNull(); - expect(rows.map((row) => row.allocation?.compute_phase ?? null)).toEqual(["restoring", null, null]); expect(rows[0]?.observation.lifecycle_state).toBe("active"); expect(rows.map((row) => row.deploymentGeneration)).toEqual([3, null, null]); expect(hostedRuntimeRows(load, "", null)[0]?.deploymentGeneration).toBeNull(); @@ -77,4 +76,11 @@ describe("hosted Runtime projections", () => { expect(matchesRuntime(rows[0]!, "core-01")).toBe(true); expect(matchesRuntime(rows[2]!, "nightly")).toBe(false); }); + + it("names a missing sample only for a sandbox Core records as active", () => { + const missing = { status: "unavailable", observed_at: null, started_at: null, cpu: null, memory: null } as const; + expect(runtimeSampleGap(hostedObservation("a", "p", { ...missing, reason: "sample_timeout" }))).toBe("sample_timeout"); + expect(runtimeSampleGap(hostedObservation("b", "p", { ...missing, lifecycle_state: "sleeping", reason: "runtime_not_running" }))).toBeNull(); + expect(runtimeSampleGap(hostedObservation("c", "p"))).toBeNull(); + }); }); diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index 025bd32ea..5fff4518e 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentSession, SandboxAllocation, SandboxNode } from "@oac/agents-client"; +import type { AgentSession, RuntimeObservation, RuntimeUnavailableReason, SandboxAllocation, SandboxNode } from "@oac/agents-client"; import type { ProjectClient } from "../../lib/projects"; import { sessionTitle } from "../../lib/session-title"; @@ -122,13 +122,19 @@ export function hostedRuntimeUsage(observations: readonly OwnedRuntimeObservatio }; } +/** + * Why an active sandbox has no sample. The lifecycle state is Core's record of the sandbox's compute; + * a pending, suspended, changing or stopped sandbox already explains its missing figures. + */ +export function runtimeSampleGap(observation: RuntimeObservation): RuntimeUnavailableReason | null { + return observation.status === "unavailable" && observation.lifecycle_state === "active" ? observation.reason : null; +} + export interface HostedRuntimeRow { observation: OwnedRuntimeObservation; session: AgentSession | null; /** The host of the Runtime's allocation, when the fleet lists it. */ node: SandboxNode | null; - /** Core's allocation record, joined only by the observation's allocation ID. */ - allocation: SandboxAllocation | null; /** Ownership generation from a matching allocation, never the deployment target or node pin. */ deploymentGeneration: number | null; /** Seconds from the Runtime's start to its last sample. */ @@ -148,7 +154,6 @@ export function hostedRuntimeRows( const allocation = observation.instance.allocation_id ? allocations.get(observation.instance.allocation_id) ?? null : null; return { observation, - allocation, session: load.sessions.get(observation.session_id) ?? null, node: allocation ? nodes.get(allocation.node_id) ?? null : null, deploymentGeneration: allocation?.deployment_generation ?? null, diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 5048e1881..323dbf84b 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -7,7 +7,7 @@ import type { TFunction } from "i18next"; import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; import { ReadFailure } from "../../components/ReadFailure"; -import { readError } from "../../lib/read-error"; +import { coreError } from "../../lib/core-error"; import { failedLast, useFailureToast } from "../../components/Toast"; import { LiveNumber } from "../../components/live-number"; import { TimeSeriesChart } from "../../components/charts/TimeSeriesChart"; @@ -169,7 +169,7 @@ export function OverviewPage() { // Each failed read is its own toast, shown once while it lasts. useFailureToast(failure !== null, tCommon("readFailure.unknown"), "overview-load"); - useFailureToast(summaryError !== null, t("errors.summary", { reason: summaryError === null ? "" : readError(summaryError, tCommon) }), "overview-summary"); + useFailureToast(summaryError !== null, t("errors.summary", { reason: summaryError === null ? "" : coreError(summaryError, tCommon) }), "overview-summary"); useFailureToast(readFailures.length > 0, tCommon("project.partial", { names: readFailures.map((entry) => entry.project.name).join(", ") }), "overview-partial"); useFailureToast(projectsFailed, t("errors.projects", { reason: projectsState.state.status === "failed" ? projectsState.state.error : projectsState.refreshError ?? "" }), "overview-projects"); @@ -488,8 +488,8 @@ function AttentionTable({ sessions, older, unreadNames, truncatedNames, now, onO
{t("attention.session")}{tCommon("project.column")} {t("attention.status")}{tCommon("project.column")} {t("attention.lastActive")}
onOpen(entry)} /> event.stopPropagation()}> -
{failed ? : waiting}
+
{failed ? : waiting}
{formatRelative(session.last_active_at, now, locale)}
@@ -143,7 +146,7 @@ function SkillsList() { help={t("help")} actions={} /> - {body} + {toolbar}{body} failure.project.name).join(", "); useFailureToast(collection.items.length > 0 && collection.failures.length > 0, tCommon("project.partial", { names: failedNames }), "vaults-partial"); + // A selected project keeps its filter in view, even when it holds nothing. + const toolbar = collection.items.length || filter ? ( + + + + + ) : null; let body; if (collection.status === "loading" && !collection.items.length) { body = ; @@ -70,10 +77,6 @@ function VaultsList() { } else { body = ( <> - - - - {rows.length ? (
@@ -126,7 +129,7 @@ function VaultsList() { help={<>{tPages("vaults.subtitle")} {t("securityNote")}} actions={} /> - {body} + {toolbar}{body} { expect(coreFieldError(new Error("invalid_name name"), "name", en)).toBeNull(); }); - it("retains unknown-code messages without parsing them", () => { + it("keeps Core's message for an uncatalogued code in English and names the code in Chinese", () => { const error = failure("future_error", undefined, { max_length: 999 }); - expect(coreError(error, zh)).toBe(error.message); - expect(readError(error, zh)).toBe(error.message); - expect(coreError(new Error("private transport data"), zh)).not.toContain("private"); + expect(coreError(error, en)).toBe(error.message); + expect(coreError(error, zh)).toBe("Core 请求失败(future_error)。"); + expect(coreFieldError(failure("future_error", "name"), "name", zh)).toBe("Core 请求失败(future_error)。"); + }); + + it("names the HTTP status of a failure without a code and never shows other errors' text", () => { + const error = new AgentCoreError("An intermediary's arbitrary text", 502); + expect(coreError(error, en)).toBe("Core request failed (502)."); + expect(coreError(error, zh)).toBe("Core 请求失败(502)。"); + expect(coreError(new TypeError("private transport data"), zh)).toBe("无法从 Core 读取数据,请重试更新。"); }); }); diff --git a/apps/web/src/lib/core-error.ts b/apps/web/src/lib/core-error.ts index 3f7f303cf..d8608e2b2 100644 --- a/apps/web/src/lib/core-error.ts +++ b/apps/web/src/lib/core-error.ts @@ -31,12 +31,19 @@ export function knownCoreError(error: unknown, t: TFunction<"common">, nameUnit: return t(`coreErrors.${error.code as keyof typeof coreErrors}`); } +/** + * Catalogued codes read in the console's language. Core's message for any + * other code is English, so only English shows it; other languages name the + * code. A failure without a code names its HTTP status. + */ export function coreError(error: unknown, t: TFunction<"common">): string { - return knownCoreError(error, t) ?? (error instanceof AgentCoreError && error.message ? error.message : t("readFailure.unknown")); + if (!(error instanceof AgentCoreError)) return t("readFailure.unknown"); + if (!error.code) return t("readFailure.request", { status: error.status }); + return knownCoreError(error, t) ?? t("readFailure.code", { code: error.code, message: error.message || error.code }); } /** Exact field paths only; timeouts and unknown write outcomes are form-level errors. */ export function coreFieldError(error: unknown, param: string, t: TFunction<"common">, nameUnit: "characters" | "bytes" = "characters"): string | null { if (!(error instanceof AgentCoreError) || error.status < 400 || error.status >= 500 || error.status === 408 || error.param !== param) return null; - return knownCoreError(error, t, nameUnit) ?? error.message; + return knownCoreError(error, t, nameUnit) ?? coreError(error, t); } diff --git a/apps/web/src/lib/diagnostic-failure.ts b/apps/web/src/lib/diagnostic-failure.ts index ab13edbf6..14336b107 100644 --- a/apps/web/src/lib/diagnostic-failure.ts +++ b/apps/web/src/lib/diagnostic-failure.ts @@ -16,9 +16,14 @@ export function diagnosticReason(failure: DiagnosticFailure, t: TFunction<"diagn return message; } +/** The catalog's next step for the reason; empty when it has none. */ +export function diagnosticNext(failure: DiagnosticFailure, t: TFunction<"diagnostics">): string { + return t(`next.${failure.code}`, { defaultValue: "" }); +} + /** The classified reason, then its next step when the catalog has one. */ export function diagnosticFailure(failure: DiagnosticFailure, t: TFunction<"diagnostics">): string { const reason = diagnosticReason(failure, t); - const next: string = t(`next.${failure.code}`, { defaultValue: "" }); + const next = diagnosticNext(failure, t); return next ? reason + t("sentenceGap") + next : reason; } diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index a744d53ba..82cd8d8cb 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -8,7 +8,7 @@ import { QueryClientProvider, useQueries, useQuery, useQueryClient } from "@tans import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { readError } from "./read-error"; +import { coreError } from "./core-error"; import { ConsoleSelect } from "../components/console-select"; import { HelpTip } from "../components/console-ui"; import { admin, listCreators } from "./admin-view"; @@ -55,12 +55,12 @@ function ProjectsState({ children }: { children: ReactNode }) { const state: ProjectsState = query.isPending ? { status: "loading", projects, error: null } : query.isError && !query.data - ? { status: "failed", projects, error: readError(query.error, t) } + ? { status: "failed", projects, error: coreError(query.error, t) } : { status: query.isFetching ? "loading" : "ready", projects, error: null }; const { refetch } = query; const refresh = useCallback(() => { void refetch(); }, [refetch]); const byId = useMemo(() => new Map(projects.map((project) => [project.id, project])), [projects]); - const refreshError = query.isError && query.data ? (readError(query.error, t)) : null; + const refreshError = query.isError && query.data ? (coreError(query.error, t)) : null; const failure = query.isError ? query.error : null; const value = useMemo(() => ({ state, refresh, byId, refreshError, failure }), [state.status, state.error, projects, refresh, byId, refreshError, failure]); // eslint-disable-line react-hooks/exhaustive-deps return {children}; @@ -211,6 +211,7 @@ export function projectClient(projectId: string): ProjectClient { * name; the others still show. */ export function useProjectCollection(spec: CollectionSpec, filter: ProjectFilterValue): ProjectCollection { + const { t } = useTranslation("common"); const { state } = useProjects(); const queryClient = useQueryClient(); const targets = useMemo(() => state.projects.filter((project) => !filter || project.id === filter), [filter, state.projects]); @@ -234,7 +235,7 @@ export function useProjectCollection(spec: CollectionSpec, filter: Project let pending = !projectsKnown; results.forEach((result, index) => { const project = targets[index]!; - if (result.isError) failures.push({ project, message: result.error instanceof Error ? result.error.message : String(result.error) }); + if (result.isError) failures.push({ project, message: coreError(result.error, t) }); if (result.isFetching || result.isPending) pending = true; }); const specKey = JSON.stringify(spec.key); diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index a63039f47..d0efe10fc 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -1,10 +1,16 @@ import { QueryClient, queryOptions } from "@tanstack/react-query"; -import type { AgentSession, EnvironmentTemplateResource, SavedAgent, Skill, SourceFileListEntry, Vault } from "@oac/agents-client"; +import { AgentCoreError, type AgentSession, type EnvironmentTemplateResource, type SavedAgent, type Skill, type SourceFileListEntry, type Vault } from "@oac/agents-client"; import { readSessionLog } from "../features/sessions/session-log"; import { listAllProjects } from "./admin-view"; import { projectClient, readAllPages, type ProjectClient } from "./projects"; +/** Only a transient failure, a 5xx answer or a failed fetch, is read again, once; a 4xx answer would not change. */ +export function retryTransient(failureCount: number, error: unknown): boolean { + const transient = error instanceof AgentCoreError ? error.status >= 500 : error instanceof TypeError; + return transient && failureCount < 1; +} + /** * One cache for every Web API read. A page the administrator has seen opens * from the cache at once and refreshes behind it; a refresh keeps the rows on @@ -15,7 +21,7 @@ export const queryClient = new QueryClient({ queries: { staleTime: 30_000, gcTime: 15 * 60_000, - retry: 1, + retry: retryTransient, refetchOnWindowFocus: false, }, }, diff --git a/apps/web/src/lib/read-error.test.ts b/apps/web/src/lib/read-error.test.ts deleted file mode 100644 index 618cc8bc8..000000000 --- a/apps/web/src/lib/read-error.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AgentCoreError } from "@oac/agents-client"; -import { describe, expect, it } from "vitest"; -import i18n from "../i18n"; -import { readError } from "./read-error"; - -describe("read failure localization", () => { - it("uses typed HTTP status in each language without parsing backend English", () => { - const error = new AgentCoreError("An intermediary's arbitrary text", 502); - expect(readError(error, i18n.getFixedT("en", "common"))).toBe("Core request failed (502)."); - expect(readError(error, i18n.getFixedT("zh-CN", "common"))).toBe("Core 请求失败(502)。"); - expect(readError(new TypeError("Failed to fetch"), i18n.getFixedT("zh-CN", "common"))).toBe("无法从 Core 读取数据,请重试更新。"); - }); -}); diff --git a/apps/web/src/lib/read-error.ts b/apps/web/src/lib/read-error.ts deleted file mode 100644 index bf2cf4cbd..000000000 --- a/apps/web/src/lib/read-error.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AgentCoreError } from "@oac/agents-client"; -import type { TFunction } from "i18next"; -import { coreError } from "./core-error"; - -export function readError(error: unknown, t: TFunction<"common">): string { - if (error instanceof AgentCoreError) return error.code ? coreError(error, t) : t("readFailure.request", { status: error.status }); - return t("readFailure.unknown"); -} diff --git a/docs/web/index.md b/docs/web/index.md index c87352c39..17fe9daca 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -19,7 +19,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Monitor | Overview | Service status, running Sessions, sandbox slots and work needing attention; 24-hour Session activity; Core and its nodes as a topology, each opening its page; Needs attention: failed Sessions and Sessions waiting for their caller, active in the last 24 hours, each with its one-line reason; usage by Project | | Monitor | Core metrics | Whether Core can run Turns: execution slots, the Turn queue and its p95 wait, interrupted Turns, connected daemons, database latency, CPU and resident memory against their limits, background jobs; Go heap, goroutines and the database pool under **Diagnostics** | | Monitor | Agent metrics | Requests, errors, duration, tokens, models, tools, Agents and API keys over 1 h, 6 h, 24 h or 7 d | -| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, active against total, with their CPU and memory observations and, when supported, **Recorded compute** lifecycle state shown separately; an E2B deployment adds the sandboxes Core holds in E2B's cloud | +| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, active against total, each with its status (active, suspended, changing state, waiting for allocation or stopped) and its CPU and memory observations; an E2B deployment adds the sandboxes Core holds in E2B's cloud | | Monitor | Request log | Recent HTTP requests to Core, their status, elapsed time and safe error codes; [scope and retention](../../contracts/agents-api/admin-api.md#request-log) | | Monitor | Session log | Every Session, and each Session's read-only conversation, trace and Turns with the classified reason of a failure and its next step; a self-hosted Session's page also manages its executor credentials and gives the command that connects a host | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index f0104efbb..39161bb33 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "OpenAgentCore Web" source: docs/web/index.md -source_hash: 3c4a694989738a6b8aad43b8554423a0a529f404d100740f4619e6a1669393fc +source_hash: 78038da909a1040224a2303365b2c1eb03e5345a58cbeca5ab7fd651b41a0462 --- Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看健康状态、容量、用量和失败情况,检查各项目的资源与执行历史,并管理项目、密钥、节点和部署设置。应用不使用 Web;它们通过自己的项目 API 密钥调用 Core 的 Agents API(`/v1`)。 @@ -21,7 +21,7 @@ Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看 | 监控 | 概览 | 服务状态、运行中的 Session、沙箱槽位和待处理工作;24 小时 Session 活动;以拓扑展示 Core 与节点,各自打开对应页面;需要处理:过去 24 小时内活跃的失败或等待调用方的 Session,每行附一行原因;按项目统计的用量 | | 监控 | Core 监控 | Core 能否运行 Turn:执行槽位、Turn 队列及其 p95 等待时间、被中断的 Turn、已连接守护进程、数据库延迟、CPU 与常驻内存及其限制、后台任务;Go 堆、goroutine 和数据库连接池位于 **诊断** | | 监控 | Agent 监控 | 1 小时、6 小时、24 小时或 7 天的请求、错误、耗时、token、模型、工具、Agent 和 API 密钥 | -| Monitor | Sandbox metrics | 各 Project 托管 Runtime 随时间的变化,显示活跃数量与总数及其 CPU、内存观测;支持时另行显示 **Recorded compute** 生命周期状态;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | +| 监控 | 沙箱监控 | 各项目托管 Runtime 随时间的变化,显示活跃数量与总数,以及各自的状态(活跃、已挂起、切换中、等待分配或已停止)和 CPU、内存观测;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | | 监控 | 请求日志 | 到达 Core 的近期 HTTP 请求、状态、耗时和安全错误码;[范围及保留方式](../../../contracts/agents-api/zh/admin-api.md#request-log) | | 监控 | Session 日志 | 全部 Session;各 Session 的只读对话、跟踪、Turn、失败分类原因及下一步操作;自托管 Session 页面还管理执行器凭据并提供主机连接命令 | | 资源 | Agent、Environment 模板、Skills、文件、Vault | 检查和允许的删除操作,显示各资源所属项目及创建密钥 |