Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The console runs beside the administrator's own Core, with execution, files and

## Information Architecture

- **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the attention table, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log.
- **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the attention table, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log.
- **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete.
- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity observations, host figures, allocations and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset, idle policy, per-node capacity and enrollment. Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Nodes links to these forms; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each harness's default model. Service and deployment owns installation facts, Domain and HTTPS actions and read-only startup settings.
- A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows.
Expand Down
16 changes: 16 additions & 0 deletions apps/web/e2e/data/request-log.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Synthetic current-process HTTP observations for the console acceptance fixture. */
export function requestLog(url) {
const surface = url.searchParams.get("surface") ?? "public";
const entry = (n, values) => ({ request_id: `req_${String(n).padStart(32, "0")}`, trace_id: String(n).padStart(32, "0"), started_at: `2026-10-13T10:00:0${n}Z`, method: "GET", route: "/v1/agents/{agent_id}", state: "completed", status: 200, duration_ms: 12, response_bytes: 512, error_code: null, ...values });
const retained = surface === "public" ? [
entry(4, { route: "/v1/agents/sessions/{session_id}/events", state: "active", status: 200, duration_ms: 2400, response_bytes: 1024 }),
entry(3, { route: "/v1/files/{file_id}", status: 401, error_code: "invalid_api_key", response_bytes: 128 }),
entry(2, { method: "POST", route: "/v1/agents/sessions/{session_id}/events", status: 202 }),
entry(1, { route: "/v1/agents/sessions/{session_id}/events", state: "aborted", status: 200 }),
] : [entry(5, { route: "/core/v1/projects" })];
const filtered = retained.filter((row) => (!url.searchParams.get("method") || row.method === url.searchParams.get("method")) && (!url.searchParams.get("request_id") || row.request_id === url.searchParams.get("request_id")) &&
(url.searchParams.get("outcome") !== "active" || row.state === "active") &&
(url.searchParams.get("outcome") !== "errors" || row.status >= 400 || row.state === "aborted"));
const data = filtered.slice(0, Number(url.searchParams.get("limit") ?? 100));
return { object: "core.request_log.list", instance_id: "fixture-process", instance_started_at: "2026-10-13T09:00:00Z", surface, capacity: 1000, retained_count: retained.length, total_matching: filtered.length, has_more: data.length < filtered.length, data };
}
2 changes: 2 additions & 0 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestLog } from "./data/request-log.mjs";
// Browser acceptance fixture: the console service's routes (/console/**) and the
// Core management tree it forwards (/core/v1/**: installation, projects, summary,
// audit log, metrics, harness default models and /core/v1/sandbox/**), with synthetic, deterministic data and
Expand Down Expand Up @@ -259,6 +260,7 @@ function adminRead(response, path, url) {
if (path === "/installation") return send(response, 200, installation());
if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false });
if (path === "/summary") return send(response, 200, a.summary(url));
if (path === "/request-logs") return send(response, 200, requestLog(url));
if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h"));
const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
const project = match && a.projects.find((entry) => entry.id === match[1]);
Expand Down
88 changes: 88 additions & 0 deletions apps/web/e2e/request-log.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import { expectManagementBoundary, openConsole } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));
const id = (n: number) => `req_${String(n).padStart(32, "0")}`;

test("inspects request arrival and HTTP outcomes without confusing Turn completion", async ({ page, request }) => {
const reads: string[] = [];
page.on("request", (request) => { if (request.url().includes("/request-logs")) reads.push(request.url()); });
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.setViewportSize({ width: 1280, height: 800 });
await openConsole(page, request, "request-log");
await expect(page.getByRole("heading", { name: "Request log", exact: true })).toBeVisible();
await expect(page.getByText("HTTP completion does not mean the Agent Turn has finished.")).toBeVisible();
const table = page.getByRole("table", { name: "Request log" });
await expect(table.getByRole("row")).toHaveCount(5);
const accepted = table.getByRole("row").filter({ hasText: "POST" });
await expect(accepted).toContainText("202");
await expect(accepted).toContainText("Completed");
await expect(accepted.getByRole("cell").last()).toBeInViewport();
await table.getByRole("button", { name: `Open request ${id(4)}`, exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Request details" });
await expect(dialog).toContainText("Elapsed at snapshot");
await expect(dialog).toContainText("1,024");
await dialog.getByRole("button", { name: "Copy trace ID" }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("00000000000000000000000000000004");
await page.keyboard.press("Escape");
expect(reads).toHaveLength(1);
await page.getByRole("radio", { name: "Errors", exact: true }).click();
await expect(table.getByRole("row")).toHaveCount(3);
await expect(table).toContainText("Aborted");
await table.getByRole("button", { name: `Open request ${id(3)}`, exact: true }).click();
await expect(dialog).toContainText("invalid_api_key");
await page.keyboard.press("Escape");
await page.getByRole("radio", { name: "Active", exact: true }).click();
await expect(table.getByRole("row")).toHaveCount(2);
await expect(table).toContainText("Active");
await page.getByRole("radio", { name: "All", exact: true }).click();
await page.getByRole("combobox", { name: "HTTP method" }).click();
await page.getByRole("option", { name: "POST", exact: true }).click();
await expect(table.getByRole("row")).toHaveCount(2);
await expect(table).toContainText("POST");
await page.getByRole("combobox", { name: "HTTP method" }).click();
await page.getByRole("option", { name: "All methods", exact: true }).click();
await page.getByRole("searchbox", { name: "Request ID", exact: true }).fill(id(2));
await page.getByRole("button", { name: "Find request", exact: true }).click();
await expect(table.getByRole("row")).toHaveCount(2);
await expect(table).toContainText("POST");
await page.getByRole("searchbox").fill("");
await page.getByRole("button", { name: "Find request", exact: true }).click();
await page.getByRole("radio", { name: "Administration API", exact: true }).click();
await expect(table).toContainText("/core/v1/projects");
await expect(table.getByRole("row")).toHaveCount(2);
});

test("keeps a failed refresh visibly stale and recovers without losing retained requests", async ({ page, request }) => {
await openConsole(page, request, "request-log");
const table = page.getByRole("table", { name: "Request log" });
await expect(table).toBeVisible();
await page.route("**/core/v1/request-logs?*", (route) => route.fulfill({ status: 503, json: { error: { message: "Unavailable", type: "server_error", code: "unavailable" } } }));
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expect(page.getByText("Some reads failed. Any figures and rows shown cover only data already read; they may be incomplete or out of date.")).toBeVisible();
await expect(table.getByRole("row")).toHaveCount(5);
await page.unroute("**/core/v1/request-logs?*");
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.getByText("Some reads failed. Any figures and rows shown cover only data already read; they may be incomplete or out of date.")).toHaveCount(0);
});

test("distinguishes unavailable reads, empty retained results and bounded coverage", async ({ page, request }) => {
await page.route("**/core/v1/request-logs?*", (route) => route.fulfill({ status: 503, json: { error: { message: "Unavailable", type: "server_error", code: "unavailable" } } }));
await openConsole(page, request, "request-log");
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
await expect(page.getByText("No retained requests match")).toHaveCount(0);
await page.unroute("**/core/v1/request-logs?*");
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.getByRole("table")).toBeVisible();
await page.route("**/core/v1/request-logs?*", async (route) => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({ json: { ...body, retained_count: 1000, total_matching: 500, has_more: true } });
});
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expect(page.getByText("Newest 4 shown. Narrow the filters to find another retained request.")).toBeVisible();
await page.unroute("**/core/v1/request-logs?*");
await page.getByRole("searchbox").fill(id(9));
await page.getByRole("button", { name: "Find request", exact: true }).click();
await expect(page.getByText("No retained requests match")).toBeVisible();
});
3 changes: 3 additions & 0 deletions apps/web/src/ConsoleApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ConsoleTourContext, ConsoleTourScreen } from "./features/onboarding/Con
import { withTransition } from "./features/onboarding/view-transition";
import { OverviewPage } from "./features/overview/OverviewPage";
import { SandboxManagerView } from "./features/sandbox/SandboxManagerView";
import { RequestLogPage, requestLogQuery } from "./features/request-log/RequestLogPage";
import { SessionLogPage } from "./features/sessions/SessionLogPage";
import { SessionPage } from "./features/sessions/SessionPage";
import { SkillsPage } from "./features/skills/SkillsPage";
Expand Down Expand Up @@ -45,6 +46,7 @@ function ConsolePage({ view }: { view: ConsoleView }) {
case "core-metrics": return <CoreMetricsPage />;
case "agent-metrics": return <AgentMetricsPage />;
case "sandbox-metrics": return <SandboxMetricsPage />;
case "request-log": return <RequestLogPage />;
case "sessions": return <SessionLogPage />;
case "session": return <SessionPage />;
case "agents": return <AgentsPage />;
Expand Down Expand Up @@ -106,6 +108,7 @@ function ConsoleShell() {
const navigation = useMemo(() => ({ ...location, navigate, back, clearIntent }), [location, navigate, back, clearIntent]);

const prefetch = useCallback((view: ConsoleView) => {
if (view === "request-log") { void queryClient.prefetchQuery(requestLogQuery("", "all")); return; }
const spec = prefetchable[view];
if (!spec) return;
for (const project of state.projects) void queryClient.prefetchQuery(collectionQuery(spec, project.id));
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ConsoleSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Layers3,
LayoutDashboard,
ListChecks,
ListFilter,
ListTree,
Network,
Puzzle,
Expand All @@ -27,6 +28,7 @@ const viewIcons: Record<ConsoleView, LucideIcon> = {
"core-metrics": Network,
"agent-metrics": Activity,
"sandbox-metrics": Cpu,
"request-log": ListFilter,
sessions: ListTree,
session: ListTree,
agents: Bot,
Expand Down
Loading
Loading