diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 5cec577c6..9f24ec16a 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -489,6 +489,12 @@ Every resource list, the Session log and the project list share one grammar: gaps, two columns below 900px). It starts with the ID (with its copy button) and the Project and includes the Creator. - Sections follow: usage figures in a KPI strip, then tables in cards. +- A Session's **History** header holds an outline "Jump to the failed Turn" (with + the count when several failed) before the view switch while any Turn failed; + it shows the conversation (the Turn table when there are no Items), scrolls the + page body to the next failed Turn and focuses it. +- An active project's page ends its keys with a **How to call** section (see + Dialogs) before its write operations. - A self-hosted Session's **Executor credentials** section ends with a **Connect a host** card when the console serves the self-hosted installer: a 13px/600 title with a help tip (what revoke and rotate do to the host, how to reconnect, @@ -509,7 +515,13 @@ request runs. confirm button (danger), whose label changes while busy. Core's reason for a rejection, or an uncertain-outcome warning, appears in red inside the dialog. The Skill page's delete dialogs follow the same grammar; deleting a whole Skill - also requires typing its name. + also requires typing its name. Archiving a project says in bold that it can't be + undone, then how many active keys it revokes (the project read's count, or more + when its loaded key list shows more) and that assets and accepted work stay; + with active keys it too requires typing the project's name, shown in mono with + its inner spaces kept (surrounding spaces are forgiven, Unicode compared in NFC). + While the project list is read again Archive waits; if that read failed, a red + line says the count may be out of date and Archive stays disabled. - **Key dialogs**: name fields carry their rules in a help tip and their problem in red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. @@ -553,11 +565,17 @@ request runs. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the - installation's API base URL) and `OPENAI_API_KEY` (the new key) together, a curl - request and a Python example with the pinned SDK. A copy the clipboard refuses - selects the sample and says so in red underneath. One Graphite line says running - an Agent needs a model provider: in each request, saved on the Agent, or the - deployment default. When the public address is loopback, a note above the + installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then + curl and Python (with the pinned SDK), each listing the project's Agents and + creating a Session with a first message (`environment`, an inline `agent` with + `model: ""`, and `input`). A copy the clipboard refuses + selects the sample and says so in red underneath. One Graphite line says to put + a model the model provider serves in place of ``, and that running an + Agent needs a model provider: in each request, saved on the Agent, or the + deployment default. An active project's page shows the same samples as a + section without any key: the Shell block exports a quoted placeholder, and a + Graphite line above the samples says to use a key issued for this project, + shown only once at issuance. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without a public address only a note to set one shows. Before the installation is read, a skeleton holds the first sample's place. @@ -591,9 +609,11 @@ hidden element for assistive technology. ### Status dot A 7px circle plus a plain label at 12.5px: ok green, warning amber, danger red, pending Series 1 with a soft expanding ring while work is in progress, neutral -Idle Gray. A failed Session's error and a waiting -Session's required actions sit in a help tip beside the label. Never a coloured -pill, never colour alone. +Idle Gray. A waiting Session's required actions sit in a help tip beside the +label. A failed Session's reason, as Core sent it, stays visible under the label +in 12px Graphite: in full on the Session page, its line breaks kept; in the +Session log on one truncated line, with the full text in its tooltip, that never +widens the status column. Never a coloured pill, never colour alone. ### Meter A 6px pill rail in Meter Track with a neutral ink fill. The fill turns amber at 90% @@ -614,7 +634,14 @@ first point; refreshes of the same range redraw in place. ### Tables A card with a sticky 34px Margin Gray header in Graphite 12px/500, 44px rows divided by Faint Rules, hover wash, right-aligned tabular numerics, clickable rows where a -detail page exists, and the list grammar above. +detail page exists, and the list grammar above. Agent metrics' By Agent table +links a saved Agent's name to its page and a nonzero Failed figure (in its red) to +the Session log with its project and Agent filters set to that Agent, every +status; both turn indigo on hover. The figure counts failed Turns in the range, as +the column's help tip says, so the link's name and tooltip give that count and say +it opens the Agent's Sessions. A +key count in a section heading reads "3 active · 1 revoked" (revoked left out +at zero). ### Notices Errors are popups, never lines inserted into a page. A failed action whose outcome @@ -660,8 +687,12 @@ numbered ring (a check on the tile wash when done), a 13px/600 title over one pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open System; Create project (which continues to the new project's first key) or Issue key; -Projects and keys. Add node, Create project and Issue key open their page with the -dialog already open; Open System focuses the default harness's Set or Replace. Every step done turns it into one line, "You're set", with Take the tour and +See how to call (the newest active project, preferring one with an active key), or +Projects and keys without an active project. Add node, Create project and Issue key +open their page with the dialog already open; Open System brings the Default +model section to the top of the page body and focuses the default harness's Set or +Replace; See how to call opens the project and, once its keys, usage and address +are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index b826dfac6..4111b9d23 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -70,8 +70,11 @@ workbench. duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log - (every Session, read-only, opening one Session's history; a self-hosted - Session's page also has its environment's executor credentials). + (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). Agent + metrics' By Agent table opens an Agent's page and, from its failed Turns, its + 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 @@ -110,7 +113,9 @@ workbench. 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 on the default harness (on any enabled harness when none is default), - a project with an active key, and a first Session. Completion comes from reads the + 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". The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, @@ -125,9 +130,13 @@ workbench. - **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. Key plaintext is shown once, at issuance, and never - stored by the console. Beside it the console tells developers to set - `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the key). + 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. - **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 diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 34759e445..2e713d340 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; -import { expectManagementBoundary, FIXTURE_CORE_KEY, openConsole, resetFixture } from "./console"; +import { expectManagementBoundary, FIXTURE_CORE_KEY, issueKeys, openConsole, resetFixture } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -75,9 +75,24 @@ test("opens a fresh install on the Overview's Getting started: a project and its await expect(issued.getByLabel("New key my-app")).toHaveValue(/fixture-secret/); await issued.getByRole("button", { name: "I've saved this key" }).click(); await expect(page.getByLabel("New key my-app")).toHaveCount(0); + // More keys make the project's key table tall, so its page settles well below where it first draws. + const projectId = new URLSearchParams(new URL(page.url()).hash.split("?")[1]).get("id")!; + await issueKeys(request, projectId, Array.from({ length: 12 }, (_, index) => `app-${index + 1}`)); await page.getByRole("button", { name: "Overview", exact: true }).click(); + // A new page load, so the project's page reads its keys again rather than showing the ones it has. + await page.reload(); await expect(step("Create a project and issue a key")).toContainText("Done"); + // The first Session: the project's call samples, which never hold the key. + await step("Run the first Session").getByRole("button", { name: "See how to call" }).click(); + await expect(page.getByRole("heading", { name: "My app", level: 1 })).toBeVisible(); + const call = page.getByRole("region", { name: "How to call" }); + await expect(call.getByRole("heading", { name: "How to call" })).toBeFocused(); + // Once the keys have drawn, the card's heading is still in view, under the page header. + await expect(page.getByRole("table", { name: "Keys of My app" })).toContainText("app-12"); + await expect(call.getByRole("heading", { name: "How to call" })).toBeInViewport(); + await expect(page.getByRole("heading", { name: "My app", level: 1 })).toBeInViewport(); + await expect(call.getByLabel("Shell", { exact: true })).toHaveText('export OPENAI_BASE_URL=https://core.example.com/v1\nexport OPENAI_API_KEY=""'); const stored = await browserStorage(page); expect(stored).not.toContain("fixture-secret"); expect(stored).not.toContain(FIXTURE_CORE_KEY); @@ -92,6 +107,8 @@ test("leads from Getting started to the default model, and counts it done once t // It arrives on the default harness's action. const set = page.getByRole("button", { name: "Set the default model for Codex" }); await expect(set).toBeFocused(); + // Only the page body scrolls to it: the page header stays in view. + await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeInViewport(); await set.click(); const form = page.getByRole("dialog", { name: "Set default model for Codex" }); await form.getByLabel("Base URL").fill("https://model.example/v1"); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index a43ac4b8c..91e00a8d5 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -47,6 +47,11 @@ export async function archiveProject(request: APIRequestContext, projectId: stri await request.post(`${fixture}/core/v1/projects/${projectId}/archive`, { headers: { cookie: "core_console=fixture-session" } }); } +/** Issues keys in a project behind the console's back, as another administrator would. */ +export async function issueKeys(request: APIRequestContext, projectId: string, names: readonly string[]) { + for (const name of names) await request.post(`${fixture}/core/v1/projects/${projectId}/keys`, { headers: { cookie: "core_console=fixture-session" }, data: { name } }); +} + /** Writes the browser sent through the console, as "METHOD /path". */ export async function writes(request: APIRequestContext): Promise { return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes; diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 2b6d442be..94e94c24a 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -36,6 +36,42 @@ test("opens a Session's conversation from the Session log, read-only", async ({ await expect(page.getByRole("textbox")).toHaveCount(0); }); +test("keeps a failed Session's reason in sight in the Session log and on its page", async ({ page, request }) => { + const reasons = /Sandbox allocation failed: node unavailable\.|Model provider returned 429 Too Many Requests\.|Tool call timed out after 300 s\./; + await page.setViewportSize({ width: 1280, height: 800 }); + await openConsole(page, request, "sessions"); + await page.getByRole("radio", { name: /^Failed/ }).click(); + const row = page.getByRole("row").filter({ hasText: reasons }).first(); + const reason = (await row.getByText(reasons).textContent())!; + await expect(row.getByText(reason)).toHaveAttribute("title", reason); + // 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); + await page.getByRole("button", { name: /^Jump to (the|a) failed Turn/ }).click(); + await expect(page.locator("li.chat-turn:focus")).toContainText("Failed"); +}); + +test("opens an Agent's Sessions from its failed Turns on Agent metrics", async ({ page, request }) => { + await openConsole(page, request, "agent-metrics"); + const link = /^\d+ failed Turns? — open .+'s Sessions$/; + const row = page.getByRole("row").filter({ has: page.getByRole("button", { name: link }) }).first(); + const failed = row.getByRole("button", { name: link }); + await expect(failed).toHaveAttribute("title", link); + const agent = (await row.getByRole("rowheader").innerText()).trim(); + const project = (await row.getByRole("cell").first().innerText()).trim(); + await failed.click(); + await expect(page.getByRole("heading", { name: "Session log", level: 1 })).toBeVisible(); + // The log's own project and Agent filters, with every status. + await expect(page.getByRole("combobox", { name: "Project", exact: true })).toHaveText(project); + await expect(page.getByRole("combobox", { name: "Agent", exact: true })).toHaveText(agent); + await expect(page.getByRole("radio", { name: /^All/ })).toBeChecked(); + await expect(page.getByRole("searchbox")).toHaveValue(""); + const sessions = page.getByRole("table", { name: "Session log" }).getByRole("row"); + await expect(sessions.nth(1)).toContainText(agent); + await expect(sessions.filter({ hasNotText: agent })).toHaveCount(1); +}); + test("shows a self-hosted Session's install command, issues its credential once, and revokes and restores it", async ({ page, request }) => { await openConsole(page, request, "sessions"); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index ffd6986da..d5cd9be7f 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -21,6 +21,7 @@ test("creates a project, shows a new key once, revokes it and archives the proje const call = issued.getByRole("region", { name: "How to call" }); await expect(call.getByLabel("Shell", { exact: true })).toHaveText(`export OPENAI_BASE_URL=https://core.example.com/v1\nexport OPENAI_API_KEY=${key}`); await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents"'); + await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents/sessions"'); await expect(call.getByLabel("Python", { exact: true })).toContainText("pip install openai==3.13.0"); await issued.getByRole("button", { name: "I've saved this key" }).click(); await expect(page.getByRole("table", { name: "Keys of Acceptance" })).toContainText("ci"); @@ -36,6 +37,20 @@ test("creates a project, shows a new key once, revokes it and archives the proje await expect(page.getByText("Archived").first()).toBeVisible(); }); +test("archives a project with active keys only once its name is typed", async ({ page, request }) => { + await openConsole(page, request, "projects?id=proj_7f3a91c2"); + await expect(page.getByRole("region", { name: /^Keys/ }).getByRole("heading")).toHaveText("Keys 3 active · 1 revoked"); + await page.getByRole("button", { name: "Archive Production" }).click(); + const dialog = page.getByRole("dialog", { name: "Archive project" }); + await expect(dialog).toContainText("This can't be undone"); + await expect(dialog).toContainText("Its 3 active keys are revoked at once"); + const archive = dialog.getByRole("button", { name: "Archive" }); + await expect(archive).toBeDisabled(); + await dialog.getByLabel("Type Production to confirm").fill("Production"); + await archive.click(); + await expect(page.getByText("Archived").first()).toBeVisible(); +}); + 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/src/components/console-ui.tsx b/apps/web/src/components/console-ui.tsx index 4b4ada663..53c530ac6 100644 --- a/apps/web/src/components/console-ui.tsx +++ b/apps/web/src/components/console-ui.tsx @@ -141,11 +141,25 @@ export function PageBody({ children, className }: { children: ReactNode; classNa return
{children}
; } +/** + * Scrolls the page body so `element` sits at its top, then focuses `focus` + * (the element itself by default). Only the body scrolls: `scrollIntoView` + * would also shift the page, whose overflow is hidden, and push the page + * header out of sight. + */ +export function revealInPageBody(element: HTMLElement | null, focus: HTMLElement | null = element): void { + if (!element) return; + const body = element.closest(".console-page-body"); + if (body) body.scrollTop += element.getBoundingClientRect().top - body.getBoundingClientRect().top - 12; + focus?.focus({ preventScroll: true }); +} + export function Section({ title, help, actions, headingId, + headingFocusable = false, children, className, }: { @@ -153,6 +167,8 @@ export function Section({ help?: ReactNode; actions?: ReactNode; headingId: string; + /** The heading can take focus, for a link that opens the page on this section. */ + headingFocusable?: boolean; children: ReactNode; className?: string; }) { @@ -160,7 +176,7 @@ export function Section({
-

{title}

+

{title}

{help ? {help} : null}
{actions ?
{actions}
: null} diff --git a/apps/web/src/features/api-keys/HowToCall.test.ts b/apps/web/src/features/api-keys/HowToCall.test.ts index cde476131..637d1da1c 100644 --- a/apps/web/src/features/api-keys/HowToCall.test.ts +++ b/apps/web/src/features/api-keys/HowToCall.test.ts @@ -7,4 +7,12 @@ describe("how-to-call samples", () => { it("install the SDK release Core's contract is pinned to", () => { expect(callSamples("https://core.example/v1", "key").python).toContain(`pip install openai==${upstream.sdk_version}\n`); }); + + it("create a Session in the official shape, and without a key export a quoted placeholder", () => { + const samples = callSamples("https://core.example/v1", null, ""); + expect(samples.shell).toBe('export OPENAI_BASE_URL=https://core.example/v1\nexport OPENAI_API_KEY=""'); + const body = samples.curl.match(/-d '([^']*)'$/)?.[1] ?? ""; + expect(JSON.parse(body)).toEqual({ environment: { type: "openai_hosted" }, agent: { model: "" }, input: "Say hello." }); + expect(samples.python).toContain("client.beta.agents.sessions.create("); + }); }); diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx index 0b6f128d4..a7c14739e 100644 --- a/apps/web/src/features/api-keys/HowToCall.tsx +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -3,6 +3,7 @@ import { Check, Copy } from "lucide-react"; import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { Section } from "../../components/console-ui"; import { installationQuery } from "../../lib/installation"; import { useCopy } from "./IssuedKey"; import "./how-to-call.css"; @@ -10,19 +11,38 @@ import "./how-to-call.css"; /** The official SDK release Core is pinned to (contracts/agents-api/upstream.json). */ const SDK_PIN = "openai==3.13.0"; +/** Stands for the model name in the Session example; the note under the samples says what to put there. */ +export const MODEL_PLACEHOLDER = ""; + /** * What an application needs to call Core with a Project API key: the official - * SDK's environment variables, a raw request and the SDK itself. Both - * variables are exported together, so a shell that already exports one of - * them never sends the new key to another base URL. + * SDK's environment variables, then raw requests and the SDK itself, each + * listing the project's Agents and creating a Session with a first message + * (`POST /agents/sessions` in its official shape: an environment, an inline + * Agent with its model, and the input). Both variables are exported together, + * so a shell that already exports one of them never sends the new key to + * another base URL. Without a key at hand (`apiKey` null) the export holds + * `keyPlaceholder`, quoted so a pasted line stays valid shell. */ -export function callSamples(apiBaseUrl: string, apiKey: string) { +export function callSamples(apiBaseUrl: string, apiKey: string | null, keyPlaceholder = "") { return { - shell: `export OPENAI_BASE_URL=${apiBaseUrl}\nexport OPENAI_API_KEY=${apiKey}`, + shell: `export OPENAI_BASE_URL=${apiBaseUrl}\nexport OPENAI_API_KEY=${apiKey ?? `"${keyPlaceholder}"`}`, curl: [ + "# List the project's Agents", 'curl "$OPENAI_BASE_URL/agents" \\', ' -H "Authorization: Bearer $OPENAI_API_KEY" \\', ' -H "OpenAI-Beta: agents=v1"', + "", + "# Create a Session with a first message", + 'curl "$OPENAI_BASE_URL/agents/sessions" \\', + ' -H "Authorization: Bearer $OPENAI_API_KEY" \\', + ' -H "OpenAI-Beta: agents=v1" \\', + ' -H "Content-Type: application/json" \\', + " -d '{", + ' "environment": {"type": "openai_hosted"},', + ` "agent": {"model": "${MODEL_PLACEHOLDER}"},`, + ' "input": "Say hello."', + " }'", ].join("\n"), python: [ `# pip install ${SDK_PIN}`, @@ -30,49 +50,80 @@ export function callSamples(apiBaseUrl: string, apiKey: string) { "", "client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY", "print(client.beta.agents.list().data)", + "", + "# Create a Session with a first message", + "session = client.beta.agents.sessions.create(", + ' environment={"type": "openai_hosted"},', + ` agent={"model": "${MODEL_PLACEHOLDER}"},`, + ' input="Say hello.",', + ")", + "print(session.id, session.status)", ].join("\n"), }; } /** - * How to call Core with a key that was just issued. The key comes from the - * issuing flow's memory and leaves with it; the console never sends these - * requests. When Core is reachable only on its own machine it says so, and - * without a public address it says to set one instead of guessing. + * The samples, or why there are none. The console never sends these requests. + * When Core is reachable only on its own machine it says so, and without a + * public address it says to set one instead of guessing. */ -export function HowToCall({ apiKey }: { apiKey: string }) { +function HowToCallBody({ apiKey }: { apiKey: string | null }) { const { t } = useTranslation("keys"); const { t: tCommon } = useTranslation("common"); - const headingId = useId(); const installation = useQuery(installationQuery); const base = installation.data?.api_base_url ?? null; - let body; if (installation.data === undefined) { - body = installation.isError && !installation.isFetching + return installation.isError && !installation.isFetching ?

{t("howToCall.failed")}

// The first sample's place, as the console's other first reads hold theirs. :
; - } else if (base === null) { - body =

{t("howToCall.noAddress")}

; - } else { - const samples = callSamples(base, apiKey); - body = <> + } + if (base === null) return

{t("howToCall.noAddress")}

; + const samples = callSamples(base, apiKey, t("howToCall.keyPlaceholder")); + return ( + <> {installation.data.local_only ?

{t("howToCall.localOnly")}

: null} + {apiKey === null ?

{t("howToCall.projectKey")}

: null} -

{t("howToCall.model")}

- ; - } +

{t("howToCall.model", { model: MODEL_PLACEHOLDER })}

+ + ); +} + +/** How to call Core with a key that was just issued. The key comes from the issuing flow's memory and leaves with it. */ +export function HowToCall({ apiKey }: { apiKey: string }) { + const { t } = useTranslation("keys"); + const headingId = useId(); return (

{t("howToCall.title")}

- {body} +
); } +/** The heading Getting started focuses when it opens a project's samples. */ +export const PROJECT_CALL_HEADING_ID = "project-call-heading"; + +/** + * How to call Core with a key of this project, always on its page. It never + * holds a key: the console shows a key only once, when it is issued, so the + * samples carry a placeholder and say to use a key issued for this project. + */ +export function ProjectHowToCall() { + const { t } = useTranslation("keys"); + return ( +
+
+ +
+
+ ); +} + /** A labelled sample with a copy button; when the clipboard refuses, the sample is selected to copy by hand. */ function CodeSample({ label, value }: { label: string; value: string }) { const { t } = useTranslation("keys"); diff --git a/apps/web/src/features/api-keys/ProjectDetail.tsx b/apps/web/src/features/api-keys/ProjectDetail.tsx index c24a67546..435c0d9b2 100644 --- a/apps/web/src/features/api-keys/ProjectDetail.tsx +++ b/apps/web/src/features/api-keys/ProjectDetail.tsx @@ -9,6 +9,7 @@ import { formatCompact, formatDateTime, formatInteger, formatRelative } from ".. import { useConsoleNavigation } from "../../lib/console-navigation"; import type { ConsoleView } from "../../lib/console-routes"; import { admin } from "../../lib/projects"; +import { ProjectHowToCall } from "./HowToCall"; import { prefixLabel } from "./key-flows"; import { loadedFrom, projectKeysQuery, projectSummaryQuery, type Loaded } from "./project-queries"; import { ProjectStatus } from "./ProjectStatus"; @@ -53,6 +54,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: { const pending = summaries.status === "loading" && !summaries.value; const figure = (value: number | null | undefined, compact = false) => (pending ? "—" : compact ? formatCompact(value, locale) : formatInteger(value, locale)); const activeCount = (keys.value ?? []).filter((key) => key.revoked_at === null).length; + const revokedCount = (keys.value?.length ?? 0) - activeCount; const byKey = summaries.value?.byKey ?? null; const unknownUsage = byKey?.get(null) ?? null; const usage = summary?.usage ?? null; @@ -122,7 +124,13 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
{t("detail.keys")} {keys.value ? {formatInteger(keys.value.length, locale)} : null}} + title={<>{t("detail.keys")} {keys.value ? ( + + {revokedCount + ? t("detail.keyCounts", { active: formatInteger(activeCount, locale), revoked: formatInteger(revokedCount, locale) }) + : t("detail.keyCountsActive", { active: formatInteger(activeCount, locale) })} + + ) : null}} help={t("detail.keysHelp")} actions={project.status === "active" ? (
+ {project.status === "active" ? : null} + ); diff --git a/apps/web/src/features/api-keys/ProjectsPage.tsx b/apps/web/src/features/api-keys/ProjectsPage.tsx index 251306806..c855e992b 100644 --- a/apps/web/src/features/api-keys/ProjectsPage.tsx +++ b/apps/web/src/features/api-keys/ProjectsPage.tsx @@ -1,10 +1,10 @@ import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, FolderKanban, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; -import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton } from "../../components/console-ui"; +import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, revealInPageBody } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { Modal } from "../../components/Modal"; @@ -12,10 +12,12 @@ import { useFailureToast } from "../../components/Toast"; import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { formatDateTime, formatInteger, formatRelative } from "../../lib/format"; import { admin, useProjects } from "../../lib/projects"; -import { activeKeyNames, flowError, isAbort, isUsableName, matchesProject, normalizeName, prefixLabel, projectNameProblem, type FlowError } from "./key-flows"; +import { activeKeyNames, archiveKeyCount, flowError, isAbort, isArchiveConfirmed, isUsableName, matchesProject, normalizeName, prefixLabel, projectNameProblem, type FlowError } from "./key-flows"; import { FlowErrorMessage, KeyFlowDialogs, NameField, PendingKeyNotice } from "./KeyFlowDialogs"; +import { PROJECT_CALL_HEADING_ID } from "./HowToCall"; import { ProjectDetail, useProjectKeys } from "./ProjectDetail"; -import { invalidateProjects, projectActivityQuery, projectScope } from "./project-queries"; +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"; @@ -27,7 +29,8 @@ type Dialog = /** `thenIssue`: Getting started continues from the new project to its first key. */ | { kind: "create"; name: string; thenIssue?: boolean } | { kind: "rename"; project: Project; name: string } - | { kind: "archive"; project: Project } + /** `typed`: the project name, required while it has active keys. */ + | { kind: "archive"; project: Project; typed: string } | { kind: "revoke"; project: Project; key: AdminKey; activeCount: number }; const manageable = (project: Project) => project.status === "active"; @@ -92,6 +95,30 @@ export function ProjectsPage() { useConsoleIntent("issue-key", selected ? (manageable(selected) && flow.step === "idle" ? "ready" : "unavailable") : state.status === "loading" ? "wait" : "unavailable", () => { if (selected) dispatch({ type: "openIssue", project: selected }); }); + // Getting started's last step opens a project on its call samples. It waits + // until the reads that size the page above them (keys, usage) and the + // samples themselves (the installation) settle, so the heading stays in view. + // The same reads as the project's page, which shows its call samples only while it is active. + const summary = useQuery({ ...projectSummaryQuery(selected?.id ?? ""), enabled: selected !== null }); + const installation = useQuery({ ...installationQuery, enabled: selected !== null && manageable(selected) }); + const settled = (query: { isFetching: boolean; isError: boolean; data: unknown }) => !query.isFetching && (query.data !== undefined || query.isError); + const callReadiness = !selected + ? (state.status === "loading" ? "wait" : "unavailable") + : !manageable(selected) ? "unavailable" + : keys.status !== "loading" && settled(summary) && settled(installation) ? "ready" : "wait"; + useConsoleIntent("how-to-call", callReadiness, () => { + revealInPageBody(document.getElementById(PROJECT_CALL_HEADING_ID)); + }); + + // The archive dialog counts the keys it revokes from the latest project read, + // or from the project's key list when that shows more. While the project list + // is read again, or after that read failed, the count may be out of date, so + // Archive waits. + const archiving = dialog?.kind === "archive" ? byId.get(dialog.project.id) ?? dialog.project : null; + const archiveKeys = useQuery({ ...projectKeysQuery(archiving?.id ?? ""), enabled: archiving !== null }); + const archiveActive = archiving ? archiveKeyCount(archiving, archiveKeys.data) : 0; + const projectsStale = state.status === "failed" || refreshError !== null; + const projectsSettled = state.status === "ready" && !projectsStale; const dialogNameProblem = dialog?.kind === "create" ? projectNameProblem(dialog.name, names) @@ -100,7 +127,9 @@ export function ProjectsPage() { : null; const dialogReady = !dialogBusy && (dialog?.kind === "create" || dialog?.kind === "rename" ? isUsableName(dialog.name, dialogNameProblem) && !(dialog.kind === "rename" && normalizeName(dialog.name) === dialog.project.name) - : Boolean(dialog)); + : dialog?.kind === "archive" && archiving + ? projectsSettled && isArchiveConfirmed(archiving.name, archiveActive, dialog.typed) + : Boolean(dialog)); const runDialog = async () => { if (!dialog || !dialogReady) return; @@ -165,7 +194,7 @@ export function ProjectsPage() { {selected && manageable(selected) ? ( <> - + ) : null} @@ -237,7 +266,7 @@ export function ProjectsPage() { {manageable(project) ? ( - + ) : null} @@ -306,14 +335,26 @@ export function ProjectsPage() { placeholder={t("createDialog.placeholder")} disabled={dialogBusy} /> - ) : dialog.kind === "archive" ? ( -

{t("archiveDialog.prompt", { name: dialog.project.name })}

- ) : ( + ) : dialog.kind === "archive" && archiving ? ( + <> +

{t("archiveDialog.prompt", { name: archiving.name })}

+

{archiveActive ? t("archiveDialog.keys", { count: archiveActive }) : t("archiveDialog.noKeys")}

+

{t("archiveDialog.kept")}

+ {archiveActive ? ( + + ) : null} + {projectsStale ?

{t("archiveDialog.stale")}

: null} + + ) : dialog.kind === "revoke" ? ( <>

{t("revokeDialog.prompt", { name: dialog.key.name, prefix: prefixLabel(dialog.key.prefix) })}

{dialog.activeCount <= 1 ?

{t("revokeDialog.lastKey", { project: dialog.project.name })}

: null} - )} + ) : null} ) : null} diff --git a/apps/web/src/features/api-keys/api-keys.css b/apps/web/src/features/api-keys/api-keys.css index 3d439b34d..b27b929c1 100644 --- a/apps/web/src/features/api-keys/api-keys.css +++ b/apps/web/src/features/api-keys/api-keys.css @@ -205,3 +205,15 @@ .modal-card:has(.plaintext-key) { width: min(560px, 100%); } + +/* The name to type before archiving: exact, inner spaces kept visible. */ +.confirm-name { + padding: 0 4px; + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--surface-subtle); + border-radius: 4px; +} diff --git a/apps/web/src/features/api-keys/how-to-call.css b/apps/web/src/features/api-keys/how-to-call.css index 95c780f23..4eb0de35c 100644 --- a/apps/web/src/features/api-keys/how-to-call.css +++ b/apps/web/src/features/api-keys/how-to-call.css @@ -77,3 +77,8 @@ .how-to-call-skeleton .skeleton-bar:last-child { width: 48%; } + +/* Getting started moves the focus to the project page's heading, which is not a control. */ +.project-call h2:focus { + outline: none; +} diff --git a/apps/web/src/features/api-keys/key-flows.test.ts b/apps/web/src/features/api-keys/key-flows.test.ts index 4e5d2c55c..e07f449db 100644 --- a/apps/web/src/features/api-keys/key-flows.test.ts +++ b/apps/web/src/features/api-keys/key-flows.test.ts @@ -6,6 +6,8 @@ import { activeKeyNames, flowError, idleFlow, + archiveKeyCount, + isArchiveConfirmed, isUsableName, keyFlowReducer, keyNameProblem, @@ -116,4 +118,20 @@ describe("helpers", () => { expect(matchesProject(project, "7F3A")).toBe(true); expect(matchesProject(project, "data")).toBe(false); }); + + it("counts the keys archiving revokes from the project read, or from its key list when that shows more", () => { + const key = (id: string, projectId = project.id, revoked: number | null = null): AdminKey => ({ id, project_id: projectId, name: id, prefix: `pc_${id}`, created_at: 1, revoked_at: revoked }); + const stale = { ...project, active_key_count: 0 }; + expect(archiveKeyCount(stale, undefined)).toBe(0); + expect(archiveKeyCount(stale, [key("new"), key("old", project.id, 5), key("other", "proj_other")])).toBe(1); + expect(archiveKeyCount({ ...project, active_key_count: 3 }, [key("new")])).toBe(3); + }); + + it("archives a project with active keys only once its name is typed", () => { + expect([isArchiveConfirmed("Production", 1, ""), isArchiveConfirmed("Production", 1, "production"), isArchiveConfirmed("Production", 1, " Production ")]).toEqual([false, false, true]); + expect(isArchiveConfirmed("Production", 0, "")).toBe(true); + // Inner spaces count; a decomposed é matches the composed one. + expect([isArchiveConfirmed("Data team", 1, "Data team"), isArchiveConfirmed("Data team", 1, "Data team")]).toEqual([false, true]); + expect(isArchiveConfirmed("Caf\u00e9", 1, "Cafe\u0301")).toBe(true); + }); }); diff --git a/apps/web/src/features/api-keys/key-flows.ts b/apps/web/src/features/api-keys/key-flows.ts index 5268a1e0c..029548e56 100644 --- a/apps/web/src/features/api-keys/key-flows.ts +++ b/apps/web/src/features/api-keys/key-flows.ts @@ -41,6 +41,26 @@ export function isUsableName(value: string, problem: NameProblem | null): boolea return normalizeName(value).length > 0 && problem === null; } +/** + * The active keys archiving revokes: the project read's count, or more when the + * project's loaded key list shows more (it may be newer, for example after a + * key was issued and the project list could not be read again). + */ +export function archiveKeyCount(project: Pick, keys: readonly AdminKey[] | null | undefined): number { + const listed = keys ? keys.filter((key) => key.project_id === project.id && key.revoked_at === null).length : 0; + return Math.max(project.active_key_count, listed); +} + +/** + * Archiving can't be undone and revokes every active key at once, so a + * project with active keys is archived only after its name is typed. Only + * surrounding spaces are forgiven; both sides compare in NFC, so a name typed + * with a decomposed character still matches. + */ +export function isArchiveConfirmed(name: string, activeKeys: number, typed: string): boolean { + return activeKeys === 0 || typed.trim().normalize("NFC") === name.normalize("NFC"); +} + /** * A write either failed before Core acted ("rejected": Core's reason, such as * an archived project on a 409; correct and retry) or has an unknown outcome diff --git a/apps/web/src/features/metrics/AgentMetricsPage.tsx b/apps/web/src/features/metrics/AgentMetricsPage.tsx index f7fd54f64..9e31660c1 100644 --- a/apps/web/src/features/metrics/AgentMetricsPage.tsx +++ b/apps/web/src/features/metrics/AgentMetricsPage.tsx @@ -20,11 +20,13 @@ import { } from "../../components/console-ui"; import { DashboardSkeleton, TableSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; +import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatClock, formatCompact, formatDuration, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; import { ProjectFilter, ProjectName, useProjects, type ProjectFilterValue } from "../../lib/projects"; import { agentMetricsOutcome, INLINE_AGENT_ID, + isInlineAgent, OTHER_SERIES_ID, type AgentMetrics, type AgentMetricsRange, @@ -179,6 +181,7 @@ function AgentMetricsContent({ const { t, i18n } = useTranslation("metrics"); const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; + const { navigate } = useConsoleNavigation(); const { window, totals, series } = metrics; const bucket = bucketLabel(window.bucketSeconds, t); const modelLabel = (id: string) => (id === OTHER_SERIES_ID ? t("chart.other") : id === "unknown" ? t("agent.unknownModel") : id); @@ -350,25 +353,40 @@ function AgentMetricsContent({ {showProject ? {tCommon("project.column")} : null} {t("agent.sessions")} {t("agent.requests")} - {t("agent.failed")} + {t("agent.failed")}{t("agent.failedHelp")} {t("agent.errorRate")} {t("agent.latency")} {t("agent.tokens")} - {metrics.byAgent.map((agent) => ( - - {agentLabel(agent.agentId, agent.label)} - {showProject ? : null} - {integer(agent.sessions)} - {integer(agent.requests)} - {integer(agent.failed)} - {formatPercent(agent.finished ? agent.failed / agent.finished : null, locale)} - {formatDuration(agent.averageLatencySeconds)} - {agent.reportedTurns ? compact(agent.tokens) : MISSING} - - ))} + {metrics.byAgent.map((agent) => { + const label = agentLabel(agent.agentId, agent.label); + // A saved Agent opens its page, and its failures its Sessions in the Session log; an inline Agent has neither. + const saved = agent.projectId !== null && !isInlineAgent(agent.agentId) ? { project: agent.projectId, id: agent.agentId } : null; + // The figure counts failed Turns in the range; the link lists all the Agent's Sessions, and its name and tooltip say so. + const openSessions = t("agent.openSessions", { count: agent.failed, agent: label }); + return ( + + + {saved ? ( + + ) : {label}} + + {showProject ? : null} + {integer(agent.sessions)} + {integer(agent.requests)} + + {saved && agent.failed ? ( + + ) : integer(agent.failed)} + + {formatPercent(agent.finished ? agent.failed / agent.finished : null, locale)} + {formatDuration(agent.averageLatencySeconds)} + {agent.reportedTurns ? compact(agent.tokens) : MISSING} + + ); + })} diff --git a/apps/web/src/features/metrics/MetricsView.css b/apps/web/src/features/metrics/MetricsView.css index eca22e84c..77cf52bb4 100644 --- a/apps/web/src/features/metrics/MetricsView.css +++ b/apps/web/src/features/metrics/MetricsView.css @@ -60,3 +60,24 @@ font-size: 12px; font-weight: 400; } + +/* A figure that opens the Session log filtered to it; it keeps its cell's colour. */ +.figure-link { + padding: 0; + color: inherit; + font: inherit; + letter-spacing: inherit; + background: none; + border: 0; + border-radius: 4px; + cursor: pointer; +} + +.figure-link:hover { + color: var(--accent); +} + +.figure-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} diff --git a/apps/web/src/features/metrics/agent-metrics.ts b/apps/web/src/features/metrics/agent-metrics.ts index 83e41e914..17291665c 100644 --- a/apps/web/src/features/metrics/agent-metrics.ts +++ b/apps/web/src/features/metrics/agent-metrics.ts @@ -199,6 +199,11 @@ function modelOf(session: AgentSession): string { export const INLINE_AGENT_ID = "inline"; +/** Inline Agents have no ID of their own: they are keyed by `inline`, or `inline:` when named. */ +export function isInlineAgent(agentId: string): boolean { + return agentId === INLINE_AGENT_ID || agentId.startsWith(`${INLINE_AGENT_ID}:`); +} + function agentOf(session: AgentSession): { id: string; label: string } { const name = typeof session.agent?.name === "string" && session.agent.name.trim() ? session.agent.name : null; if (typeof session.agent?.id === "string" && session.agent.id) return { id: session.agent.id, label: name ?? session.agent.id }; diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx index 6b73a42c6..6ed9baf76 100644 --- a/apps/web/src/features/overview/GettingStarted.tsx +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -39,7 +39,7 @@ export function GettingStarted({ fleet, sessions }: { fleet: FleetState; session projects: projects.data ?? (projects.isError ? "failed" : undefined), harnesses: harnesses.data?.data ?? (harnesses.isError ? "failed" : undefined), }); - const states = [steps.sandboxes.state, steps.model, steps.key.state, steps.session]; + const states = [steps.sandboxes.state, steps.model, steps.key.state, steps.session.state]; const allDone = states.every((state) => state === "done"); // Remembered per installation; a choice made here overrides what was read. @@ -122,6 +122,10 @@ export function GettingStarted({ fleet, sessions }: { fleet: FleetState; session const keyAction = keyProject ? { label: t("gettingStarted.key.issue"), run: () => navigate("projects", { id: keyProject.id }, "issue-key") } : { label: t("gettingStarted.key.create"), run: () => navigate("projects", {}, "create-project") }; + const callProject = steps.session.project; + const sessionAction = callProject + ? { label: t("gettingStarted.session.howToCall"), run: () => navigate("projects", { id: callProject.id }, "how-to-call") } + : { label: t("gettingStarted.session.open"), run: () => navigate("projects") }; return (
@@ -142,7 +146,7 @@ export function GettingStarted({ fleet, sessions }: { fleet: FleetState; session navigate("system", {}, "default-model") }} /> - navigate("projects") }} /> +
); diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index c0d0db4ea..8a8137456 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -38,7 +38,17 @@ describe("Getting started steps", () => { expect(steps([older, project("p4")]).key.state).toBe("done"); expect(steps(undefined).key.state).toBeNull(); expect(steps("failed").key.state).toBe("unknown"); - expect([steps([], 0).session, steps([], 2).session, steps([], null).session, steps([], "failed").session]).toEqual(["todo", "done", null, "unknown"]); + expect([steps([], 0).session.state, steps([], 2).session.state, steps([], null).session.state, steps([], "failed").session.state]).toEqual(["todo", "done", null, "unknown"]); + }); + + it("opens the call samples of the newest active project with a key, else of the newest active project", () => { + const call = (projects: Parameters[0]["projects"]) => gettingStartedSteps({ fleet: { status: "loading" }, projects, sessions: 0, harnesses: undefined }).session.project; + const keyed = project("p1", { created_at: 1 }); + const newer = project("p2", { active_key_count: 0, created_at: 2 }); + const archived = project("p3", { status: "archived", active_key_count: 0, created_at: 3 }); + expect(call([keyed, newer, archived])).toBe(keyed); + expect(call([newer, archived])).toBe(newer); + expect([call([archived]), call(undefined), call("failed")]).toEqual([null, null, null]); }); it("needs a default model on the default harness, or on any enabled harness when none is default", () => { diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index fc9f8fdb3..abaae996d 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -20,7 +20,8 @@ export interface GettingStartedSteps { model: StepState; /** `project` is the active project a key would be issued for; null means create one first. */ key: { state: StepState; project: Project | null }; - session: StepState; + /** `project` is the active project whose call samples the step opens; null leads to the project list. */ + session: { state: StepState; project: Project | null }; } export function gettingStartedSteps(input: { @@ -37,7 +38,10 @@ export function gettingStartedSteps(input: { sandboxes: sandboxStep(input.fleet), model: modelStep(input.harnesses), key: keyStep(input.projects), - session: sessions === null ? null : sessions === "failed" ? "unknown" : sessions > 0 ? "done" : "todo", + session: { + state: sessions === null ? null : sessions === "failed" ? "unknown" : sessions > 0 ? "done" : "todo", + project: callProject(input.projects), + }, }; } @@ -72,13 +76,26 @@ function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): St return set ? "done" : "todo"; } +/** The newest of the projects, most likely the one just created. */ +function newestOf(projects: readonly Project[]): Project | null { + return projects.reduce((best, project) => (!best || project.created_at > best.created_at ? project : best), null); +} + function keyStep(projects: readonly Project[] | "failed" | undefined): GettingStartedSteps["key"] { if (!projects || projects === "failed") return { state: projects ? "unknown" : null, project: null }; const active = projects.filter((project) => project.status === "active"); if (active.some((project) => project.active_key_count > 0)) return { state: "done", project: null }; - // The newest active project, most likely the one just created. - const newest = active.reduce((best, project) => (!best || project.created_at > best.created_at ? project : best), null); - return { state: "todo", project: newest }; + return { state: "todo", project: newestOf(active) }; +} + +/** + * Where the first Session's call samples are: the newest active project with + * an active key, else the newest active project (whose page issues one). + */ +function callProject(projects: readonly Project[] | "failed" | undefined): Project | null { + if (!projects || projects === "failed") return null; + const active = projects.filter((project) => project.status === "active"); + return newestOf(active.filter((project) => project.active_key_count > 0)) ?? newestOf(active); } /** diff --git a/apps/web/src/features/sessions/SessionLogPage.tsx b/apps/web/src/features/sessions/SessionLogPage.tsx index 05a8523bc..8f0149fd1 100644 --- a/apps/web/src/features/sessions/SessionLogPage.tsx +++ b/apps/web/src/features/sessions/SessionLogPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useFailureToast } from "../../components/Toast"; import { 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 { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { formatClock, formatCompact, formatDateTime, formatInteger, formatRelative, MISSING } from "../../lib/format"; import { CreatorCell, CreatorHeading, ProjectFilter, ProjectName, useCreators, useProjectCollection, useProjects, type Creators, type Owned, type ProjectFilterValue } from "../../lib/projects"; import { SessionDeleteDialog, type SessionDeleteTarget } from "./SessionDeleteDialog"; @@ -47,11 +47,18 @@ export function SessionLogPage() { const { t, i18n } = useTranslation("sessions"); const { t: tCommon } = useTranslation(); const locale = i18n.resolvedLanguage; - const { params, navigate } = useConsoleNavigation(); + const { params, navigate, intent } = useConsoleNavigation(); const { state: projects, byId, refresh: refreshProjects } = useProjects(); const [project, setProject] = useState(() => params.project ?? remembered.project); - // A linked ID (a Session or an Agent) prefills the search, like the other lists. - const [filters, setFilters] = useState(() => (params.id ? { ...initialSessionLogFilters, query: params.id } : remembered.filters)); + // A linked ID (a Session or an Agent) prefills the search, like the other lists; + // a link that says it is an Agent's ("agent-sessions") sets the Agent filter instead. + const [filters, setFilters] = useState(() => ( + !params.id ? remembered.filters + : intent === "agent-sessions" ? { ...initialSessionLogFilters, agentId: params.id } + : { ...initialSessionLogFilters, query: params.id } + )); + // The filter above already acted on it. + useConsoleIntent("agent-sessions", "ready", () => undefined); const [limit, setLimit] = useState(PAGE_SIZE); const [deleted, setDeleted] = useState>(() => new Set()); const [deleteTarget, setDeleteTarget] = useState(null); @@ -269,7 +276,7 @@ function SessionLogRow({ {projectCell} - event.stopPropagation()}> + event.stopPropagation()}> {session.agent.model || MISSING} {t(`environment.${environmentKind(session)}`)} diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 330b88414..3b81c5613 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -1,12 +1,12 @@ import type { AgentSession } from "@agents-core-web/agents-client"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFailureToast } from "../../components/Toast"; import { DetailSkeleton } from "../../components/Skeleton"; -import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section, SegmentedControl } from "../../components/console-ui"; +import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, revealInPageBody, Section, SegmentedControl } from "../../components/console-ui"; import { CopyableId } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatClock, formatDateTime, formatInteger, MISSING } from "../../lib/format"; @@ -16,7 +16,7 @@ import { forgetDeleted } from "../resources/detail-queries"; import { ExecutorCredentialsSection } from "./ExecutorCredentialsSection"; import { SessionDeleteDialog, type SessionDeleteTarget } from "./SessionDeleteDialog"; import { sessionKey } from "./session-queries"; -import { isSessionActive } from "./session-history"; +import { isSessionActive, turnAnchorId } from "./session-history"; import { environmentKind, isDeletable } from "./session-log"; import { SessionStatus, useWaitingFor } from "./SessionStatus"; import { hasObservableRuntime } from "./session-runtime"; @@ -54,6 +54,16 @@ export function SessionPage() { const queryClient = useQueryClient(); const history = useSessionHistory(projectId, sessionId); const [view, setView] = useState("conversation"); + // A jump to a failed Turn: the Turn to show once its view is on screen. Repeated jumps go through every failed Turn. + const [jump, setJump] = useState(null); + const jumps = useRef(0); + useEffect(() => { + if (!jump) return; + const target = document.getElementById(turnAnchorId(jump)); + if (!target) return; + revealInPageBody(target); + setJump(null); + }, [jump, view]); const [deleteTarget, setDeleteTarget] = useState(null); const [refreshToken, setRefreshToken] = useState(0); const waitingFor = useWaitingFor(); @@ -93,6 +103,15 @@ export function SessionPage() { } else { const items = history.history?.items ?? []; const turns = history.history?.turns ?? []; + const failedTurns = turns.filter((turn) => turn.status === "failed"); + const jumpToFailed = () => { + const target = failedTurns[jumps.current % failedTurns.length]; + if (!target) return; + jumps.current += 1; + // The conversation shows the Turn with its error; without Items, the Turn table does. + setView(items.length ? "conversation" : "turns"); + setJump(target.id); + }; const usage = session.usage; const waiting = waitingFor(session); const metadata = Object.entries(session.metadata).filter(([key]) => key !== "title"); @@ -156,16 +175,21 @@ export function SessionPage() { title={t("history.title")} help={t("history.help")} actions={( - + <> + {failedTurns.length ? ( + + ) : null} + + )} > {view === "conversation" ? ( diff --git a/apps/web/src/features/sessions/SessionStatus.tsx b/apps/web/src/features/sessions/SessionStatus.tsx index fc0d5f7ca..909476945 100644 --- a/apps/web/src/features/sessions/SessionStatus.tsx +++ b/apps/web/src/features/sessions/SessionStatus.tsx @@ -14,15 +14,27 @@ export function useWaitingFor() { )); } -export function SessionStatus({ session }: { session: AgentSession }) { +/** + * A Session's status. A failed Session's reason, as Core sent it, stays in + * sight under the status: in full on the Session page, on one truncated line + * with the full text in its tooltip in a list (`truncate`). + */ +export function SessionStatus({ session, truncate = false }: { session: AgentSession; truncate?: boolean }) { const { t } = useTranslation("sessions"); const waitingFor = useWaitingFor()(session); const key = statusKey(session.status); - return ( + const reason = session.status === "failed" && session.error ? session.error : null; + const status = ( - {session.status === "failed" && session.error ? {session.error} : null} {session.status === "requires_action" && waitingFor.length ? {waitingFor.join(" · ")} : null} ); + if (!reason) return status; + return ( + + {status} + {reason} + + ); } diff --git a/apps/web/src/features/sessions/SessionTranscript.tsx b/apps/web/src/features/sessions/SessionTranscript.tsx index 4dc8683ee..739d22c1f 100644 --- a/apps/web/src/features/sessions/SessionTranscript.tsx +++ b/apps/web/src/features/sessions/SessionTranscript.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { StatusDot } from "../../components/console-ui"; import { formatDateTime, formatDuration, formatInteger } from "../../lib/format"; import { ThreadItems } from "./items/ItemRenderers"; -import { transcriptGroups, turnDurationSeconds } from "./session-history"; +import { transcriptGroups, turnAnchorId, turnDurationSeconds } from "./session-history"; import { turnTone } from "./SessionTurnsTable"; /** @@ -26,7 +26,7 @@ export function SessionTranscript({ turns, items, agentName }: { turns: readonly const duration = turn ? turnDurationSeconds(turn, now) : null; const key = turn?.id ?? "unassociated"; return ( -
  • +
  • {turn || turns.length ? (
    {group.number ? t("turnTable.number", { number: group.number }) : t("history.unassociated")} diff --git a/apps/web/src/features/sessions/SessionTurnsTable.tsx b/apps/web/src/features/sessions/SessionTurnsTable.tsx index bbed117d4..8769e3b8b 100644 --- a/apps/web/src/features/sessions/SessionTurnsTable.tsx +++ b/apps/web/src/features/sessions/SessionTurnsTable.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { CopyableId } from "../../components/list-ui"; import { formatDateTime, formatDuration, formatInteger, MISSING } from "../../lib/format"; -import { itemsPerTurn, turnDurationSeconds } from "./session-history"; +import { itemsPerTurn, turnAnchorId, turnDurationSeconds } from "./session-history"; export const turnTone: Record = { queued: "pending", @@ -51,7 +51,7 @@ export function SessionTurnsTable({ turns, items, failure = null }: { turns: rea {turns.map((turn, index) => { const itemCount = counts.get(turn.id) ?? 0; return ( - + {t("turnTable.number", { number: index + 1 })} diff --git a/apps/web/src/features/sessions/session-history.ts b/apps/web/src/features/sessions/session-history.ts index a343f7057..59c0220c2 100644 --- a/apps/web/src/features/sessions/session-history.ts +++ b/apps/web/src/features/sessions/session-history.ts @@ -17,6 +17,11 @@ const SESSION_POLL_MAX_MS = 60_000; export const HISTORY_LIMIT = 10_000; const HISTORY_PAGE_SIZE = 100; +/** The element of one Turn in the conversation or the Turn table, which a jump to it focuses. */ +export function turnAnchorId(turnId: string): string { + return `session-turn-${turnId}`; +} + /** A Session with queued, running or waiting work can still change. */ export function isSessionActive(status: string | undefined): boolean { return status === "in_progress" || status === "requires_action"; diff --git a/apps/web/src/features/sessions/sessions.css b/apps/web/src/features/sessions/sessions.css index ff67fb44d..8249b3375 100644 --- a/apps/web/src/features/sessions/sessions.css +++ b/apps/web/src/features/sessions/sessions.css @@ -556,3 +556,53 @@ .executor-install-separator { 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. */ +.session-status { + display: inline-flex; + max-width: 100%; + flex-direction: column; + gap: 1px; +} + +.session-status-reason { + padding-left: 13px; + color: var(--fg-muted); + font-size: 12px; + line-height: 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.session-status-truncated { + display: flex; +} + +/* The log's reason is clamped to one line rather than kept from wrapping: it + can break anywhere, so it never sets the status column's minimum width and + the table keeps fitting its card, yet it uses the room the table has spare, + up to 220px. */ +.session-status-truncated .session-status-reason { + display: -webkit-box; + max-width: 220px; + overflow: hidden; + overflow-wrap: anywhere; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + line-clamp: 1; +} + +/* A jump to a failed Turn focuses it; only keyboard focus draws a ring. */ +.chat-turn:focus, +.session-turns-table tr:focus { + outline: none; +} + +.chat-turn:focus-visible, +.session-turns-table tr:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} diff --git a/apps/web/src/features/system/DefaultModelsSection.tsx b/apps/web/src/features/system/DefaultModelsSection.tsx index b33168fe2..5cbe11840 100644 --- a/apps/web/src/features/system/DefaultModelsSection.tsx +++ b/apps/web/src/features/system/DefaultModelsSection.tsx @@ -4,7 +4,7 @@ import { useCallback, useId, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "../../components/ConfirmDialog"; -import { EmptyState, HelpTip, Section, StatusDot } from "../../components/console-ui"; +import { EmptyState, HelpTip, revealInPageBody, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { Modal } from "../../components/Modal"; import { TableSkeleton } from "../../components/Skeleton"; @@ -72,8 +72,7 @@ export function DefaultModelsSection() { window.requestAnimationFrame(() => { const section = document.getElementById("system-models-heading")?.closest("section"); const action = section?.querySelector("article[data-default] .system-model-actions button") ?? section?.querySelector(".system-model-actions button"); - section?.scrollIntoView({ block: "start" }); - action?.focus(); + revealInPageBody(section ?? null, action ?? null); }); }); diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 92350442c..9b4203453 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -55,7 +55,14 @@ export const keys = { }, archiveDialog: { title: "Archive project", - prompt: "Archive {{name}}? All of its keys are revoked immediately. Its assets are kept and stay viewable.", + prompt: "Archive {{name}}? This can't be undone: an archived project can't be restored.", + keys: "Its {{count}} active keys are revoked at once; requests using them stop working.", + keys_one: "Its {{count}} active key is revoked at once; requests using it stop working.", + keys_other: "Its {{count}} active keys are revoked at once; requests using them stop working.", + noKeys: "It has no active keys.", + kept: "Its assets are kept and stay viewable, and work Core already accepted keeps running.", + typeName: "Type to confirm", + stale: "The project list couldn't be read again, so the key count may be out of date. Refresh the projects, then archive.", submit: "Archive", submitting: "Archiving…", }, @@ -102,7 +109,10 @@ export const keys = { failed: "The API address couldn't be read.", localOnly: "Core's API is only reachable on the Core machine. To call it from elsewhere, set public_url in config.json and apply it.", noAddress: "Core has no public address yet. Set public_url in config.json and apply it.", - model: "Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default.", + model: "To create a Session, replace {{model}} with a model name your model provider serves. Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default.", + keyPlaceholder: "", + projectKey: "Set OPENAI_API_KEY to an API key issued for this project. A key is shown only once, when it is issued; if it's lost, issue a new one.", + projectHelp: "Samples for applications that call the Agents API with this project's API keys. The console never sends these requests.", }, detail: { back: "Back", @@ -136,6 +146,8 @@ export const keys = { }, coverageValue: "{{reported}} / {{total}}", keys: "Keys", + keyCounts: "{{active}} active · {{revoked}} revoked", + keyCountsActive: "{{active}} active", keysHelp: "Every active key of the project reaches the same assets. Usage columns count the Sessions each key created. Revoked keys stay listed.", keysLabel: "Keys of {{name}}", keysLoading: "Loading keys…", diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index eccd0f875..ee155196c 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -81,6 +81,11 @@ export const metrics = { agentSectionDetail: "Requests, errors and tokens of each Agent in the selected range. An Agent belongs to one project.", agent: "Agent", sessions: "Sessions", + openAgent: "Open Agent {{name}}", + openSessions: "{{count}} failed Turns — open {{agent}}'s Sessions", + openSessions_one: "{{count}} failed Turn — open {{agent}}'s Sessions", + openSessions_other: "{{count}} failed Turns — open {{agent}}'s Sessions", + failedHelp: "Failed Turns in the selected range; the link lists this Agent's Sessions.", }, keys: { title: "By API key", diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index 2d605e475..79da46a71 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -37,6 +37,7 @@ export const overview = { title: "Run the first Session", body: "Your app calls the /v1 Agents API with the project API key.", open: "Projects and keys", + howToCall: "See how to call", }, complete: { title: "You're set", diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index 7fcd19192..6263feb1a 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -41,7 +41,6 @@ export const sessions = { created: "Created", lastActive: "Last active", actions: "Actions", - errorLabel: "Error", waitingLabel: "What the Session waits for", exactTokens: "{{tokens}} tokens", open: "Open Session {{id}}", @@ -107,6 +106,8 @@ export const sessions = { unassociated: "Items without a Turn", steps: "{{n}} steps", tokens: "{{value}} tokens", + jumpToFailed: "Jump to the failed Turn", + jumpToFailedOfMany: "Jump to a failed Turn ({{count}})", }, turnTable: { turn: "Turn", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 7ec8ccd9c..55181b7d1 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -57,7 +57,14 @@ export const keys: TranslationShape = { }, archiveDialog: { title: "归档项目", - prompt: "归档 {{name}}?它的所有 key 会立即撤销。资产保留,仍然可以查看。", + prompt: "归档 {{name}}?此操作无法撤销:归档后的项目不能恢复。", + keys: "它的 {{count}} 个有效 key 会立即撤销,使用这些 key 的请求将无法通过认证。", + keys_one: "它的 {{count}} 个有效 key 会立即撤销,使用这些 key 的请求将无法通过认证。", + keys_other: "它的 {{count}} 个有效 key 会立即撤销,使用这些 key 的请求将无法通过认证。", + noKeys: "它没有有效 key。", + kept: "资产保留,仍然可以查看;Core 已经接受的工作会继续运行。", + typeName: "输入 以确认", + stale: "无法重新读取项目列表,key 数量可能已过期。请先刷新项目,再归档。", submit: "归档", submitting: "正在归档…", }, @@ -104,7 +111,10 @@ export const keys: TranslationShape = { failed: "无法读取 API 地址。", localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", noAddress: "Core 还没有公开地址。请在 config.json 中设置 public_url 并应用。", - model: "运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", + model: "创建 Session 时,把 {{model}} 换成模型服务提供的模型名。运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", + keyPlaceholder: "<项目 API key>", + projectKey: "把 OPENAI_API_KEY 设为这个项目签发的 API key。key 只在签发时显示一次;丢失后请签发新 key。", + projectHelp: "应用用这个项目的 API key 调用 Agents API 的示例。控制台不会发送这些请求。", }, detail: { back: "返回", @@ -138,6 +148,8 @@ export const keys: TranslationShape = { }, coverageValue: "{{reported}} / {{total}}", keys: "Key", + keyCounts: "{{active}} 有效 · {{revoked}} 已撤销", + keyCountsActive: "{{active}} 有效", keysHelp: "项目里的每个有效 key 访问的都是同一批资产。用量列统计的是每个 key 创建的 Session。已撤销的 key 会继续列在这里。", keysLabel: "{{name}} 的 key", keysLoading: "正在加载 key…", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 9580da7d4..fcb9264d1 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -81,6 +81,11 @@ export const metrics = { agentSectionDetail: "所选时间范围内每个 Agent 的请求、错误与 Token。每个 Agent 属于一个项目。", agent: "Agent", sessions: "Session", + openAgent: "打开 Agent {{name}}", + openSessions: "{{count}} 个失败的 Turn,打开 {{agent}} 的 Session", + openSessions_one: "{{count}} 个失败的 Turn,打开 {{agent}} 的 Session", + openSessions_other: "{{count}} 个失败的 Turn,打开 {{agent}} 的 Session", + failedHelp: "所选时间范围内失败的 Turn;链接列出这个 Agent 的 Session。", }, keys: { title: "按 API 密钥", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index f85a5a534..060ef8395 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -37,6 +37,7 @@ export const overview = { title: "运行第一个 Session", body: "你的应用用项目 API Key 调用 /v1 Agents API。", open: "项目与 key", + howToCall: "查看调用示例", }, complete: { title: "一切就绪", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index be9e8c70c..b4627b06b 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -38,7 +38,6 @@ export const sessions = { created: "创建时间", lastActive: "最近活跃", actions: "操作", - errorLabel: "错误信息", waitingLabel: "Session 在等待什么", exactTokens: "{{tokens}} 个 Token", open: "打开 Session {{id}}", @@ -104,6 +103,8 @@ export const sessions = { unassociated: "未关联 Turn", steps: "{{n}} 个步骤", tokens: "{{value}} token", + jumpToFailed: "跳到失败的 Turn", + jumpToFailedOfMany: "跳到失败的 Turn({{count}} 个)", }, turnTable: { turn: "Turn", diff --git a/apps/web/src/lib/console-navigation.tsx b/apps/web/src/lib/console-navigation.tsx index 58931f6fa..99144405a 100644 --- a/apps/web/src/lib/console-navigation.tsx +++ b/apps/web/src/lib/console-navigation.tsx @@ -34,10 +34,12 @@ export function hashWithParams(base: string, params: RouteParams = {}): string { /** * What a link asks its target page to open on arrival, such as Add node from - * Getting started. It lives only in memory: a reload, Back or any other - * navigation drops it, so nothing reopens on its own. + * Getting started, a project's call samples, or the Session log filtered to an + * Agent (whose ID is the link's `id`) from Agent metrics. It lives only in + * memory: a reload, Back or any other navigation drops it, so nothing reopens + * on its own. */ -export type ConsoleIntent = "add-node" | "create-project" | "issue-key" | "getting-started" | "default-model"; +export type ConsoleIntent = "add-node" | "create-project" | "issue-key" | "getting-started" | "default-model" | "how-to-call" | "agent-sessions"; /** Whether a page can act on an intent now, not yet, or not at all (then it drops the intent). */ export type IntentReadiness = "ready" | "wait" | "unavailable"; diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index f76b7249a..5171432e5 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -18,7 +18,7 @@ the administrator API are defined by the [administrator API contract](../../cont | Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`), the self-hosted executor installer (`self_hosted_installer`, `self_hosted_installer_sha256`) and the providers whose node assets it holds (`node_artifacts`) | | Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics, the installation | | Sandbox administration | `/core/v1/sandbox/**` | Core key, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | -| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives shell exports of `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends none of them; when the installation is `local_only` it says the API is reachable only on the Core machine, and without an `api_base_url` it says to set `public_url` | +| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, and without any key on an active project's page, the console gives shell exports of `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key, or a placeholder for a key of the project) with `curl` and Python examples for `GET /v1/agents` and `POST /v1/agents/sessions`, and sends none of them; when the installation is `local_only` it says the API is reachable only on the Core machine, and without an `api_base_url` it says to set `public_url` | Browser requests are same-origin and carry only the console session. The browser sends the Core key once, in the sign-in request body, and never stores it; it never @@ -30,11 +30,11 @@ value. | Operation | Route | Console use | | --- | --- | --- | -| List projects | `GET /core/v1/projects` | Project filter on every project-scoped page; Projects and keys list; the Overview's Getting started (a project with an active key) | +| List projects | `GET /core/v1/projects` | Project filter on every project-scoped page; Projects and keys list; the Overview's Getting started (a project with an active key, and the newest active project, preferring one with an active key, whose call samples the first-Session step opens); `active_key_count` in the archive confirmation, which counts more when the project's key list shows more | | Create project | `POST /core/v1/projects` | **Create project**, also from Getting started | | Rename project | `POST /core/v1/projects/{project_id}` | **Rename** on an active project; the ID stays the same | | Archive project | `POST /core/v1/projects/{project_id}/archive` | **Archive**: revokes every key; the project's assets stay readable and deletable | -| List keys | `GET /core/v1/projects/{project_id}/keys` | Key table of a project: name, prefix, status, creation and revocation time | +| List keys | `GET /core/v1/projects/{project_id}/keys` | Key table of a project: name, prefix, status, creation and revocation time; the active keys the archive confirmation counts | | Issue key | `POST /core/v1/projects/{project_id}/keys` | **Issue key** on an active project, also from Getting started; the plaintext is shown once | | Revoke key | `DELETE /core/v1/projects/{project_id}/keys/{key_id}` | **Revoke**, with a warning when it is the project's last active key | @@ -117,7 +117,7 @@ without the installer shows no Connect a host. | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key; `public_url` in a self-hosted Session's Connect a host command; `local_only` stops Add node from issuing a command; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key and on an active project's page; `public_url` in a self-hosted Session's Connect a host command; `local_only` stops Add node from issuing a command; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions