diff --git a/.env.example b/.env.example index b58a813f9..482c6add0 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ AGENTS_API_PROXY_TARGET=http://127.0.0.1:8091 # Preferred: plaintext execution-tenant bearer file read only by the local # Vite server. If unset, Web also checks this conventional path automatically. AGENTS_API_PROXY_TOKEN_FILE=~/.parsar/agents-api/web-token +# Optional server-only OTLP/HTTP Runtime history exporter configuration. +# When unset, current observations and the Dashboard require no telemetry backend. +# Runtime history uses the Core PostgreSQL database with 30-second periodic sampling. +# Optional sampling/OTLP export overrides: services/agents-api/runtime-history/README.md +# AGENTS_API_RUNTIME_HISTORY_FILE=/absolute/path/runtime-history.json +# Set sample_interval_seconds (5..300) in that server-only file to enable the +# execution-owner singleton sampler. Omit it for on-read export only. # Alternative server-side value. Never use a VITE_ prefix, and do not set this # together with AGENTS_API_PROXY_TOKEN_FILE. diff --git a/AGENTS.md b/AGENTS.md index a5ad47173..f48404ad3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,8 @@ execution truth or expose server-side credentials in the browser. Update architecture and generated contracts with changes. Run `make check` before reporting completion. -After implementation and verification, request an independent blind review with -only requirements, acceptance criteria, boundaries, repository path and baseline. -Fix in-scope blockers and review again. Never use `codex exec` for this review. +Choose independent blind review according to change risk; follow CONTRIBUTING.md +for reviewer context and re-review criteria. Small, verified fixes may use self-review. +Never use `codex exec` as a substitute reviewer. Documentation and code comments are English; user-facing copy may be bilingual. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6884a915e..b2ef5dae7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,10 +20,13 @@ hashes. Do not automatically sync or delete the original repository's Core. Develop in an isolated worktree on a feature branch and submit a PR. Do not edit or commit implementation directly on main. An empty repository bootstrap commit -is only the comparison base for the first import PR. After validation, conduct an -independent blind review using only requirements, acceptance criteria, boundaries, -repository path and comparison baseline. Fix in-scope blockers before delivery. -Do not use `codex exec` as a substitute reviewer. +is only the comparison base for the first import PR. Choose review depth by risk. Substantial changes and changes involving security, +shared lifecycle ownership or uncertain cross-package behavior need an independent +blind review after validation. Give the reviewer only requirements, acceptance +criteria, boundaries, repository path and comparison baseline. Small, verified +fixes may use self-review, including focused corrections after a blind review; +repeat independent review when a correction materially changes the design or risk. +Fix in-scope blockers before delivery. Do not use `codex exec` as a substitute reviewer. The Core Web is an administrator console for execution and resource operations; business collaboration remains in Parsar. Environment Template management shares @@ -291,6 +294,35 @@ starts the Runtime and its daemon authenticates and initiates the Core connectio Core verifies principal ownership and the exact Environment binding. These are management responsibilities, not separate execution architectures. +Runtime telemetry uses a separate read-only service boundary documented in +[`contracts/agents-api/runtime-observability.md`](contracts/agents-api/runtime-observability.md). +Resolve durable Session, Environment and Runtime-instance identity before selecting +a provider source. Observation never extends a lease or changes compute lifecycle. +Keep observed zero, unavailable data and unsupported Runtime modes distinct. Metrics +may inform operators, but automatic suspension requires durable Core-owned activity +state and must not use a monitoring backend as lifecycle authority. +Managed Docker observes one non-streaming Inspect/Stats sample. Managed microsandbox +observes the exact persisted compute generation through the existing one-shot helper +and pinned native CLI metrics report, with SDK identity checks before and after +observation. Derive compute start from the same native sample timestamp and precise +uptime; never subtract rounded uptime from a new wall-clock timestamp. Preserve +cumulative CPU seconds, memory usage/limit and +compute uptime semantics across both. Do not use microsandbox's instantaneous CPU +percent, wake suspended compute, or expose provider-native identifiers to fill a +common field. + +Runtime history uses the existing Core PostgreSQL database: one sanitized row per +periodic observation, seven-day retention and bounded reads. It is best-effort +operational evidence, not execution or Usage authority. The execution owner samples +by default every 30 seconds. Existing canonical Session Usage supplies token +snapshots; never aggregate provider counters as model tokens. Preserve missing data +and reset CPU derivation across compute incarnations or counter regressions. +The bounded asynchronous database writer and optional OTLP exporter have independent +queues; external telemetry outages must not stall local history or execution. +Retention cleanup also runs without active Runtimes. The browser queries only Core, +never storage or a Collector, and stays a lightweight administrator console. +No additional metrics database or Collector is required for retained charts. + In V1, our daemon fills the user-side executor role. Users deploy daemon, the selected harness, local tools and workspace together. Do not require Codex `exec-server`, a service-side harness, registry/Noise transport or remote tool diff --git a/Makefile b/Makefile index 6c05a5456..80a8d849d 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ openapi: -g cmd/server/main.go --dir ./services/agents-api,./contracts/agents-api/v1 \ --output "$$output" \ --outputTypes yaml --parseInternal; \ + python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ mv "$$output/swagger.yaml" contracts/agents-api/openapi.yaml check-sqlc: diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index f59389355..2dd5df09a 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -2399,15 +2399,36 @@ test("presents Dashboard page-chain results and System boundaries without extra "/v1/agents/sessions/session_snapshot/items", "/v1/agents/sessions/session_snapshot/turns", ]; + let delayNextSessionList = true; + let releaseSessionList: (() => void) | null = null; + let markSessionListStarted: (() => void) | null = null; + const sessionListStarted = new Promise((resolve) => { + markSessionListStarted = resolve; + }); + await page.route("**/v1/agents/sessions*", async (route) => { + if (new URL(route.request().url()).pathname !== "/v1/agents/sessions" || !delayNextSessionList) { + return route.continue(); + } + delayNextSessionList = false; + markSessionListStarted?.(); + await new Promise((resolve) => { + releaseSessionList = resolve; + }); + await route.continue(); + }); const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); await refresh.click(); + await sessionListStarted; + await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); + releaseSessionList?.(); await expect.poll(async () => { const entries = await fixtureRequests(request); return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; - }).toEqual([count(before, "/v1/agents") + 1, count(before, "/v1/agents/sessions") + 1]); + }).toEqual([count(before, "/v1/agents") + 1, count(before, "/v1/agents/sessions") + 2]); const after = await fixtureRequests(request); expect(count(after, "/v1/agents")).toBe(count(before, "/v1/agents") + 1); - expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 1); + expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 2); + expect(count(after, "/v1/agents/runtime-observations")).toBe(count(before, "/v1/agents/runtime-observations") + 1); for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); @@ -2536,6 +2557,417 @@ test("presents Dashboard page-chain results and System boundaries without extra await attachScreenshot(page, testInfo, "narrow-system-contract-boundary"); }); +test("renders Runtime telemetry as visual snapshot panels with details on demand", async ({ page }, testInfo) => { + const baseline = 1_789_438_800; + const sessionId = "11111111-1111-4111-8111-111111111111"; + const environmentId = "22222222-2222-4222-8222-222222222222"; + const allocationId = "33333333-3333-4333-8333-333333333333"; + const runtimeSession = { + id: sessionId, + object: "agent.session", + agent: { + id: "agent_b", + model: "fixture/model", + name: "Runtime analyst", + instructions: null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + }, + environment: { + type: "openai_hosted", + id: environmentId, + capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }, + status: "in_progress", + error: null, + metadata: { title: "Repository migration" }, + required_actions: [], + vault_ids: [], + usage: { + input_tokens: 420_000, + output_tokens: 66_000, + total_tokens: 486_000, + input_tokens_details: { cached_tokens: 180_000 }, + output_tokens_details: { reasoning_tokens: 22_000 }, + }, + created_at: baseline - 9_000, + last_active_at: baseline - 20, + }; + const runtimeSessions = Array.from({ length: 12 }, (_, index) => ({ + ...runtimeSession, + id: index === 0 ? sessionId : `11111111-1111-4111-8111-${String(index + 1).padStart(12, "0")}`, + metadata: { title: `Repository migration ${index + 1}` }, + usage: { ...runtimeSession.usage, input_tokens: 420_000 + index, total_tokens: 486_000 + index }, + })); + const list = (data: Array<{ id: string }>) => ({ + object: "list", + data, + has_more: false, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }); + + await page.route("**/v1/agents/runtime-history/capabilities", (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + object: "agent.runtime_history_capabilities", + available: false, + reason: "not_configured", + collection_mode: null, + sample_interval_seconds: null, + retention_seconds: null, + minimum_step_seconds: null, + maximum_range_seconds: null, + maximum_points: null, + metrics: [], + }), + })); + await page.route("**/v1/agents/sessions*", async (route) => { + if (new URL(route.request().url()).pathname !== "/v1/agents/sessions") return route.fallback(); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(list(runtimeSessions)) }); + }); + let runtimeObservationReads = 0; + await page.route("**/v1/agents/runtime-observations*", async (route) => { + const sampleIndex = runtimeObservationReads; + runtimeObservationReads += 1; + const observedAt = baseline - 1 + sampleIndex * 30; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(list(runtimeSessions.map((runtime, index) => ({ + id: runtime.id, + object: "agent.runtime_observation", + session_id: runtime.id, + environment_id: environmentId, + mode: "openai_hosted", + provider_type: "docker", + instance: { + kind: "managed_allocation", + allocation_id: index === 0 ? allocationId : `33333333-3333-4333-8333-${String(index + 1).padStart(12, "0")}`, + device_id: null, + connection_generation: null, + }, + status: "observed", + reason: null, + allocation_created_at: baseline - 8_500, + resolved_at: observedAt + 1, + observed_at: observedAt, + started_at: baseline - 8_100, + cpu: { + usage_seconds_total: 7_350 + index + sampleIndex * 30, + capacity_cores: 2, + usage_cores: null, + utilization_ratio: null, + }, + memory: { usage_bytes: 1_288_490_188, limit_bytes: 2_147_483_648 }, + })))), + }); + }); + + await page.goto("/"); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("1 sample ·"); + await expect(dashboard.getByRole("heading", { name: "CPU usage" })).toBeVisible(); + await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); + await expect(dashboard.getByRole("heading", { name: "Compute uptime" })).toBeVisible(); + await expect(dashboard.getByRole("heading", { name: "Token throughput" })).toBeVisible(); + await expect(dashboard.getByLabel("Live Runtime sampling every 30 seconds")).toBeVisible(); + const liveRange = dashboard.getByRole("group", { name: "Runtime live range" }); + await expect(liveRange.getByRole("button", { name: "1h" })).toHaveAttribute("aria-pressed", "true"); + await liveRange.getByRole("button", { name: "15m" }).click(); + await expect(liveRange.getByRole("button", { name: "15m" })).toHaveAttribute("aria-pressed", "true"); + await refresh.click(); + await expect(dashboard.getByLabel("CPU usage: 2 live samples")).toBeVisible(); + await refresh.click(); + await expect(dashboard.getByLabel("CPU usage: 3 live samples")).toBeVisible(); + await expect(dashboard.getByLabel("Memory usage: 3 live samples")).toBeVisible(); + await expect(dashboard.getByLabel("Compute uptime: 3 live samples")).toBeVisible(); + await expect(dashboard.getByLabel("Token throughput: 3 live samples")).toBeVisible(); + await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("3 samples"); + await expect(dashboard.getByText("CPU usage live trend available")).toBeAttached(); + await expect(dashboard).not.toContainText("Collecting live samples"); + const memoryCard = dashboard.getByRole("region", { name: "Memory usage live chart" }); + const memoryStrokePixelCount = () => memoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { + const context = canvas.getContext("2d"); + if (!context) return 0; + const { data, width, height } = context.getImageData(0, 0, canvas.width, canvas.height); + let pixels = 0; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = (y * width + x) * 4; + const red = data[offset] ?? 0; + const green = data[offset + 1] ?? 0; + const blue = data[offset + 2] ?? 0; + const alpha = data[offset + 3] ?? 0; + if (alpha > 128 && Math.abs(red - 185) < 20 && Math.abs(green - 152) < 20 && Math.abs(blue - 244) < 20) { + pixels += 1; + } + } + } + return pixels; + }); + expect(await memoryStrokePixelCount()).toBeGreaterThan(0); + const cpuChart = dashboard.getByLabel("CPU usage: 3 live samples"); + const cpuCard = cpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); + await cpuChart.focus(); + await cpuChart.press("Enter"); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); + await cpuChart.press("Escape"); + await cpuChart.hover({ position: { x: 260, y: 90 } }); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toBeVisible(); + await cpuChart.click({ position: { x: 260, y: 90 } }); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + await cpuChart.focus(); + await cpuChart.press("ArrowRight"); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("50%"); + const cpuSeriesToggle = cpuCard.locator(".dashboard-runtime-trend-legend button").first(); + await cpuSeriesToggle.click(); + await expect(cpuSeriesToggle).toHaveAttribute("aria-pressed", "false"); + await cpuSeriesToggle.click(); + await expect(cpuSeriesToggle).toHaveAttribute("aria-pressed", "true"); + + await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4); + await expect(dashboard.locator(".dashboard-runtime-timeline")).toHaveCount(0); + const initialViewStart = Number(await cpuChart.getAttribute("data-view-start")); + const initialViewEnd = Number(await cpuChart.getAttribute("data-view-end")); + const plot = cpuCard.locator(".u-over"); + const plotBox = await plot.boundingBox(); + expect(plotBox).not.toBeNull(); + await page.mouse.move(plotBox!.x + plotBox!.width * .55, plotBox!.y + plotBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(plotBox!.x + plotBox!.width, plotBox!.y + plotBox!.height / 2, { steps: 8 }); + await page.mouse.up(); + await expect.poll(async () => Number(await cpuChart.getAttribute("data-view-start"))).toBeGreaterThan(initialViewStart); + await expect.poll(async () => Number(await cpuChart.getAttribute("data-view-end"))).toBeLessThanOrEqual(initialViewEnd); + const zoomedViewStart = Number(await cpuChart.getAttribute("data-view-start")); + const zoomedViewEnd = Number(await cpuChart.getAttribute("data-view-end")); + await cpuChart.focus(); + await cpuChart.press("Escape"); + await cpuChart.press("ArrowRight"); + const keyboardSelectedAt = Number(await cpuChart.getAttribute("data-selected-at")); + expect(keyboardSelectedAt).toBeGreaterThanOrEqual(zoomedViewStart); + expect(keyboardSelectedAt).toBeLessThanOrEqual(zoomedViewEnd); + for (const chartName of ["Memory usage", "Compute uptime", "Token throughput"]) { + await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-start", String(initialViewStart)); + await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-end", String(initialViewEnd)); + } + const resetZoom = cpuCard.getByRole("button", { name: "Reset zoom" }); + await expect(resetZoom).toBeEnabled(); + await resetZoom.click(); + await expect(cpuChart).toHaveAttribute("data-view-start", String(initialViewStart)); + await expect(cpuChart).toHaveAttribute("data-view-end", String(initialViewEnd)); + await expect(dashboard.getByRole("table", { name: "Runtime targets" })).not.toBeVisible(); + for (const close of await page.getByRole("button", { name: "Close notification" }).all()) await close.click(); + await expect(page.getByRole("button", { name: "Close notification" })).toHaveCount(0); + await attachElementScreenshot(dashboard.locator(".dashboard-runtime-panel"), testInfo, "runtime-visual-dashboard"); + + await dashboard.getByText("Runtime targets", { exact: true }).click(); + await expect(dashboard.getByRole("table", { name: "Runtime targets" })).toBeVisible(); + await expect(dashboard.getByText("Page 1 of 2")).toBeVisible(); + const runtimeSearch = dashboard.getByPlaceholder("Search Session, provider, or identity"); + await runtimeSearch.fill("Repository migration 12"); + await expect(dashboard.getByText("1 visible")).toBeVisible(); + await expect(dashboard.getByRole("table", { name: "Runtime targets" }).getByRole("row", { name: /Repository migration 12/ })).toBeVisible(); + await runtimeSearch.fill(""); + await dashboard.getByRole("button", { name: "Tokens" }).click(); + await expect(dashboard.getByRole("columnheader", { name: "Tokens" })).toHaveAttribute("aria-sort", "descending"); + await dashboard.getByRole("button", { name: "Next" }).click(); + await expect(dashboard.getByText("Page 2 of 2")).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); + await expect.poll(() => cpuChart.evaluate((element) => getComputedStyle(element).touchAction)).toBe("pan-y"); + for (const close of await page.getByRole("button", { name: "Close notification" }).all()) await close.click(); + const widths = await dashboard.locator(".dashboard-runtime-panel").evaluate((element) => ({ + viewport: innerWidth, + document: document.documentElement.scrollWidth, + panel: element.getBoundingClientRect().width, + })); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.panel).toBeLessThanOrEqual(widths.viewport); + await attachElementScreenshot(dashboard.locator(".dashboard-runtime-panel"), testInfo, "runtime-visual-dashboard-narrow"); +}); + +test("restores retained Runtime history after a Dashboard reload", async ({ page }) => { + const sessionId = "11111111-1111-4111-8111-111111111111"; + const environmentId = "22222222-2222-4222-8222-222222222222"; + const allocationId = "33333333-3333-4333-8333-333333333333"; + const runtimeSession = { + id: sessionId, + object: "agent.session", + agent: { + id: "agent_history", model: "fixture/model", name: "History worker", instructions: null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: {}, service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, tools: [], + }, + environment: { + type: "openai_hosted", id: environmentId, capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, packages: { npm: [], python: [], system: [] }, + files: [], plugins: [], skills: [], + }, + status: "idle", error: null, metadata: { title: "Persisted runtime" }, required_actions: [], vault_ids: [], + usage: null, created_at: 1_789_430_000, last_active_at: 1_789_438_000, + }; + const list = (data: Array<{ id: string }>) => ({ + object: "list", data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null, + }); + + await page.route("**/v1/agents/sessions*", async (route) => { + if (new URL(route.request().url()).pathname !== "/v1/agents/sessions") return route.fallback(); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(list([runtimeSession])) }); + }); + await page.route("**/v1/agents/runtime-observations*", async (route) => { + const now = Math.floor(Date.now() / 1_000); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(list([{ + id: sessionId, object: "agent.runtime_observation", session_id: sessionId, environment_id: environmentId, + mode: "openai_hosted", provider_type: "docker", + instance: { kind: "managed_allocation", allocation_id: allocationId, device_id: null, connection_generation: null }, + status: "observed", reason: null, allocation_created_at: now - 600, resolved_at: now, + observed_at: now - 1, started_at: now - 600, + cpu: { usage_seconds_total: 120, capacity_cores: 2, usage_cores: null, utilization_ratio: null }, + memory: { usage_bytes: 536_870_912, limit_bytes: 2_147_483_648 }, + }])), + }); + }); + await page.route("**/v1/agents/runtime-history/capabilities", (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + object: "agent.runtime_history_capabilities", available: true, reason: null, collection_mode: "periodic", + sample_interval_seconds: 30, retention_seconds: 604_800, minimum_step_seconds: 30, + maximum_range_seconds: 86_400, maximum_points: 1_000, metrics: ["cpu", "memory", "tokens"], + }), + })); + await page.route(`**/v1/agents/sessions/${sessionId}/runtime-history*`, async (route) => { + const url = new URL(route.request().url()); + const start = Number(url.searchParams.get("start")); + const end = Number(url.searchParams.get("end")); + const firstStart = start; + const gapStart = end - 60; + const lastStart = end - 30; + const startedAt = start; + const point = (pointStart: number, ratio: number, memory: number) => ({ + start: pointStart, end: pointStart + 30, first_observed_at: pointStart + 10, last_observed_at: pointStart + 20, + observation_count: 1, observed_count: 1, unavailable_count: 0, + cpu: { contributor_count: 1, utilization_ratio: ratio, capacity_cores: 2 }, + memory: { contributor_count: 1, usage_bytes: memory, limit_bytes: 2_147_483_648 }, + }); + const gap = { + start: gapStart, end: gapStart + 30, first_observed_at: gapStart + 10, last_observed_at: gapStart + 20, + observation_count: 1, observed_count: 0, unavailable_count: 1, cpu: null, memory: null, + }; + const points = Array.from({ length: Math.floor((end - start) / 30) }, (_, index) => { + const pointStart = start + index * 30; + return pointStart === gapStart + ? gap + : point(pointStart, .25 + index / 1_000, 536_870_912 + index * 1_048_576); + }).filter((point) => point.start !== end - 90); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + object: "agent.runtime_history", source: "durable", session_id: sessionId, + requested_range: { start, end }, resolution_seconds: 30, generated_at: end, + coverage: { + retained_start: start, first_sample_at: firstStart + 10, last_sample_at: lastStart + 20, + sample_count: points.length, expected_sample_count: Math.floor((end - start) / 30), + buckets: points.map(({ cpu: _cpu, memory: _memory, ...coverage }) => coverage), + }, + series: [{ + environment_id: environmentId, allocation_id: allocationId, + started_at: { seconds: startedAt, nanoseconds: 123_456_789 }, provider_type: "docker", points, + }], + token_usage: points.map((point) => ({ + start: point.start, + end: point.end, + sampled_at: point.start + 20, + input_tokens: 10_000 + (point.start - start) * 10, + output_tokens: 2_000 + (point.start - start) * 2, + })), + }), + }); + }); + + await page.goto("/"); + const dashboard = page.locator(".dashboard-runtime-panel"); + await expect(dashboard.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); + await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); + await expect(dashboard.getByLabel("Runtime durable-history charts")).toBeVisible(); + await expect(dashboard.getByRole("heading", { name: "Compute uptime", exact: true })).toHaveCount(0); + await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(3); + await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); + await expect(dashboard).toContainText("120 buckets"); + await expect(dashboard).toContainText("119/120 observations"); + await expect(dashboard.getByText("Token throughput durable trend available")).toBeAttached(); + const durableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); + await expect(dashboard.getByRole("region", { name: "CPU usage durable history chart" })).toBeVisible(); + const durableCpuCard = durableCpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); + await durableCpuChart.focus(); + await durableCpuChart.press("ArrowLeft"); + await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + await durableCpuChart.press("ArrowLeft"); + await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + const durableMemoryCard = dashboard.getByRole("region", { name: "Memory usage durable history chart" }); + const durableMemorySpan = await durableMemoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { + const context = canvas.getContext("2d"); + if (!context) return 0; + const { data, width, height } = context.getImageData(0, 0, canvas.width, canvas.height); + let minimumX = width; + let maximumX = -1; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = (y * width + x) * 4; + const red = data[offset] ?? 0; + const green = data[offset + 1] ?? 0; + const blue = data[offset + 2] ?? 0; + const alpha = data[offset + 3] ?? 0; + if (alpha > 128 && Math.abs(red - 185) < 20 && Math.abs(green - 152) < 20 && Math.abs(blue - 244) < 20) { + minimumX = Math.min(minimumX, x); + maximumX = Math.max(maximumX, x); + } + } + } + return maximumX < minimumX ? 0 : maximumX - minimumX; + }); + expect(durableMemorySpan).toBeGreaterThan(100); + const durableInitialStart = Number(await durableCpuChart.getAttribute("data-view-start")); + const durablePlotBox = await durableCpuCard.locator(".u-over").boundingBox(); + expect(durablePlotBox).not.toBeNull(); + await page.mouse.move(durablePlotBox!.x + durablePlotBox!.width * .2, durablePlotBox!.y + durablePlotBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(durablePlotBox!.x + durablePlotBox!.width * .8, durablePlotBox!.y + durablePlotBox!.height / 2, { steps: 8 }); + await page.mouse.up(); + await expect.poll(async () => Number(await durableCpuChart.getAttribute("data-view-start"))).toBeGreaterThan(durableInitialStart); + const durableZoomStart = Number(await durableCpuChart.getAttribute("data-view-start")); + const durableZoomEnd = Number(await durableCpuChart.getAttribute("data-view-end")); + await expect(durableCpuCard.getByRole("button", { name: "Reset zoom" })).toBeVisible(); + await page.getByRole("button", { name: "Refresh Dashboard snapshot" }).click(); + const refreshedDurableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); + await expect(refreshedDurableCpuChart).toHaveAttribute("data-view-start", String(durableZoomStart)); + await expect(refreshedDurableCpuChart).toHaveAttribute("data-view-end", String(durableZoomEnd)); + + await page.reload(); + await expect(dashboard.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); + await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); + await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); +}); + test("publishes Dashboard counts only after every top-level Agent and Session page loads", async ({ page, request }) => { await resetFixture(request); const agentAfters: Array = []; @@ -2604,7 +3036,11 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); expect(agentAfters).toEqual([null, "agent_b"]); - expect(sessionAfters).toEqual([null, "session_snapshot"]); + // Session collection loads once for the page and once per Runtime snapshot. + // Returning to Dashboard refreshes Runtime immediately instead of waiting 30 seconds. + await expect.poll(() => sessionAfters.length).toBe(6); + expect(sessionAfters.filter((after) => after === null)).toHaveLength(3); + expect(sessionAfters.filter((after) => after === "session_snapshot")).toHaveLength(3); }); test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { @@ -2647,8 +3083,9 @@ test("keeps the previous Dashboard result when pagination exceeds the safety lim await refresh.click(); await expect.poll(() => reads).toBe(100); await expect(refresh).toBeEnabled(); - await expect(dashboard).toContainText("Using the last successful snapshot"); + await expect(dashboard).toContainText("Snapshot incomplete"); await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); + await expect(dashboard).toContainText("Sessions changed while Runtime observations were loading"); await expect(loadedAgents).toContainText("3"); await dashboard.getByRole("button", { name: "Connection settings" }).click(); const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 4f5b1a67f..dea8dac36 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -1026,6 +1026,15 @@ const server = http.createServer(async (request, response) => { return sendJson(response, created, 201); } + // The legacy Web fixture uses human-readable Session IDs for interaction + // assertions. Runtime observation resources require canonical UUIDs, so this + // fixture advertises an empty, valid collection instead of inventing a false + // identity join. Positive Runtime rendering is covered by the typed component + // and coordinator tests with canonical identities. + if (request.method === "GET" && url.pathname === "/v1/agents/runtime-observations") { + return sendJson(response, page([])); + } + if (request.method === "GET" && url.pathname === "/v1/agents/sessions") { trackAbort(response, "sessionListReads"); const control = consumeControl("sessionList"); diff --git a/apps/web/package.json b/apps/web/package.json index 98bf865e2..47db10c42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,10 +11,12 @@ }, "dependencies": { "@agents-core-web/agents-client": "workspace:*", + "@tanstack/react-table": "^8.21.3", "lucide-react": "^1.22.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-markdown": "^10.1.0" + "react-markdown": "^10.1.0", + "uplot": "^1.6.32" }, "devDependencies": { "@types/react": "^19.2.17", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4b0e787d6..34cd139e6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,6 +34,13 @@ import { requestAgentUpdate, } from "./features/agents/agent-actions"; import { DashboardView } from "./features/dashboard/DashboardView"; +import { loadRuntimeDurableSnapshot } from "./features/dashboard/runtime-history"; +import { + loadRuntimeDashboardSnapshot, + RUNTIME_SNAPSHOT_REFRESH_MS, + RUNTIME_SNAPSHOT_TIMEOUT_MS, + type RuntimeDashboardSnapshot, +} from "./features/dashboard/runtime-snapshot"; import { EnvironmentTemplatesView } from "./features/environment-templates/EnvironmentTemplatesView"; import { SessionsView, @@ -277,6 +284,10 @@ export function App() { const [sessionCollectionState, setSessionCollectionState] = useState("connecting"); const [sessionCollectionError, setSessionCollectionError] = useState(null); const [sessionCollectionHasSnapshot, setSessionCollectionHasSnapshot] = useState(false); + const [runtimeSnapshot, setRuntimeSnapshot] = useState(null); + const [runtimeCollectionState, setRuntimeCollectionState] = useState("connecting"); + const [runtimeCollectionError, setRuntimeCollectionError] = useState(null); + const [runtimeCollectionHasSnapshot, setRuntimeCollectionHasSnapshot] = useState(false); const [sessionAgentFilter, setSessionAgentFilter] = useState(null); const [filteredSessions, setFilteredSessions] = useState([]); const [filteredSessionCollectionState, setFilteredSessionCollectionState] = useState("connecting"); @@ -312,6 +323,10 @@ export function App() { const sessionCollectionRequestRef = useRef(0); const agentCollectionAbortRef = useRef(null); const sessionCollectionAbortRef = useRef(null); + const runtimeCollectionAbortRef = useRef(null); + const runtimeCollectionRequestRef = useRef(0); + const runtimeCollectionHasSnapshotRef = useRef(false); + const dashboardRefreshInFlightRef = useRef(false); const filteredSessionCollectionAbortRef = useRef(null); const filteredSessionCollectionRequestRef = useRef(0); const sessionAgentFilterRef = useRef(sessionAgentFilter); @@ -524,6 +539,54 @@ export function App() { } }, [core, coreGeneration, notify]); + const refreshRuntimeSnapshot = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + runtimeCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + runtimeCollectionAbortRef.current = controller; + const request = runtimeCollectionRequestRef.current + 1; + runtimeCollectionRequestRef.current = request; + let timedOut = false; + const timeout = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, RUNTIME_SNAPSHOT_TIMEOUT_MS); + if (!runtimeCollectionHasSnapshotRef.current) setRuntimeCollectionState("connecting"); + setRuntimeCollectionError(null); + try { + const result = await settleCollection(() => loadRuntimeDashboardSnapshot( + core, + controller.signal, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== runtimeCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason) && !timedOut) return false; + const message = timedOut + ? "Runtime snapshot exceeded the 15 second Web refresh budget." + : errorMessage(result.reason); + setRuntimeCollectionState("failed"); + setRuntimeCollectionError(message); + return false; + } + if (result.value === null) { + setRuntimeCollectionState("failed"); + setRuntimeCollectionError("Session data changed while Runtime observations were loading. The previous complete snapshot was retained."); + return false; + } + setRuntimeSnapshot(result.value); + runtimeCollectionHasSnapshotRef.current = true; + setRuntimeCollectionHasSnapshot(true); + setRuntimeCollectionState("ready"); + return true; + } finally { + window.clearTimeout(timeout); + if (runtimeCollectionAbortRef.current === controller) runtimeCollectionAbortRef.current = null; + } + }, [core, coreGeneration]); + const refreshFilteredSessions = useCallback(async (agentId: string) => { if ( coreGeneration !== connectionGenerationRef.current || @@ -897,13 +960,27 @@ export function App() { }, [coreGeneration, refreshAgents, refreshFilteredSessions, refreshSelectedSession, refreshSessions]); const refreshDashboard = useCallback(() => { + if (dashboardRefreshInFlightRef.current) return; + dashboardRefreshInFlightRef.current = true; void refreshAgents(); - void refreshSessions(); void refreshVaults(); void refreshEnvironmentTemplates(); + void (async () => { + try { + if (await refreshSessions()) await refreshRuntimeSnapshot(); + } finally { + dashboardRefreshInFlightRef.current = false; + } + })(); const filter = sessionAgentFilterRef.current; if (filter) void refreshFilteredSessions(filter); - }, [refreshAgents, refreshEnvironmentTemplates, refreshFilteredSessions, refreshSessions, refreshVaults]); + }, [refreshAgents, refreshEnvironmentTemplates, refreshFilteredSessions, refreshRuntimeSnapshot, refreshSessions, refreshVaults]); + + const loadDashboardRuntimeHistory = useCallback(( + snapshot: RuntimeDashboardSnapshot, + range: Parameters[2], + signal: AbortSignal, + ) => loadRuntimeDurableSnapshot(core, snapshot, range, signal), [core]); const refreshSystem = useCallback(() => { void refreshStartupConfiguration(); @@ -947,6 +1024,11 @@ export function App() { setSessions([]); setAgentCollectionHasSnapshot(false); setSessionCollectionHasSnapshot(false); + setRuntimeSnapshot(null); + runtimeCollectionHasSnapshotRef.current = false; + setRuntimeCollectionState("connecting"); + setRuntimeCollectionError(null); + setRuntimeCollectionHasSnapshot(false); setItems([]); setTurns([]); setEnvironmentObservations(new Map()); @@ -964,6 +1046,40 @@ export function App() { void refreshStartupConfiguration(); }, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshStartupConfiguration, refreshVaults]); + useEffect(() => { + if (view !== "dashboard") return; + let timer: number | null = null; + void (async () => { + if ( + sessionCollectionState === "ready" && + !runtimeCollectionHasSnapshotRef.current && + !dashboardRefreshInFlightRef.current + ) { + await refreshRuntimeSnapshot(); + } + })(); + const schedule = () => { + const jitter = Math.floor(Math.random() * 5_000); + timer = window.setTimeout(() => { + if (!document.hidden && !dashboardRefreshInFlightRef.current) { + void refreshRuntimeSnapshot(); + } + schedule(); + }, RUNTIME_SNAPSHOT_REFRESH_MS + jitter); + }; + const onVisibilityChange = () => { + if (!document.hidden && !dashboardRefreshInFlightRef.current) { + void refreshRuntimeSnapshot(); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + schedule(); + return () => { + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [refreshRuntimeSnapshot, sessionCollectionState, view]); + useEffect(() => { filteredSessionCollectionAbortRef.current?.abort(); filteredSessionCollectionAbortRef.current = null; @@ -2003,6 +2119,7 @@ export function App() { connectionGenerationRef.current += 1; agentCollectionRequestRef.current += 1; sessionCollectionRequestRef.current += 1; + runtimeCollectionRequestRef.current += 1; filteredSessionCollectionRequestRef.current += 1; vaultCollectionRequestRef.current += 1; startupConfigurationRequestRef.current += 1; @@ -2027,6 +2144,8 @@ export function App() { agentCollectionAbortRef.current = null; sessionCollectionAbortRef.current?.abort(); sessionCollectionAbortRef.current = null; + runtimeCollectionAbortRef.current?.abort(); + runtimeCollectionAbortRef.current = null; filteredSessionCollectionAbortRef.current?.abort(); filteredSessionCollectionAbortRef.current = null; vaultCollectionAbortRef.current?.abort(); @@ -2044,6 +2163,11 @@ export function App() { setSessionCollectionState("connecting"); setSessionCollectionError(null); setSessionCollectionHasSnapshot(false); + setRuntimeSnapshot(null); + runtimeCollectionHasSnapshotRef.current = false; + setRuntimeCollectionState("connecting"); + setRuntimeCollectionError(null); + setRuntimeCollectionHasSnapshot(false); sessionAgentFilterRef.current = null; filteredSessionsRef.current = []; setSessionAgentFilter(null); @@ -2184,6 +2308,11 @@ export function App() { sessionCollectionState={sessionCollectionState} sessionCollectionError={sessionCollectionError} sessionCollectionHasSnapshot={sessionCollectionHasSnapshot} + runtimeSnapshot={runtimeSnapshot} + runtimeCollectionState={runtimeCollectionState} + runtimeCollectionError={runtimeCollectionError} + runtimeCollectionHasSnapshot={runtimeCollectionHasSnapshot} + loadRuntimeHistory={loadDashboardRuntimeHistory} onRefresh={refreshDashboard} onCreateAgent={openAgentSetup} onStartSession={() => openSessionSetup()} diff --git a/apps/web/src/features/dashboard/DashboardView.css b/apps/web/src/features/dashboard/DashboardView.css index 999874c09..994880b84 100644 --- a/apps/web/src/features/dashboard/DashboardView.css +++ b/apps/web/src/features/dashboard/DashboardView.css @@ -613,10 +613,1468 @@ border-radius: 50%; } +.dashboard-runtime-panel { + margin-top: 16px; + overflow: hidden; +} + +.dashboard-runtime-freshness { + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 10px; + font-variant-numeric: tabular-nums; + line-height: 15px; + text-align: right; +} + +.dashboard-runtime-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-metric { + display: grid; + min-width: 0; + min-height: 106px; + padding: 16px; + grid-template-columns: 32px minmax(0, 1fr); + align-items: flex-start; + gap: 10px; +} + +.dashboard-runtime-metric + .dashboard-runtime-metric { + border-left: 1px solid var(--line); +} + +.dashboard-runtime-metric-icon { + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--line)); + border-radius: 8px; +} + +.dashboard-runtime-metric > span:last-child { + display: grid; + min-width: 0; + gap: 2px; +} + +.dashboard-runtime-metric small, +.dashboard-runtime-metric > span:last-child > span { + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.dashboard-runtime-metric strong { + overflow: hidden; + font-family: var(--font-mono); + font-size: 17px; + font-variant-numeric: tabular-nums slashed-zero; + font-weight: 550; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-visual-grid { + display: grid; + padding: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + background: color-mix(in srgb, var(--surface-subtle) 68%, var(--surface)); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-visual-card { + min-width: 0; + min-height: 248px; + padding: 15px 16px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 9px; + box-shadow: 0 1px 0 color-mix(in srgb, var(--fg) 2%, transparent); +} + +.dashboard-runtime-visual-card > header { + display: flex; + min-height: 37px; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.dashboard-runtime-visual-card h3, +.dashboard-runtime-visual-card p { + margin: 0; +} + +.dashboard-runtime-visual-card h3 { + color: var(--fg); + font-size: 12px; + font-weight: 650; + line-height: 17px; +} + +.dashboard-runtime-visual-card header p, +.dashboard-runtime-visual-card header > span { + color: var(--fg-muted); + font-size: 9px; + line-height: 14px; +} + +.dashboard-runtime-visual-card header > svg { + color: color-mix(in srgb, var(--accent) 78%, var(--fg-muted)); +} + +.dashboard-runtime-donut-layout { + display: grid; + min-height: 174px; + grid-template-columns: minmax(126px, .8fr) minmax(150px, 1.2fr); + align-items: center; + gap: 22px; +} + +.dashboard-runtime-donut { + width: 142px; + max-width: 100%; + position: relative; + justify-self: center; + aspect-ratio: 1; +} + +.dashboard-runtime-donut svg { + width: 100%; + height: 100%; + overflow: visible; +} + +.dashboard-runtime-donut circle { + fill: none; + stroke-width: 10; +} + +.dashboard-runtime-donut-track { + stroke: var(--surface-subtle); +} + +.dashboard-runtime-donut-segment { + transform: rotate(-90deg); + transform-origin: center; + stroke-linecap: butt; +} + +.dashboard-runtime-donut-observed { + stroke: var(--success); +} + +.dashboard-runtime-donut-unavailable { + stroke: var(--warning); +} + +.dashboard-runtime-donut-unsupported { + stroke: color-mix(in srgb, var(--fg-muted) 55%, var(--surface)); +} + +.dashboard-runtime-donut > span { + display: grid; + position: absolute; + inset: 0; + place-content: center; + text-align: center; +} + +.dashboard-runtime-donut strong { + font-family: var(--font-mono); + font-size: 25px; + font-variant-numeric: tabular-nums slashed-zero; + font-weight: 600; + line-height: 29px; +} + +.dashboard-runtime-donut small { + color: var(--fg-muted); + font-size: 9px; +} + +.dashboard-runtime-chart-legend { + display: grid; + gap: 10px; +} + +.dashboard-runtime-chart-legend > div { + display: grid; + grid-template-columns: 7px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + color: var(--fg-muted); + font-size: 10px; +} + +.dashboard-runtime-chart-legend i { + width: 7px; + height: 7px; + background: var(--fg-muted); + border-radius: 50%; +} + +.dashboard-runtime-chart-legend .dashboard-runtime-legend-observed { + background: var(--success); +} + +.dashboard-runtime-chart-legend .dashboard-runtime-legend-unavailable { + background: var(--warning); +} + +.dashboard-runtime-chart-legend .dashboard-runtime-legend-unsupported { + background: color-mix(in srgb, var(--fg-muted) 55%, var(--surface)); +} + +.dashboard-runtime-chart-legend strong { + color: var(--fg); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.dashboard-runtime-resource-meters { + display: grid; + min-height: 174px; + align-content: center; + gap: 25px; +} + +.dashboard-runtime-resource-meter { + display: grid; + gap: 7px; +} + +.dashboard-runtime-resource-meter > div:first-child { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.dashboard-runtime-resource-meter span, +.dashboard-runtime-resource-meter small { + color: var(--fg-muted); + font-size: 9px; + line-height: 14px; +} + +.dashboard-runtime-resource-meter strong { + overflow: hidden; + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums slashed-zero; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-resource-track { + height: 9px; + overflow: hidden; + background: var(--surface-subtle); + border-radius: 999px; +} + +.dashboard-runtime-resource-track > i { + display: block; + height: 100%; + background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 70%, var(--success)), var(--accent)); + border-radius: inherit; +} + +.dashboard-runtime-resource-track > .dashboard-runtime-resource-unknown { + width: 100%; + background: repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--fg-muted) 18%, transparent) 5px 8px); +} + +.dashboard-runtime-ranked-bars { + display: grid; + min-height: 174px; + padding-top: 11px; + align-content: center; + gap: 11px; +} + +.dashboard-runtime-ranked-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(90px, 1.1fr); + align-items: center; + gap: 3px 12px; +} + +.dashboard-runtime-ranked-row > div:first-child { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.dashboard-runtime-ranked-row span, +.dashboard-runtime-ranked-row strong, +.dashboard-runtime-ranked-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-ranked-row span { + font-size: 10px; + font-weight: 550; +} + +.dashboard-runtime-ranked-row strong { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.dashboard-runtime-ranked-row small { + grid-column: 1; + color: var(--fg-muted); + font-size: 8px; + line-height: 11px; +} + +.dashboard-runtime-ranked-track { + height: 7px; + overflow: hidden; + background: var(--surface-subtle); + border-radius: 999px; +} + +.dashboard-runtime-ranked-track > i { + display: block; + height: 100%; + background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 70%, var(--success)), var(--accent)); + border-radius: inherit; +} + +.dashboard-runtime-ranked-tokens .dashboard-runtime-ranked-track > i { + background: linear-gradient(90deg, color-mix(in srgb, var(--warning) 78%, var(--accent)), var(--warning)); +} + +.dashboard-runtime-chart-empty { + display: flex; + min-height: 174px; + align-items: center; + justify-content: center; + margin: 0; + padding: 20px; + color: var(--fg-muted); + font-size: 10px; + line-height: 15px; + text-align: center; + background: repeating-linear-gradient(135deg, transparent 0 8px, color-mix(in srgb, var(--fg-muted) 3%, transparent) 8px 9px); + border-radius: 6px; +} + +.dashboard-runtime-live { + min-width: 0; + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-live-toolbar { + display: flex; + min-height: 54px; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + gap: 16px; + background: var(--surface); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-live-toolbar h3, +.dashboard-runtime-live-toolbar p { + margin: 0; +} + +.dashboard-runtime-live-controls { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.dashboard-runtime-live-status, +.dashboard-runtime-sample-count { + display: inline-flex; + align-items: center; + white-space: nowrap; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.dashboard-runtime-live-status { + gap: 6px; + color: var(--success); + font-weight: 650; +} + +.dashboard-runtime-live-status-stale { + color: var(--warning); +} + +.dashboard-runtime-live-status i { + width: 6px; + height: 6px; + background: currentColor; + border-radius: 999px; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent); + animation: dashboard-runtime-live-pulse 2.4s ease-out infinite; +} + +.dashboard-runtime-live-status-stale i { + animation: none; +} + +.dashboard-runtime-live-status-durable i { + animation: none; +} + +@keyframes dashboard-runtime-live-pulse { + 0%, 55% { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent); } + 80%, 100% { box-shadow: 0 0 0 7px transparent; } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard-runtime-live-status i { + animation: none; + } +} + +.dashboard-runtime-live-toolbar h3 { + font-size: 12px; + font-weight: 650; + line-height: 17px; +} + +.dashboard-runtime-live-toolbar p { + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; + line-height: 14px; +} + +.dashboard-runtime-range, +.dashboard-runtime-source { + display: inline-flex; + flex: 0 0 auto; + padding: 2px; + gap: 2px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 7px; +} + +.dashboard-runtime-range button, +.dashboard-runtime-source button { + min-width: 38px; + padding: 4px 8px; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; + background: transparent; + border: 0; + border-radius: 5px; + cursor: pointer; +} + +.dashboard-runtime-range button:hover, +.dashboard-runtime-range button:focus-visible, +.dashboard-runtime-source button:hover, +.dashboard-runtime-source button:focus-visible { + color: var(--fg); +} + +.dashboard-runtime-range button:focus-visible, +.dashboard-runtime-source button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); + outline-offset: 1px; +} + +.dashboard-runtime-range button[aria-pressed="true"], +.dashboard-runtime-source button[aria-pressed="true"] { + color: var(--fg); + background: var(--surface); + box-shadow: var(--shadow-control); +} + +.dashboard-runtime-source button:disabled { + cursor: wait; + opacity: .48; +} + +.dashboard-runtime-history-note, +.dashboard-runtime-history-error { + margin: 0; + padding: 7px 14px; + color: var(--fg-muted); + font-size: 9px; + line-height: 14px; + background: color-mix(in srgb, var(--surface-subtle) 72%, var(--surface)); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-history-error { + color: var(--warning); +} + +.dashboard-runtime-trend-grid { + display: grid; + padding: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + background: color-mix(in srgb, var(--surface-subtle) 68%, var(--surface)); +} + +.dashboard-runtime-trend-card { + min-width: 0; + padding: 14px 15px 10px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 9px; +} + +.dashboard-runtime-trend-card > header { + display: flex; + min-height: 48px; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.dashboard-runtime-trend-card h3, +.dashboard-runtime-trend-card p { + margin: 0; +} + +.dashboard-runtime-trend-card h3 { + font-size: 12px; + font-weight: 650; + line-height: 17px; +} + +.dashboard-runtime-trend-card p { + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; + line-height: 14px; +} + +.dashboard-runtime-trend-legend { + display: flex; + max-width: 58%; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px 10px; + color: var(--fg-muted); + font-size: 9px; +} + +.dashboard-runtime-trend-legend button { + display: inline-flex; + max-width: 104px; + min-width: 0; + align-items: center; + padding: 2px 4px; + gap: 5px; + overflow: hidden; + color: inherit; + font: inherit; + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-trend-legend button:hover, +.dashboard-runtime-trend-legend button:focus-visible { + color: var(--fg); + background: var(--surface-subtle); +} + +.dashboard-runtime-trend-legend button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 1px; +} + +.dashboard-runtime-trend-legend button.is-hidden { + opacity: .42; + text-decoration: line-through; +} + +.dashboard-runtime-trend-legend .dashboard-runtime-chart-reset { + padding: 2px 6px; + color: var(--accent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); +} + +.dashboard-runtime-trend-legend .dashboard-runtime-chart-reset:disabled { + color: var(--fg-muted); + cursor: default; + opacity: .42; +} + +.dashboard-runtime-trend-legend i, +.dashboard-runtime-trend-tooltip i { + width: 11px; + height: 2px; + flex: 0 0 auto; + background: var(--fg-muted); +} + +.dashboard-runtime-trend-orange, +.dashboard-runtime-trend-stroke-orange { + color: #f59e52; + stroke: #f59e52; + background: #f59e52 !important; +} + +.dashboard-runtime-trend-green, +.dashboard-runtime-trend-stroke-green { + color: #50d5a0; + stroke: #50d5a0; + background: #50d5a0 !important; +} + +.dashboard-runtime-trend-blue, +.dashboard-runtime-trend-stroke-blue { + color: #78a7ff; + stroke: #78a7ff; + background: #78a7ff !important; +} + +.dashboard-runtime-trend-purple, +.dashboard-runtime-trend-stroke-purple { + color: #b998f4; + stroke: #b998f4; + background: #b998f4 !important; +} + +.dashboard-runtime-chart-frame { + min-height: 220px; + position: relative; +} + +.dashboard-runtime-chart-frame svg { + display: block; + width: 100%; + height: 220px; + overflow: visible; + cursor: crosshair; + touch-action: pan-y; +} + +.dashboard-runtime-chart-frame svg:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent); + outline-offset: 2px; + border-radius: 4px; +} + +.dashboard-runtime-uplot { + width: 100%; + height: 220px; + color: var(--fg-muted); + outline: none; + touch-action: pan-y; +} + +.dashboard-runtime-uplot:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent); + outline-offset: 2px; + border-radius: 4px; +} + +.dashboard-runtime-uplot .uplot { + width: 100% !important; +} + +.dashboard-runtime-uplot .u-over { + cursor: crosshair; + touch-action: pan-y; +} + +.dashboard-runtime-uplot .u-select { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-right: 1px dashed color-mix(in srgb, var(--accent) 78%, transparent); + border-left: 1px dashed color-mix(in srgb, var(--accent) 78%, transparent); +} + +.dashboard-runtime-uplot .u-cursor-x, +.dashboard-runtime-uplot .u-cursor-y { + border-color: color-mix(in srgb, var(--fg) 48%, transparent); +} + +.dashboard-runtime-trend-gridline { + stroke: color-mix(in srgb, var(--line) 88%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.dashboard-runtime-trend-axis { + fill: var(--fg-muted); + font-family: var(--font-mono); + font-size: 8px; +} + +.dashboard-runtime-trend-band { + opacity: .07; +} + +.dashboard-runtime-trend-band-safe { + fill: var(--success); +} + +.dashboard-runtime-trend-band-warning { + fill: var(--warning); +} + +.dashboard-runtime-trend-band-danger { + fill: var(--danger); +} + +.dashboard-runtime-trend-line { + fill: none; + stroke-width: 2.25; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.dashboard-runtime-trend-area { + stroke: none; + opacity: .14; +} + +.dashboard-runtime-trend-fill-purple { + fill: #b998f4; +} + +.dashboard-runtime-trend-latest { + fill: var(--surface); + stroke-width: 1.75; + vector-effect: non-scaling-stroke; +} + +.dashboard-runtime-trend-dot { + fill: currentColor; + stroke-width: 0; +} + +.dashboard-runtime-trend-crosshair { + stroke: color-mix(in srgb, var(--fg) 48%, transparent); + stroke-width: 1; + stroke-dasharray: 3 3; + vector-effect: non-scaling-stroke; +} + +.dashboard-runtime-trend-selected { + fill: var(--surface); + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.dashboard-runtime-trend-tooltip { + display: grid; + width: min(190px, calc(100% - 24px)); + padding: 8px 9px; + position: absolute; + z-index: 2; + top: 12px; + gap: 4px; + color: var(--fg); + background: color-mix(in srgb, var(--surface) 94%, transparent); + border: 1px solid var(--line-strong, var(--line)); + border-radius: 7px; + box-shadow: var(--shadow-popover, var(--shadow-control)); + pointer-events: none; + transform: translateX(-50%); + backdrop-filter: blur(8px); +} + +.dashboard-runtime-trend-tooltip.is-pinned { + border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); +} + +.dashboard-runtime-trend-tooltip header, +.dashboard-runtime-trend-tooltip > div { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.dashboard-runtime-trend-tooltip header { + justify-content: space-between; + padding-bottom: 4px; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 8px; + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-trend-tooltip header span { + color: var(--accent); + text-transform: uppercase; + letter-spacing: .04em; +} + +.dashboard-runtime-trend-tooltip > div { + font-size: 9px; +} + +.dashboard-runtime-trend-tooltip > div span { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + color: var(--fg-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-trend-tooltip > div strong { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.dashboard-runtime-chart-collecting { + display: grid; + padding: 9px 12px; + position: absolute; + right: 18px; + bottom: 29px; + gap: 1px; + color: var(--fg); + background: color-mix(in srgb, var(--surface) 91%, transparent); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: var(--shadow-control); + pointer-events: none; +} + +.dashboard-runtime-chart-collecting strong { + font-size: 9px; + line-height: 13px; +} + +.dashboard-runtime-chart-collecting span { + color: var(--fg-muted); + font-size: 8px; + line-height: 12px; +} + +.dashboard-runtime-trend-accessible { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.dashboard-runtime-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + + +.dashboard-runtime-explorer { + min-width: 0; +} + +.dashboard-runtime-explorer > summary { + display: flex; + min-height: 62px; + align-items: center; + justify-content: space-between; + padding: 11px 14px; + gap: 16px; + cursor: pointer; + list-style: none; + border-bottom: 1px solid transparent; +} + +.dashboard-runtime-explorer > summary::-webkit-details-marker { + display: none; +} + +.dashboard-runtime-explorer[open] > summary { + background: var(--surface-subtle); + border-bottom-color: var(--line); +} + +.dashboard-runtime-explorer > summary > span { + display: grid; + min-width: 0; + gap: 2px; +} + +.dashboard-runtime-explorer > summary > span:last-child { + display: flex; + flex: 0 0 auto; + grid-auto-flow: column; + align-items: center; + gap: 6px; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.dashboard-runtime-explorer > summary strong { + font-size: 12px; + line-height: 17px; +} + +.dashboard-runtime-explorer > summary small { + overflow: hidden; + color: var(--fg-muted); + font-size: 9px; + line-height: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-explorer > summary svg { + transition: transform 160ms var(--ease-settle); +} + +.dashboard-runtime-explorer[open] > summary svg { + transform: rotate(180deg); +} + +.dashboard-runtime-insights { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-insight { + min-width: 0; + padding: 14px; +} + +.dashboard-runtime-insight + .dashboard-runtime-insight { + border-left: 1px solid var(--line); +} + +.dashboard-runtime-insight > header, +.dashboard-runtime-targets > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.dashboard-runtime-insight h3, +.dashboard-runtime-targets h3, +.dashboard-runtime-insight p, +.dashboard-runtime-targets p { + margin: 0; +} + +.dashboard-runtime-insight h3, +.dashboard-runtime-targets h3 { + font-size: 12px; + font-weight: 600; + line-height: 17px; +} + +.dashboard-runtime-insight header p, +.dashboard-runtime-targets header p, +.dashboard-runtime-insight header > span, +.dashboard-runtime-targets header > span { + color: var(--fg-muted); + font-size: 9px; + line-height: 13px; +} + +.dashboard-runtime-insight header > span, +.dashboard-runtime-targets header > span { + flex: 0 0 auto; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.dashboard-runtime-health-grid { + display: grid; + margin-top: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.dashboard-runtime-health { + display: grid; + min-width: 0; + padding: 8px 9px 8px 19px; + position: relative; + gap: 1px; + color: var(--fg); + text-align: left; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.dashboard-runtime-health::before { + width: 6px; + height: 6px; + position: absolute; + top: 12px; + left: 8px; + content: ""; + background: var(--fg-muted); + border-radius: 50%; +} + +.dashboard-runtime-health-observed::before { + background: var(--success); +} + +.dashboard-runtime-health-unavailable::before { + background: var(--warning); +} + +.dashboard-runtime-health:hover, +.dashboard-runtime-health:focus-visible { + background: var(--hover); + border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); +} + +.dashboard-runtime-health strong, +.dashboard-runtime-health span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-health strong { + font-size: 10px; + font-weight: 550; + line-height: 14px; +} + +.dashboard-runtime-health span, +.dashboard-runtime-health-more { + color: var(--fg-muted); + font-size: 9px; + line-height: 13px; +} + +.dashboard-runtime-health-more { + align-self: center; + padding-left: 4px; +} + +.dashboard-runtime-coverage-grid { + display: grid; + margin-top: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.dashboard-runtime-coverage-grid > div { + display: grid; + min-width: 0; + padding: 9px 10px; + gap: 1px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.dashboard-runtime-coverage-grid strong { + font-family: var(--font-mono); + font-size: 10px; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 14px; +} + +.dashboard-runtime-coverage-grid span { + color: var(--fg-muted); + font-size: 9px; + line-height: 13px; +} + +.dashboard-runtime-coverage-warning strong { + color: var(--warning); +} + +.dashboard-runtime-coverage-unsupported strong { + color: var(--fg-muted); +} + +.dashboard-runtime-targets { + min-width: 0; +} + +.dashboard-runtime-targets > header { + min-height: 58px; + align-items: center; + padding: 11px 14px; + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-toolbar { + display: flex; + min-height: 48px; + align-items: center; + padding: 8px 14px; + gap: 8px; + background: var(--surface-subtle); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-toolbar label { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); + font-size: 9px; +} + +.dashboard-runtime-toolbar input, +.dashboard-runtime-toolbar select { + height: 30px; + color: var(--fg); + font: inherit; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; +} + +.dashboard-runtime-search { + width: min(360px, 100%); + height: 30px; + margin-right: auto; + padding: 0 8px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; +} + +.dashboard-runtime-search input { + width: 100%; + min-width: 0; + height: auto; + padding: 0; + background: transparent; + border: 0; + outline: 0; +} + +.dashboard-runtime-toolbar select { + min-width: 112px; + padding: 0 24px 0 8px; +} + +.dashboard-runtime-visible-count { + flex: 0 0 auto; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.dashboard-runtime-table-scroll { + overflow-x: auto; +} + +.dashboard-runtime-table { + width: 100%; + min-width: 1080px; + border-collapse: collapse; + font-size: 10px; + line-height: 14px; +} + +.dashboard-runtime-table th { + height: 34px; + padding: 0 10px; + color: var(--fg-muted); + font-size: 9px; + font-weight: 600; + text-align: left; + background: var(--surface-subtle); + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-table th button { + display: inline-flex; + align-items: center; + padding: 0; + gap: 4px; + color: inherit; + font: inherit; + background: transparent; + border: 0; +} + +.dashboard-runtime-table td { + height: 68px; + padding: 9px 10px; + vertical-align: middle; + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-table tbody tr:hover { + background: var(--hover); +} + +.dashboard-runtime-table th:first-child, +.dashboard-runtime-table td:first-child { + width: 220px; + padding-left: 14px; +} + +.dashboard-runtime-target-identity, +.dashboard-runtime-table-value { + display: grid; + min-width: 0; + gap: 2px; +} + +.dashboard-runtime-target-identity { + position: relative; +} + +.dashboard-runtime-target-identity > button { + width: fit-content; + max-width: 100%; + padding: 0; + overflow: hidden; + color: var(--fg); + font: inherit; + font-weight: 600; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; +} + +.dashboard-runtime-target-identity > button:hover, +.dashboard-runtime-target-identity > button:focus-visible { + color: var(--accent); +} + +.dashboard-runtime-target-identity > small, +.dashboard-runtime-table-value small { + overflow: hidden; + color: var(--fg-muted); + font-size: 9px; + line-height: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-target-identity details { + color: var(--fg-muted); + font-size: 9px; +} + +.dashboard-runtime-target-identity summary { + width: fit-content; + cursor: pointer; +} + +.dashboard-runtime-target-identity dl { + display: grid; + width: 320px; + margin: 6px 0 0; + padding: 8px; + position: absolute; + z-index: 2; + gap: 4px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: var(--shadow-control); +} + +.dashboard-runtime-target-identity dl > div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); +} + +.dashboard-runtime-target-identity dt, +.dashboard-runtime-target-identity dd { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-target-identity dd { + color: var(--fg); + font-family: var(--font-mono); +} + +.dashboard-runtime-status { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + font-size: 10px; + font-weight: 550; + line-height: 15px; +} + +.dashboard-runtime-status > span { + width: 7px; + height: 7px; + background: var(--fg-muted); + border-radius: 50%; +} + +.dashboard-runtime-status-observed > span { + background: var(--success); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 11%, transparent); +} + +.dashboard-runtime-status-unavailable { + color: var(--warning); +} + +.dashboard-runtime-status-unavailable > span { + background: var(--warning); +} + +.dashboard-runtime-status-unsupported { + color: var(--fg-muted); +} + +.dashboard-runtime-table-value strong { + overflow: hidden; + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums slashed-zero; + font-weight: 550; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-runtime-bar { + display: block; + width: 100%; + max-width: 110px; + height: 3px; + margin-top: 2px; + overflow: hidden; + background: var(--surface-subtle); + border-radius: 999px; +} + +.dashboard-runtime-bar i { + display: block; + height: 100%; + background: var(--accent); + border-radius: inherit; +} + +.dashboard-runtime-no-results { + margin: 0; + padding: 20px 14px; + color: var(--fg-muted); + font-size: 10px; + text-align: center; + border-bottom: 1px solid var(--line); +} + +.dashboard-runtime-pagination { + display: flex; + min-height: 46px; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + color: var(--fg-muted); + font-family: var(--font-mono); + font-size: 9px; +} + +.dashboard-runtime-pagination > div { + display: flex; + gap: 6px; +} + +.dashboard-runtime-pagination button { + display: inline-flex; + min-height: 28px; + align-items: center; + padding: 4px 8px; + gap: 4px; + color: var(--fg); + font: inherit; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 5px; +} + +.dashboard-runtime-pagination button:disabled { + cursor: not-allowed; + opacity: .45; +} + @media (max-width: 980px) { .dashboard-primary-grid { grid-template-columns: 1fr; } + + .dashboard-runtime-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-runtime-metric:nth-child(3) { + border-left: 0; + } + + .dashboard-runtime-metric:nth-child(n + 3) { + border-top: 1px solid var(--line); + } + + .dashboard-runtime-insights { + grid-template-columns: 1fr; + } + + .dashboard-runtime-insight + .dashboard-runtime-insight { + border-top: 1px solid var(--line); + border-left: 0; + } } @media (max-width: 760px) { @@ -670,6 +2128,40 @@ .dashboard-metric:nth-child(n + 3) { border-top: 1px solid var(--line); } + + .dashboard-runtime-toolbar { + align-items: stretch; + flex-wrap: wrap; + } + + .dashboard-runtime-visual-grid { + grid-template-columns: 1fr; + } + + .dashboard-runtime-trend-grid { + grid-template-columns: 1fr; + } + + .dashboard-runtime-search { + width: 100%; + flex-basis: 100%; + margin-right: 0; + } + + .dashboard-runtime-toolbar label:not(.dashboard-runtime-search) { + flex: 1; + } + + .dashboard-runtime-toolbar select { + min-width: 0; + flex: 1; + } + + .dashboard-runtime-visible-count { + display: flex; + width: 100%; + justify-content: flex-end; + } } @media (max-width: 520px) { @@ -701,6 +2193,91 @@ align-items: flex-start; } + .dashboard-runtime-summary { + grid-template-columns: 1fr; + } + + .dashboard-runtime-metric + .dashboard-runtime-metric, + .dashboard-runtime-metric:nth-child(3) { + border-top: 1px solid var(--line); + border-left: 0; + } + + .dashboard-runtime-health-grid, + .dashboard-runtime-coverage-grid { + grid-template-columns: 1fr; + } + + .dashboard-runtime-visual-grid { + padding: 10px; + gap: 10px; + } + + .dashboard-runtime-trend-grid { + padding: 10px; + gap: 10px; + } + + .dashboard-runtime-trend-card > header { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } + + .dashboard-runtime-live-toolbar { + align-items: flex-start; + flex-direction: column; + gap: 7px; + } + + .dashboard-runtime-live-controls { + width: 100%; + flex-wrap: wrap; + justify-content: flex-start; + } + + .dashboard-runtime-range { + margin-left: auto; + } + + .dashboard-runtime-source { + order: -1; + } + + .dashboard-runtime-trend-legend { + max-width: 100%; + justify-content: flex-start; + } + + .dashboard-runtime-visual-card { + min-height: 224px; + } + + .dashboard-runtime-donut-layout { + grid-template-columns: 118px minmax(130px, 1fr); + gap: 14px; + } + + .dashboard-runtime-donut { + width: 118px; + } + + .dashboard-runtime-ranked-row { + grid-template-columns: minmax(0, 1fr) minmax(70px, .8fr); + } + + .dashboard-runtime-explorer > summary { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .dashboard-runtime-targets > header { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .dashboard-panel header p { white-space: normal; } diff --git a/apps/web/src/features/dashboard/DashboardView.test.tsx b/apps/web/src/features/dashboard/DashboardView.test.tsx index f7c617ba4..3cb3e67a7 100644 --- a/apps/web/src/features/dashboard/DashboardView.test.tsx +++ b/apps/web/src/features/dashboard/DashboardView.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { AgentSession, SavedAgent } from "@agents-core-web/agents-client"; +import type { AgentSession, RuntimeObservation, SavedAgent } from "@agents-core-web/agents-client"; import { DashboardView, type DashboardViewProps } from "./DashboardView"; @@ -54,6 +54,7 @@ function session(id: string, overrides: Partial = {}): AgentSessio } const callbacks = { + loadRuntimeHistory: async () => null, onRefresh: () => undefined, onCreateAgent: () => undefined, onStartSession: () => undefined, @@ -74,6 +75,10 @@ function render(overrides: Partial = {}): string { sessionCollectionState="ready" sessionCollectionError={null} sessionCollectionHasSnapshot + runtimeSnapshot={{ sessions: [], observations: [], loadedAt: 1_700_000_000_000 }} + runtimeCollectionState="ready" + runtimeCollectionError={null} + runtimeCollectionHasSnapshot {...callbacks} {...overrides} />, @@ -121,6 +126,21 @@ describe("Dashboard loaded-result presentation", () => { expect(html).not.toContain("Sessions: Agent core request failed (502)"); }); + it("keeps a Runtime-only 503 scoped to the optional observation feature", () => { + const html = render({ + runtimeSnapshot: null, + runtimeCollectionState: "failed", + runtimeCollectionError: "Agent core request failed (503).", + runtimeCollectionHasSnapshot: false, + }); + + expect(html).toContain("Runtime: Agent core request failed (503)."); + expect(html).toContain("Runtime observations unavailable"); + expect(html).not.toContain("Agent Core backend is not ready"); + expect(html).not.toContain("Core backend is offline"); + expect(html).not.toContain("Open startup guide"); + }); + it("renders a compact actionable overview while preserving Environment qualifications", () => { const selfHosted: AgentSession["environment"] = { type: "self_hosted", @@ -174,6 +194,89 @@ describe("Dashboard loaded-result presentation", () => { expect(html).not.toContain("Execution ready"); }); + it("renders current Docker resources without inventing a CPU percentage or history", () => { + const hosted = session("11111111-1111-4111-8111-111111111111", { + metadata: { title: "Managed research" }, + environment: { + type: "openai_hosted", + id: "22222222-2222-4222-8222-222222222222", + capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }, + usage: { + input_tokens: 30, + output_tokens: 12, + total_tokens: 42, + input_tokens_details: { cached_tokens: 7 }, + output_tokens_details: { reasoning_tokens: 3 }, + }, + }); + const observation: RuntimeObservation = { + id: hosted.id, + object: "agent.runtime_observation", + session_id: hosted.id, + environment_id: "22222222-2222-4222-8222-222222222222", + mode: "openai_hosted", + provider_type: "docker", + instance: { + kind: "managed_allocation", + allocation_id: "33333333-3333-4333-8333-333333333333", + device_id: null, + connection_generation: null, + }, + status: "observed", + reason: null, + allocation_created_at: 1_700_000_000, + resolved_at: 1_700_000_100, + observed_at: 1_700_000_090, + started_at: 1_700_000_010, + cpu: { usage_seconds_total: 73.5, capacity_cores: 2, usage_cores: 3, utilization_ratio: 1.5 }, + memory: { usage_bytes: 536_870_912, limit_bytes: 2_147_483_648 }, + }; + const html = render({ + runtimeSnapshot: { sessions: [hosted], observations: [observation], loadedAt: 1_700_000_100_000 }, + }); + + expect(html).toContain("Runtime monitoring"); + expect(html).toContain("1/1 managed observed"); + expect(html).toContain("Cumulative CPU / capacity"); + expect(html).toContain("1m 13s / 2 cores"); + expect(html).toContain("512 MiB / 2.00 GiB"); + expect(html).toContain('aria-label="Runtime durable-history charts"'); + expect(html).toContain("Resource trends"); + expect(html).toContain("Retained samples · durable history"); + expect(html).not.toContain('aria-label="Runtime trend source"'); + expect(html).toContain("History · loading"); + expect(html).toContain("0 buckets"); + expect(html).toContain('aria-label="Runtime durable range"'); + expect(html).toContain('aria-pressed="true">1h'); + expect(html).toContain("CPU usage"); + expect(html).toContain("Memory usage"); + expect(html).not.toContain("Compute uptime"); + expect(html).toContain("Token throughput"); + expect(html).toContain("No retained CPU samples"); + expect(html).toContain("0/2 valid points · 0 snapshots · no history is synthesized"); + expect(html).toContain("Latest value"); + expect(html).toContain("Missing samples"); + expect(html).toContain("Runtime targets"); + expect(html).toContain('
'); + expect(html).toContain("Search Runtime targets"); + expect(html).toContain("All statuses"); + expect(html).toContain("All modes"); + expect(html).toContain(''); + expect(html).toContain("CPU time"); + expect(html).toContain("Managed research"); + expect(html).toContain("Identity"); + expect(html).toContain("unknown remains unknown, never zero"); + expect(html).not.toContain("CPU %"); + expect(html).not.toContain("CPU now"); + expect(html).not.toContain("historical chart"); + }); + it("keeps partial Usage out of the primary overview", () => { const html = render({ sessions: [session("usage-unknown")] }); @@ -219,6 +322,48 @@ describe("Dashboard loaded-result presentation", () => { expect(html).toContain("In this snapshot"); }); + it("does not present retained Runtime samples as live after a refresh failure", () => { + const runtimeSession = session("runtime-stale", { + environment: { + type: "openai_hosted", + id: "22222222-2222-4222-8222-222222222222", + capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }, + }); + const observation: RuntimeObservation = { + id: runtimeSession.id, + object: "agent.runtime_observation", + session_id: runtimeSession.id, + environment_id: "22222222-2222-4222-8222-222222222222", + mode: "openai_hosted", + provider_type: "docker", + instance: { kind: "managed_allocation", allocation_id: "33333333-3333-4333-8333-333333333333", device_id: null, connection_generation: null }, + status: "observed", + reason: null, + allocation_created_at: 1_700_000_000, + resolved_at: 1_700_000_100, + observed_at: 1_700_000_090, + started_at: 1_700_000_010, + cpu: null, + memory: null, + }; + const html = render({ + runtimeSnapshot: { sessions: [runtimeSession], observations: [observation], loadedAt: 1_700_000_100_000 }, + runtimeCollectionState: "failed", + runtimeCollectionError: "Runtime refresh failed", + runtimeCollectionHasSnapshot: true, + }); + + expect(html).toContain("Runtime: Runtime refresh failed"); + expect(html).toContain("History · loading"); + expect(html).not.toContain("Live · 30s"); + }); + it("keeps an existing snapshot visible during a refresh and disables duplicate refresh", () => { const html = render({ agents: [agent()], diff --git a/apps/web/src/features/dashboard/DashboardView.tsx b/apps/web/src/features/dashboard/DashboardView.tsx index c2cd9786d..de2637444 100644 --- a/apps/web/src/features/dashboard/DashboardView.tsx +++ b/apps/web/src/features/dashboard/DashboardView.tsx @@ -14,12 +14,15 @@ import { StatusIcon, type StatusKind } from "../../components/StatusIcon"; import { backendFailureStatus } from "../../lib/core-readiness"; import { buildDashboardSnapshot, + buildRuntimeDashboardModel, dashboardEnvironmentLabel, dashboardStatusLabel, formatDashboardTimestamp, type DashboardCollectionState, type DashboardSessionRow, } from "./dashboard-model"; +import { RuntimeObservabilityContent, type RuntimeHistoryLoader } from "./RuntimeObservabilityContent"; +import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import "./DashboardView.css"; export interface DashboardViewProps { @@ -31,6 +34,11 @@ export interface DashboardViewProps { sessionCollectionState: DashboardCollectionState; sessionCollectionError: string | null; sessionCollectionHasSnapshot: boolean; + runtimeSnapshot: RuntimeDashboardSnapshot | null; + runtimeCollectionState: DashboardCollectionState; + runtimeCollectionError: string | null; + runtimeCollectionHasSnapshot: boolean; + loadRuntimeHistory: RuntimeHistoryLoader; onRefresh: () => void; onCreateAgent: () => void; onStartSession: () => void; @@ -232,6 +240,11 @@ export function DashboardView({ sessionCollectionState, sessionCollectionError, sessionCollectionHasSnapshot, + runtimeSnapshot, + runtimeCollectionState, + runtimeCollectionError, + runtimeCollectionHasSnapshot, + loadRuntimeHistory, onRefresh, onCreateAgent, onStartSession, @@ -241,21 +254,28 @@ export function DashboardView({ onOpenSession, }: DashboardViewProps) { const snapshot = useMemo(() => buildDashboardSnapshot(agents, sessions, 6, 5), [agents, sessions]); + const runtimeModel = useMemo(() => runtimeSnapshot + ? buildRuntimeDashboardModel(runtimeSnapshot.sessions, runtimeSnapshot.observations) + : null, [runtimeSnapshot]); const agentsAvailable = collectionHasSnapshot(agentCollectionState, agentCollectionHasSnapshot); const sessionsAvailable = collectionHasSnapshot(sessionCollectionState, sessionCollectionHasSnapshot); - const refreshing = agentCollectionState === "connecting" || sessionCollectionState === "connecting"; + const runtimeAvailable = runtimeCollectionState === "ready" || runtimeCollectionHasSnapshot; + const refreshing = agentCollectionState === "connecting" || sessionCollectionState === "connecting" || runtimeCollectionState === "connecting"; const hasStaleSnapshot = ( (agentCollectionState === "failed" && agentCollectionHasSnapshot) || - (sessionCollectionState === "failed" && sessionCollectionHasSnapshot) + (sessionCollectionState === "failed" && sessionCollectionHasSnapshot) || + (runtimeCollectionState === "failed" && runtimeCollectionHasSnapshot) ); - const hasUnavailableSource = !agentsAvailable || !sessionsAvailable; + const hasUnavailableSource = !agentsAvailable || !sessionsAvailable || !runtimeAvailable; const attentionCount = snapshot.statusCounts.requires_action + snapshot.statusCounts.failed; - const sourceErrors: Array = [ + const sourceErrors: Array = [ agentCollectionState === "failed" ? ["Agents", agentCollectionError] as const : null, sessionCollectionState === "failed" ? ["Sessions", sessionCollectionError] as const : null, - ].filter((entry): entry is readonly ["Agents" | "Sessions", string | null] => entry !== null); - const backendFailureStatuses = sourceErrors.map(([, error]) => backendFailureStatus(error)); - const backendUnavailable = sourceErrors.length > 0 && backendFailureStatuses.every(Boolean); + runtimeCollectionState === "failed" ? ["Runtime", runtimeCollectionError] as const : null, + ].filter((entry): entry is readonly ["Agents" | "Sessions" | "Runtime", string | null] => entry !== null); + const coreSourceErrors = sourceErrors.filter(([label]) => label !== "Runtime"); + const backendFailureStatuses = coreSourceErrors.map(([, error]) => backendFailureStatus(error)); + const backendUnavailable = coreSourceErrors.length > 0 && backendFailureStatuses.every(Boolean); const backendFailureDetail = Array.from(new Set(backendFailureStatuses.filter(Boolean))).map((status) => ( status === "network" ? "network failure" : `HTTP ${status}` )).join(" / "); @@ -304,6 +324,7 @@ export function DashboardView({
+
@@ -359,6 +380,46 @@ export function DashboardView({ +
+
+
+

Runtime monitoring

+

Current provider status · retained metrics history

+
+ {runtimeModel ? ( + + {runtimeModel.summary.observedRuntimeCount}/{runtimeModel.summary.managedRuntimeCount} managed observed + {runtimeModel.summary.newestResolvedAt === null ? "" : ` · ${formatDashboardTimestamp(runtimeModel.summary.newestResolvedAt)}`} + + ) : null} +
+ {!runtimeAvailable || !runtimeSnapshot || !runtimeModel ? ( +

+

+ ) : ( + <> + {runtimeModel.rows.length ? ( + + ) : ( +

No Session-owned Runtime contexts in this snapshot.

+ )} + + )} +
+
diff --git a/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx b/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx new file mode 100644 index 000000000..b11424475 --- /dev/null +++ b/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx @@ -0,0 +1,435 @@ +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + ChevronUp, + Cpu, + Gauge, + MemoryStick, + Search, + Server, +} from "lucide-react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type FilterFn, + type SortingState, +} from "@tanstack/react-table"; + +import { + buildRuntimeDashboardModel, + dashboardEnvironmentLabel, + dashboardStatusLabel, + formatDashboardBytes, + formatDashboardDuration, + formatDashboardTimestamp, + formatDashboardTokens, + runtimeObservationStatusLabel, + type RuntimeDashboardRow, +} from "./dashboard-model"; +import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; +import { RUNTIME_SNAPSHOT_REFRESH_MS } from "./runtime-snapshot"; +import { RuntimeTrendCharts } from "./RuntimeTrendCharts"; +import type { RuntimeTrendSource } from "./RuntimeTrendCharts"; +import { + RUNTIME_DURABLE_RANGES, + type RuntimeDurableRange, + type RuntimeDurableSnapshot, +} from "./runtime-history"; +import { + appendRuntimeTrendSample, + RUNTIME_TREND_RANGES, + RUNTIME_TREND_WINDOW_MS, + runtimeTrendRange, + type RuntimeTrendRange, + type RuntimeTrendSample, +} from "./runtime-trends"; + +const PAGE_SIZE = 10; + +export type RuntimeHistoryLoader = ( + snapshot: RuntimeDashboardSnapshot, + range: RuntimeDurableRange, + signal: AbortSignal, +) => Promise; + +function RuntimeMetric({ + icon, + label, + value, + detail, +}: { + icon: ReactNode; + label: string; + value: string; + detail: string; +}) { + return ( +
+ + + {label} + {value} + {detail} + +
+ ); +} + +function percent(usage: number | null | undefined, limit: number | null | undefined): number | null { + if (typeof usage !== "number" || typeof limit !== "number" || limit <= 0) return null; + return Math.min(100, Math.max(0, usage / limit * 100)); +} + +function runtimeModeLabel(row: RuntimeDashboardRow): string { + if (row.observation.mode === "openai_hosted") { + const provider = row.observation.provider_type; + return provider ? `Managed ${provider === "docker" ? "Docker" : provider}` : "Managed"; + } + return dashboardEnvironmentLabel(row.session.environmentProfile); +} + +function SortHeader({ + label, + sorted, + onClick, +}: { + label: string; + sorted: false | "asc" | "desc"; + onClick: (event: unknown) => void; +}) { + const Icon = sorted === "asc" ? ChevronUp : sorted === "desc" ? ChevronDown : ChevronsUpDown; + return ( + + ); +} + +const runtimeGlobalFilter: FilterFn = (row, _columnId, value) => { + const query = String(value).trim().toLocaleLowerCase(); + if (!query) return true; + const item = row.original; + return [ + item.session.title, + item.session.agentLabel, + item.observation.session_id, + item.observation.environment_id, + item.observation.instance.allocation_id, + item.observation.provider_type, + item.observation.status, + item.observation.reason, + runtimeModeLabel(item), + ].some((candidate) => typeof candidate === "string" && candidate.toLocaleLowerCase().includes(query)); +}; + +function RuntimeTargets({ + rows, + onOpenSession, +}: { + rows: RuntimeDashboardRow[]; + onOpenSession: (sessionId: string) => void; +}) { + const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [modeFilter, setModeFilter] = useState("all"); + const filteredRows = useMemo(() => rows.filter((row) => ( + (statusFilter === "all" || row.observation.status === statusFilter) && + (modeFilter === "all" || row.observation.mode === modeFilter) + )), [modeFilter, rows, statusFilter]); + const columns = useMemo[]>(() => [{ + id: "session", + accessorFn: (row) => row.session.title, + header: ({ column }) => undefined)} />, + cell: ({ row }) => { + const item = row.original; + return ( +
+ + {item.session.agentLabel} +
+ Identity +
+
Session
{item.observation.session_id}
+
Environment
{item.observation.environment_id ?? "Not applicable"}
+
Allocation
{item.observation.instance.allocation_id ?? "Not available"}
+
Resolved
{formatDashboardTimestamp(item.observation.resolved_at)}
+
+
+
+ ); + }, + }, { + id: "mode", + accessorFn: runtimeModeLabel, + header: ({ column }) => undefined)} />, + cell: ({ row }) => {runtimeModeLabel(row.original)}, + }, { + id: "status", + accessorFn: (row) => row.observation.status, + header: ({ column }) => undefined)} />, + cell: ({ row }) => ( + + + ), + }, { + id: "cpu", + accessorFn: (row) => row.observation.cpu?.usage_seconds_total ?? -1, + header: ({ column }) => undefined)} />, + cell: ({ row }) => { + const cpu = row.original.observation.status === "observed" ? row.original.observation.cpu : null; + return {formatDashboardDuration(cpu?.usage_seconds_total ?? null)}{typeof cpu?.capacity_cores === "number" ? `${cpu.capacity_cores.toLocaleString("en-US")} cores` : "Capacity unknown"}; + }, + }, { + id: "memory", + accessorFn: (row) => row.observation.memory?.usage_bytes ?? -1, + header: ({ column }) => undefined)} />, + cell: ({ row }) => { + const memory = row.original.observation.status === "observed" ? row.original.observation.memory : null; + const memoryPercent = percent(memory?.usage_bytes, memory?.limit_bytes); + return ( + + {formatDashboardBytes(memory?.usage_bytes ?? null)} + {memory?.limit_bytes == null ? "Limit unknown" : `of ${formatDashboardBytes(memory.limit_bytes)}`} + {memoryPercent !== null ? : null} + + ); + }, + }, { + id: "uptime", + accessorFn: (row) => row.computeUptimeSeconds ?? -1, + header: ({ column }) => undefined)} />, + cell: ({ row }) => {formatDashboardDuration(row.original.computeUptimeSeconds)}{row.original.allocationAgeSeconds === null ? "Allocation age unknown" : `${formatDashboardDuration(row.original.allocationAgeSeconds)} allocated`}, + }, { + id: "sessionStatus", + accessorFn: (row) => row.session.status, + header: ({ column }) => undefined)} />, + cell: ({ row }) => {dashboardStatusLabel(row.original.session.status)}, + }, { + id: "tokens", + accessorFn: (row) => row.session.totalTokens ?? -1, + header: ({ column }) => undefined)} />, + cell: ({ row }) => {formatDashboardTokens(row.original.session.totalTokens)}{row.original.session.totalTokens === null ? "Not reported" : "Session reported"}, + }], [onOpenSession]); + const table = useReactTable({ + data: filteredRows, + columns, + state: { sorting, globalFilter }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: runtimeGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageIndex: 0, pageSize: PAGE_SIZE } }, + }); + const visibleRows = table.getFilteredRowModel().rows.length; + + return ( +
+
+ + + + {visibleRows.toLocaleString("en-US")} visible +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => )} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => )} + + ))} + +
{flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ {visibleRows === 0 ?

No Runtime targets match these filters.

: null} + + {table.getPageCount() > 1 ? ( +
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+ + +
+
+ ) : null} + + ); +} + +export function RuntimeObservabilityContent({ + snapshot, + stale, + loadRuntimeHistory, + onOpenSession, +}: { + snapshot: RuntimeDashboardSnapshot; + stale: boolean; + loadRuntimeHistory: RuntimeHistoryLoader; + onOpenSession: (sessionId: string) => void; +}) { + const model = useMemo(() => buildRuntimeDashboardModel(snapshot.sessions, snapshot.observations), [snapshot]); + const summary = model.summary; + const [trendSamples, setTrendSamples] = useState(() => appendRuntimeTrendSample([], snapshot)); + const [selectedTrendRange, setSelectedTrendRange] = useState(RUNTIME_TREND_WINDOW_MS); + const [selectedDurableRange, setSelectedDurableRange] = useState(RUNTIME_DURABLE_RANGES[0].milliseconds); + const [durableSnapshot, setDurableSnapshot] = useState(null); + const [durableState, setDurableState] = useState<"connecting" | "ready" | "unavailable" | "failed">("connecting"); + const [durableError, setDurableError] = useState(null); + const visibleTrendSamples = useMemo( + () => runtimeTrendRange(trendSamples, selectedTrendRange), + [selectedTrendRange, trendSamples], + ); + const source: RuntimeTrendSource = durableState === "unavailable" && durableSnapshot === null + ? "live" + : "durable"; + const selectedSamples = source === "durable" + ? durableSnapshot?.samples ?? [] + : visibleTrendSamples; + const latestTrendSample = selectedSamples.at(-1); + const selectedRange = source === "durable" ? selectedDurableRange : selectedTrendRange; + const rangeEnd = source === "durable" && durableSnapshot !== null + ? durableSnapshot.rangeEnd + : latestTrendSample?.sampledAt ?? snapshot.loadedAt; + const rangeStart = source === "durable" && durableSnapshot !== null + ? durableSnapshot.rangeStart + : rangeEnd - selectedTrendRange; + + useEffect(() => { + setTrendSamples((current) => appendRuntimeTrendSample(current, snapshot)); + }, [snapshot]); + + useEffect(() => { + const controller = new AbortController(); + setDurableState("connecting"); + setDurableError(null); + void loadRuntimeHistory(snapshot, selectedDurableRange, controller.signal).then((result) => { + if (controller.signal.aborted) return; + if (result === null) { + setDurableSnapshot(null); + setDurableState("unavailable"); + return; + } + setDurableSnapshot(result); + setDurableState("ready"); + }).catch((error: unknown) => { + if (controller.signal.aborted) return; + setDurableState("failed"); + setDurableError(error instanceof Error ? error.message : "Durable Runtime history request failed."); + }); + return () => controller.abort(); + }, [loadRuntimeHistory, selectedDurableRange, snapshot]); + + const rangeOptions = source === "durable" ? RUNTIME_DURABLE_RANGES : RUNTIME_TREND_RANGES; + const sourceStatus = source === "durable" + ? durableState === "failed" + ? "History stale" + : durableState === "connecting" + ? "History · loading" + : `Durable · ${durableSnapshot?.resolutionSeconds ?? 0}s` + : stale + ? "Stale · retrying" + : `Live · ${RUNTIME_SNAPSHOT_REFRESH_MS / 1_000}s`; + const sourceStatusStale = source === "durable" ? durableState === "failed" : stale; + + return ( + <> +
+ } label="Active Runtimes" value={summary.observedRuntimeCount.toLocaleString("en-US")} detail={`${summary.managedRuntimeCount} managed · ${summary.unavailableRuntimeCount} unavailable`} /> + } label="Cumulative CPU / capacity" value={summary.cpuUsageSecondsTotal === null && summary.cpuCapacityCores === null ? "No current sample" : `${formatDashboardDuration(summary.cpuUsageSecondsTotal)} / ${summary.cpuCapacityCores?.toLocaleString("en-US") ?? "—"} cores`} detail={`${summary.cpuCoverageCount}/${summary.observedRuntimeCount} observed Runtimes report CPU time`} /> + } label="Memory now" value={summary.memoryUsageBytes === null && summary.memoryLimitBytes === null ? "No current sample" : `${formatDashboardBytes(summary.memoryUsageBytes)} / ${formatDashboardBytes(summary.memoryLimitBytes)}`} detail={`${summary.memoryCoverageCount}/${summary.observedRuntimeCount} observed Runtimes report usage`} /> + } label="Reported tokens" value={formatDashboardTokens(summary.totalTokens)} detail={`${summary.tokenCoverageCount}/${summary.sessionCount} Sessions report usage`} /> +
+ +
+
+
+

Resource trends

+

{source === "durable" ? "Retained samples · durable history" : "Browser-local samples · reset on reload"}

+
+
+ +