diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index e03dfbb8..49c798ed 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -136,6 +136,15 @@ test('a #/traces/:id deep link with no daemon shows the down state and keeps the expect(new URL(page.url()).hash).toBe('#/traces/e2e-trace?obs=e2e-obs'); }); +test('#/monitoring with no daemon shows the down state and keeps window/agent in the URL', async ({ page }) => { + await page.route('**/observe/**', (route) => + route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"down"}' }), + ); + await page.goto('/?token=e2e-studio-token#/monitoring?window=7d&agent=e2e-agent'); + await expect(page.getByRole('main').getByText('lousho observe start')).toBeVisible(); + expect(new URL(page.url()).hash).toBe('#/monitoring?window=7d&agent=e2e-agent'); +}); + test('#/settings shows the settings panel inside the screen', async ({ page }) => { await page.goto('/?token=e2e-studio-token#/settings'); const main = page.getByRole('main'); diff --git a/apps/agent-forge/src/__tests__/contrast.test.ts b/apps/agent-forge/src/__tests__/contrast.test.ts index 1713c22b..3cb2d452 100644 --- a/apps/agent-forge/src/__tests__/contrast.test.ts +++ b/apps/agent-forge/src/__tests__/contrast.test.ts @@ -47,6 +47,8 @@ const PAIRS: [text: string, background: string][] = [ // DataTable: header sort arrow and hovered/focused row text on the header/row tint. ['accent', 'surface-2'], ['text', 'surface-2'], + // Monitoring: the selected window button draws text on the accent tint. + ['text', 'accent-soft'], ]; describe('theme contrast', () => { diff --git a/apps/agent-forge/src/observe/FleetPanel.tsx b/apps/agent-forge/src/observe/FleetPanel.tsx deleted file mode 100644 index 8cf65ecf..00000000 --- a/apps/agent-forge/src/observe/FleetPanel.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useAgents, useInsights } from './useObserve'; -import type { ObserveAgentRow, ObserveAgentInsights } from './types'; -import { fmtAge, fmtInt, fmtPercent } from './format'; -import { ObserveDaemonDown, ObserveError } from './chrome'; -import { StatusPill } from '../ui/StatusPill'; -import { ObserveDaemonDownError } from './observeClient'; - -/** Fleet freshness poll - the Monitoring surface is the "is my fleet up" pane, so it self-refreshes. */ -const POLL_MS = 15_000; - -function usePoll(ms: number): number { - const [tick, setTick] = useState(0); - useEffect(() => { - const timer = setInterval(() => setTick((t) => t + 1), ms); - return () => clearInterval(timer); - }, [ms]); - return tick; -} - -function agentLabel(agent: ObserveAgentRow): string { - return agent.name ?? agent.agent_id.slice(0, 8); -} - -/** - * `GET /api/v1/agents` + `GET /api/v1/insights?days=1` - the fleet registry - * (name, project, derived status, last heartbeat) joined with today's run - * and error figures. "View traces" drills into the Traces tab scoped to - * that agent via ObserveView's shared filter state. - */ -export function FleetPanel({ onShowTraces }: { onShowTraces: (agentId: string) => void }) { - const tick = usePoll(POLL_MS); - const agents = useAgents(undefined, tick); - const today = useInsights({ days: 1 }, tick); - const todayByAgent = new Map((today.data?.agents ?? []).map((a) => [a.agent_id, a])); - - if (agents.error instanceof ObserveDaemonDownError) return ; - - const rows = agents.data ?? []; - return ( -
- - {agents.loading && agents.data === undefined ? ( -
Loading fleet...
- ) : rows.length === 0 ? ( -
-

No agents are registered yet. An agent registers on its first emit when LOUSHO_OBSERVE=1.

-
- ) : ( -
- - - - - - - - - - - - - - {rows.map((agent) => { - const stats = todayByAgent.get(agent.agent_id); - return ( - - - - - - - - - - ); - })} - -
AgentProjectStatusLast heartbeatRuns todayError rate
- {agentLabel(agent)} - {agent.version && v{agent.version}} - {agent.project ?? '-'} - - - {fmtAge(agent.heartbeat_age_ms)} - {stats ? fmtInt(stats.runs) : '-'}{stats ? fmtPercent(stats.error_rate) : '-'} - -
-
- )} -
- ); -} diff --git a/apps/agent-forge/src/observe/InsightsPanel.tsx b/apps/agent-forge/src/observe/InsightsPanel.tsx deleted file mode 100644 index 671b8791..00000000 --- a/apps/agent-forge/src/observe/InsightsPanel.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useInsights } from './useObserve'; -import type { ObserveAgentInsights, ObserveRollupRow } from './types'; -import { fmtCost, fmtDuration, fmtInt, fmtPercent } from './format'; -import { ObserveDaemonDown, ObserveError } from './chrome'; -import { ObserveDaemonDownError } from './observeClient'; - -const DAY_OPTIONS = [1, 7, 30] as const; -const METRICS = [ - { id: 'runs', label: 'Runs' }, - { id: 'errors', label: 'Errors' }, - { id: 'cost_usd', label: 'Cost ($)' }, - { id: 'input_tokens', label: 'Input tokens' }, - { id: 'output_tokens', label: 'Output tokens' }, -] as const; -type MetricId = (typeof METRICS)[number]['id']; - -function fmtMetric(metric: MetricId, value: number): string { - return metric === 'cost_usd' ? fmtCost(value) : fmtInt(value); -} - -/** One agent's aggregate card: runs/day, p50/p95, cost, error rate - the numbers the spec's Insights section names. */ -function AgentCard({ agent }: { agent: ObserveAgentInsights }) { - return ( -
-
- {agent.name ?? agent.agent_id.slice(0, 8)} - {agent.project && {agent.project}} -
-
-
- {agent.runs_per_day.toFixed(1)} - runs/day -
-
- {fmtDuration(agent.p50_latency_ms)} - p50 -
-
- {fmtDuration(agent.p95_latency_ms)} - p95 -
-
- {fmtCost(agent.cost_usd)} - cost -
-
- 0 ? ' insight-bad' : ''}`}> - {fmtPercent(agent.error_rate)} - - error rate -
-
- {fmtInt(agent.input_tokens + agent.output_tokens)} - tokens -
-
-
- ); -} - -interface Bucket { - start: number; - value: number; -} - -/** Sum every agent's rollup rows into one (bucket_start -> value) series for `metric`. */ -function metricBuckets(series: ObserveRollupRow[], metric: MetricId): Bucket[] { - const byBucket = new Map(); - for (const row of series) { - if (row.metric !== metric) continue; - byBucket.set(row.bucket_start, (byBucket.get(row.bucket_start) ?? 0) + row.value); - } - return [...byBucket.entries()].sort(([a], [b]) => a - b).map(([start, value]) => ({ start, value })); -} - -/** Minimal bar chart - plain divs, no chart dependency. */ -function MetricChart({ buckets, metric }: { buckets: Bucket[]; metric: MetricId }) { - const max = Math.max(...buckets.map((b) => b.value), 0); - if (buckets.length === 0) return

No rollup rows in this window yet.

; - return ( -
- {buckets.map((bucket) => ( -
- ))} -
- ); -} - -/** - * `GET /api/v1/insights` - per-agent aggregate cards plus the rollup time - * series. Aggregates come from the daemon's `rollups` table; the panel only - * picks the window (days) and which metric the chart draws. - */ -export function InsightsPanel() { - const [days, setDays] = useState(7); - const [metric, setMetric] = useState('runs'); - const { data, error, loading } = useInsights({ days, granularity: days <= 1 ? 'hour' : 'day' }); - const buckets = useMemo(() => metricBuckets(data?.series ?? [], metric), [data, metric]); - - if (error instanceof ObserveDaemonDownError) return ; - - return ( -
-
- - -
- - {loading && data === undefined ? ( -
Loading insights...
- ) : (data?.agents.length ?? 0) === 0 ? ( -
-

No rollup data in this window - Insights fills in as agents report runs.

-
- ) : ( - <> -
- {data!.agents.map((agent) => ( - - ))} -
-
- {METRICS.find((m) => m.id === metric)?.label} per {days <= 1 ? 'hour' : 'day'} (all agents) -
- - - )} -
- ); -} diff --git a/apps/agent-forge/src/observe/ObserveView.tsx b/apps/agent-forge/src/observe/ObserveView.tsx index 66193b81..c0cfb624 100644 --- a/apps/agent-forge/src/observe/ObserveView.tsx +++ b/apps/agent-forge/src/observe/ObserveView.tsx @@ -6,29 +6,23 @@ import { ObserveDaemonDown } from './chrome'; import type { TraceListFilter } from './observeClient'; import { TracesPanel } from './TracesPanel'; import { TraceDetailView } from './TraceDetailView'; -import { InsightsPanel } from './InsightsPanel'; -import { FleetPanel } from './FleetPanel'; import { ScoresPanel } from './ScoresPanel'; const TABS = [ { id: 'traces', label: 'Traces' }, - { id: 'insights', label: 'Insights' }, - { id: 'fleet', label: 'Monitoring' }, { id: 'scores', label: 'Scores' }, ] as const; export type ObserveTab = (typeof TABS)[number]['id']; const TAB_IDS: ObserveTab[] = TABS.map((t) => t.id); /** - * The observability surface - Traces, Insights, Monitoring (fleet) and - * Scores - served by the observe daemon's read API through the same-origin - * `/observe` proxy. Cross-panel drill-down (fleet row -> that agent's - * traces) is just shared state here: `tracesFilter` is owned at the top so - * another tab can pre-fill it. + * The observability surface - Traces and Scores - served by the observe + * daemon's read API through the same-origin `/observe` proxy. Cross-panel + * drill-down (score row -> its trace) is just shared state here. Monitoring + * (fleet + charts) is its own screen, `screens/monitoring`. * * Given `tab`, the route owns the panel: the tab strip is hidden (the left * nav is the only switcher) and drill-downs ask `onTabChange` to navigate. - * Monitoring then also shows Insights under the fleet table. */ export function ObserveView({ tab: routeTab, @@ -42,12 +36,6 @@ export function ObserveView({ const [tracesFilter, setTracesFilter] = useState({}); const [traceId, setTraceId] = useState(undefined); - // Monitoring's "view traces" click-through: same table, scoped to the agent. - const drillToAgent = (agentId: string) => { - setTracesFilter((filter) => ({ ...filter, agent_id: agentId })); - setTraceId(undefined); - setTab('traces'); - }; const drillToTrace = (id: string) => { setTraceId(id); setTab('traces'); @@ -68,17 +56,6 @@ export function ObserveView({ onOpenTrace={setTraceId} /> ); - else if (tab === 'insights') body = ; - else if (tab === 'fleet' && routed) - body = ( - <> - -
- -
- - ); - else if (tab === 'fleet') body = ; else body = ; if (routed) diff --git a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx index 99c94b84..3bd5dd2e 100644 --- a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx +++ b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx @@ -146,11 +146,4 @@ describe('ObserveView', () => { expect(container!.querySelector('[role="tablist"]')).toBeNull(); expect(container!.textContent).toContain('No traces match'); }); - - it('shows Insights under the fleet table on the Monitoring panel', async () => { - stubDaemon(); - await renderView( {}} />); - expect(container!.querySelector('[role="tablist"]')).toBeNull(); - expect(container!.querySelector('[aria-label="Insights"]')).not.toBeNull(); - }); }); diff --git a/apps/agent-forge/src/observe/chrome.tsx b/apps/agent-forge/src/observe/chrome.tsx index 02d1d459..7c650fed 100644 --- a/apps/agent-forge/src/observe/chrome.tsx +++ b/apps/agent-forge/src/observe/chrome.tsx @@ -12,7 +12,7 @@ export function AgentSelect({ value, onChange, }: { - agents: ObserveAgentRow[] | undefined; + agents: Pick[] | undefined; value: string; onChange: (agentId: string) => void; }) { diff --git a/apps/agent-forge/src/observe/observe.css b/apps/agent-forge/src/observe/observe.css index 4bc5075a..8d276459 100644 --- a/apps/agent-forge/src/observe/observe.css +++ b/apps/agent-forge/src/observe/observe.css @@ -1,5 +1,5 @@ /* - * Observe view (Traces / Insights / Monitoring / Scores) - styles for the + * Observe view (Traces / Scores) - styles for the * panels reading the observe daemon. Reuses ui/base.css's primitives (.btn, * .select, .status-pill, .trace-row/.trace-bar waterfall, .agent-card) and * only adds what the panels themselves need. @@ -287,57 +287,10 @@ margin-top: 14px; } -/* ---------- Insights ---------- */ -.insight-cards { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 10px; - margin-bottom: 14px; -} -.insight-card { - cursor: default; - margin-bottom: 0; -} -.insight-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-top: 8px; -} -.insight-stat { - display: flex; - flex-direction: column; -} -.insight-value { - font-weight: 700; - font-size: 14px; - font-family: var(--font-mono); -} +/* Low scores in the Scores table. */ .insight-bad { color: var(--danger); } -.insight-label { - font-size: 10px; - color: var(--text-faint); - text-transform: uppercase; - letter-spacing: 0.4px; -} -.insight-chart { - display: flex; - align-items: flex-end; - gap: 2px; - height: 120px; - padding: 8px; - border: 1px solid var(--border); - border-radius: 9px; - background: var(--surface); -} -.insight-bar { - flex: 1; - min-width: 3px; - background: var(--accent); - border-radius: 2px 2px 0 0; -} /* ---------- Scores ---------- */ .score-trends { diff --git a/apps/agent-forge/src/observe/types.ts b/apps/agent-forge/src/observe/types.ts index 381e280f..60501cd5 100644 --- a/apps/agent-forge/src/observe/types.ts +++ b/apps/agent-forge/src/observe/types.ts @@ -95,6 +95,12 @@ export interface ObserveAgentRow { heartbeat_interval_ms: number | null; hostname: string | null; pid: number | null; + /** Cumulative telemetry events the agent's reporter dropped (latest heartbeat); `null` from an older SDK. */ + dropped: number | null; + /** Root runs in today's UTC day rollup bucket. */ + runs_today: number; + /** Errors / runs in today's UTC day bucket (0 with no runs). */ + error_rate_today: number; } export interface ObserveAgentInsights { diff --git a/apps/agent-forge/src/screens/monitoring/FleetTable.tsx b/apps/agent-forge/src/screens/monitoring/FleetTable.tsx new file mode 100644 index 00000000..f822b99e --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/FleetTable.tsx @@ -0,0 +1,101 @@ +import { fmtAge, fmtDuration, fmtInt, fmtPercent } from '../../observe/format'; +import type { ObserveAgentInsights, ObserveAgentRow } from '../../observe/types'; +import { DataTable, type Column } from '../../ui/DataTable'; +import { EmptyState } from '../../ui/EmptyState'; +import { StatusPill } from '../../ui/StatusPill'; + +/** A fleet row: the registry entry plus its last-24h p95 from insights. */ +interface FleetRow { + agent: ObserveAgentRow; + p95: number | null | undefined; +} + +export const agentLabel = (agent: Pick): string => + agent.name ?? agent.agent_id.slice(0, 8); + +function hostTitle(agent: ObserveAgentRow): string | undefined { + if (!agent.hostname) return undefined; + return agent.pid ? `${agent.hostname} pid ${agent.pid}` : agent.hostname; +} + +const COLUMNS: Column[] = [ + { + key: 'agent', + header: 'Agent', + render: ({ agent }) => ( + + {agentLabel(agent)} + {agent.version && v{agent.version}} + + ), + }, + { key: 'status', header: 'Status', render: ({ agent }) => }, + { + key: 'heartbeat', + header: 'Last heartbeat', + render: ({ agent }) => {fmtAge(agent.heartbeat_age_ms)}, + }, + { key: 'runs', header: 'Runs today', align: 'right', render: ({ agent }) => fmtInt(agent.runs_today) }, + { key: 'error_rate', header: 'Error rate', align: 'right', render: ({ agent }) => fmtPercent(agent.error_rate_today) }, + { key: 'p95', header: 'p95 (24h)', align: 'right', hideBelow: 560, render: ({ p95 }) => fmtDuration(p95) }, + { + key: 'dropped', + header: 'Dropped events', + label: 'Dropped events', + align: 'right', + hideBelow: 480, + render: ({ agent }) => ( + + {agent.dropped === null || agent.dropped === undefined ? '—' : fmtInt(agent.dropped)} + + ), + }, + { + key: 'traces', + header: Traces, + label: 'Traces', + render: ({ agent }) => ( + + Traces + + ), + }, +]; + +/** + * The fleet: every registered agent with its derived status, last heartbeat, + * today's runs and error rate (the daemon's day rollup), last-24h p95, the + * reporter's dropped-event count, and a link to that agent's traces. + */ +export function FleetTable({ + agents, + insights, + loading, +}: { + agents: ObserveAgentRow[]; + insights: ObserveAgentInsights[] | undefined; + loading: boolean; +}) { + const p95 = new Map((insights ?? []).map((a) => [a.agent_id, a.p95_latency_ms])); + const rows = agents.map((agent) => ({ agent, p95: p95.get(agent.agent_id) })); + return ( + r.agent.agent_id} + loading={loading} + density="compact" + empty={ + + An agent registers on its first emit when LOUSHO_OBSERVE=1 is set. + + } + /> + } + /> + ); +} diff --git a/apps/agent-forge/src/screens/monitoring/MonitoringCharts.tsx b/apps/agent-forge/src/screens/monitoring/MonitoringCharts.tsx new file mode 100644 index 00000000..80d98387 --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/MonitoringCharts.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from 'react'; +import { fmtCost, fmtDuration, fmtInt, fmtPercent } from '../../observe/format'; +import type { ObserveRollupRow } from '../../observe/types'; +import { TimeSeriesChart, type TimeSeries } from '../../ui/TimeSeriesChart'; +import { + costSeries, + perAgentSeries, + runsSeries, + tokenSeries, + type ChartWindow, + type PerAgentSeries, +} from './monitoringSeries'; + +interface ChartsProps { + rows: ObserveRollupRow[]; + window: ChartWindow; + agentLabel: (agentId: string) => string; + onBucketClick: (from: number, to: number) => void; +} + +function ChartCard({ + title, + series, + yFormat, + note, + window: w, + onBucketClick, +}: Pick & { + title: string; + series: TimeSeries[]; + yFormat: (v: number) => string; + note?: ReactNode; +}) { + return ( +
+

{title}

+ + {note &&

{note}

} +
+ ); +} + +function omittedNote({ omitted }: PerAgentSeries): ReactNode { + return omitted > 0 ? `${omitted} more agent${omitted === 1 ? '' : 's'} not shown - pick one in the agent filter.` : undefined; +} + +/** + * The five Monitoring charts over one window. Runs, Cost and Tokens sum across + * agents; Latency (p95 - the rollups carry no p50) and Error rate are drawn + * per agent. Every chart's bucket click goes to that bucket's traces. + */ +export function MonitoringCharts({ rows, window: w, agentLabel, onBucketClick }: ChartsProps) { + const latency = perAgentSeries(rows, 'p95_latency_ms', agentLabel); + const errorRate = perAgentSeries(rows, 'error_rate', agentLabel, (v) => v * 100); + const shared = { window: w, onBucketClick }; + return ( +
+ + + fmtPercent(v / 100)} + note={omittedNote(errorRate)} + {...shared} + /> + + +
+ ); +} diff --git a/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx b/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx new file mode 100644 index 00000000..69680ee8 --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { AgentSelect, ObserveDaemonDown, ObserveError } from '../../observe/chrome'; +import { ObserveDaemonDownError } from '../../observe/observeClient'; +import { useAgents, useDaemonHealth, useInsights } from '../../observe/useObserve'; +import { navigate } from '../../router/router'; +import { useQueryState } from '../../router/useRoute'; +import { EmptyState } from '../../ui/EmptyState'; +import { agentLabel, FleetTable } from './FleetTable'; +import { MonitoringCharts } from './MonitoringCharts'; +import { + bucketTracesPath, + chartWindow, + formatWindow, + parseWindow, + WINDOWS, + type MonitoringWindow, +} from './monitoringSeries'; +// The shared daemon-down / error presentation is styled there. +import '../../observe/observe.css'; +import './monitoring.css'; + +/** Fleet + charts refresh: Monitoring is the "is my fleet up" screen, so it polls by itself. */ +export const MONITORING_POLL_MS = 15_000; + +/** A counter that bumps every `ms`. */ +function usePoll(ms: number): number { + const [tick, setTick] = useState(0); + useEffect(() => { + const timer = setInterval(() => setTick((t) => t + 1), ms); + return () => clearInterval(timer); + }, [ms]); + return tick; +} + +const parseAgent = (s: string | null): string => s ?? ''; +const formatAgent = (v: string): string | null => v || null; + +function WindowPicker({ value, onChange }: { value: MonitoringWindow; onChange: (w: MonitoringWindow) => void }) { + return ( +
+ {WINDOWS.map((w) => ( + + ))} +
+ ); +} + +/** Fleet registry, last-24h per-agent figures and the window's rollup series, all refreshed every 15 s (and when the daemon comes back). */ +function useMonitoringData(win: MonitoringWindow, agentId: string) { + const tick = usePoll(MONITORING_POLL_MS); + const health = useDaemonHealth(); + const refreshKey = `${tick}:${health.down}`; + // Re-read the clock on each poll so the window slides with time. + const now = useMemo(() => Date.now(), [tick]); + const chart = chartWindow(win, now); + const agents = useAgents(undefined, refreshKey); + const fleetInsights = useInsights({ days: 1 }, refreshKey); + const insights = useInsights( + { since: chart.from, until: chart.to, agent_id: agentId || undefined, granularity: chart.granularity }, + refreshKey, + ); + const errors = [agents.error, fleetInsights.error, insights.error]; + const down = health.down || errors.some((e) => e instanceof ObserveDaemonDownError); + return { agents, fleetInsights, insights, chart, down, error: errors.find((e) => e !== undefined) }; +} + +/** + * `#/monitoring?window=1h|24h|7d|30d&agent=` - the fleet table over the + * Runs / Latency / Error rate / Cost / Tokens charts. Window (default 24h, not + * written) and agent live in the query string, so a reload keeps them. + * Clicking a chart bucket opens Traces for that bucket (and agent). A dead + * daemon shows the shared down state with the URL kept; the screen recovers + * in place once the health probe sees it again. + */ +export function MonitoringScreen() { + const [win, setWin] = useQueryState('window', parseWindow, formatWindow); + const [agentId, setAgentId] = useQueryState('agent', parseAgent, formatAgent); + const { agents, fleetInsights, insights, chart, down, error } = useMonitoringData(win, agentId); + + const names = useMemo(() => new Map((agents.data ?? []).map((a) => [a.agent_id, agentLabel(a)])), [agents.data]); + const labelOf = (id: string) => names.get(id) ?? id.slice(0, 8); + const rows = insights.data?.series ?? []; + // An `agent=` id the fleet doesn't list still filters, so the select offers it too. + const agentOptions = useMemo(() => { + const fleet = agents.data ?? []; + return agentId && !fleet.some((a) => a.agent_id === agentId) ? [...fleet, { agent_id: agentId, name: null }] : fleet; + }, [agents.data, agentId]); + + if (down) + return ( +
+ +
+ ); + return ( +
+

Monitoring

+ +
+ +
+
+ + +
+ {insights.data === undefined && insights.loading ? ( +
Loading charts...
+ ) : rows.length === 0 ? ( + + ) : ( + navigate(bucketTracesPath(from, to, agentId))} + /> + )} +
+ ); +} diff --git a/apps/agent-forge/src/screens/monitoring/__tests__/MonitoringScreen.test.tsx b/apps/agent-forge/src/screens/monitoring/__tests__/MonitoringScreen.test.tsx new file mode 100644 index 00000000..a73042d3 --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/__tests__/MonitoringScreen.test.tsx @@ -0,0 +1,229 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ObserveAgentRow, ObserveRollupRow } from '../../../observe/types'; +import { render } from '../../../ui/__tests__/render'; +import { MonitoringScreen, MONITORING_POLL_MS } from '../MonitoringScreen'; +import { chartWindow } from '../monitoringSeries'; + +const NOW = Date.UTC(2026, 9, 25, 12, 30); +const HOUR = 3_600_000; + +function agent(over: Partial & { agent_id: string }): ObserveAgentRow { + return { + project: null, + name: over.agent_id, + version: null, + first_seen: NOW - HOUR, + last_heartbeat: NOW - 5000, + status: 'healthy', + heartbeat_age_ms: 5000, + heartbeat_interval_ms: 30_000, + hostname: null, + pid: null, + dropped: null, + runs_today: 0, + error_rate_today: 0, + ...over, + }; +} + +const AGENTS = [ + agent({ agent_id: 'a1', name: 'Support bot', status: 'stale', dropped: 7, runs_today: 12, error_rate_today: 0.25 }), + agent({ agent_id: 'a2', name: 'Billing bot', dropped: null }), +]; + +const rollup = (bucket_start: number, agent_id: string, metric: string, value: number): ObserveRollupRow => ({ + bucket_start, + granularity: 'hour', + agent_id, + metric, + value, +}); + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +/** A stand-in daemon behind `/observe`; `down` fails every request like a refused connection. */ +function daemon(series: ObserveRollupRow[] = []) { + const state = { down: false, series }; + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + if (state.down) throw new TypeError('fetch failed'); + const url = String(input); + if (url.startsWith('/observe/health')) return json(200, { service: 'lousho-observe', ok: true }); + if (url.startsWith('/observe/__target')) return json(200, { url: 'http://127.0.0.1:7777' }); + if (url.startsWith('/observe/api/v1/agents')) return json(200, { agents: AGENTS }); + if (url.startsWith('/observe/api/v1/insights')) { + const q = new URL(url, 'http://x').searchParams; + const p95 = { agent_id: 'a1', p95_latency_ms: 1500 }; + // The fleet's `days=1` call carries no series; the chart call gets the window's rows. + return json(200, { window: { since: 0, until: 1 }, agents: [p95], series: q.has('days') ? [] : state.series }); + } + return json(404, { error: `unexpected ${url}` }); + }); + vi.stubGlobal('fetch', fetchFn); + const calls = (path: string) => fetchFn.mock.calls.map(([u]) => String(u)).filter((u) => u.startsWith(`/observe${path}`)); + return { state, fetchFn, calls }; +} + +/** Flush fetch promises and renders until `check` holds (timers are fake, so this counts turns, not ms). */ +async function waitFor(check: () => boolean, turns = 50) { + for (let i = 0; !check(); i++) { + if (i > turns) throw new Error('waitFor gave up'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +const fleetRow = (c: HTMLElement, name: string) => + [...c.querySelectorAll('.mon-fleet tbody tr')].find((tr) => tr.textContent!.includes(name)) as HTMLElement | undefined; +const chart = (c: HTMLElement, title: string) => c.querySelector(`[data-chart="${title}"]`) as HTMLElement | null; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); + vi.setSystemTime(NOW); + window.history.replaceState(null, '', '/#/monitoring'); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + window.history.replaceState(null, '', '/'); +}); + +describe('MonitoringScreen', () => { + it('renders each fleet row with its status pill, dropped count, figures and traces link', async () => { + daemon(); + const c = render(); + await waitFor(() => fleetRow(c, 'Billing bot') !== undefined && fleetRow(c, 'Support bot')!.textContent!.includes('1.5s')); + const support = fleetRow(c, 'Support bot')!; + expect(support.querySelector('.status-pill.pill-warning')?.textContent).toBe('stale'); + expect(support.querySelector('[data-dropped]')?.textContent).toBe('7'); + expect(support.textContent).toContain('5s ago'); + expect(support.textContent).toContain('12'); + expect(support.textContent).toContain('25.0%'); + expect(support.querySelector('a[href="#/traces?agent=a1"]')).not.toBeNull(); + const billing = fleetRow(c, 'Billing bot')!; + expect(billing.querySelector('.status-pill.pill-ok')?.textContent).toBe('healthy'); + expect(billing.querySelector('[data-dropped]')?.textContent).toBe('—'); + }); + + it('renders a single-bucket series as a point', async () => { + const w = chartWindow('24h', NOW); + daemon([rollup(w.to - HOUR, 'a1', 'runs', 3), rollup(w.to - HOUR, 'a1', 'p95_latency_ms', 900)]); + const c = render(); + await waitFor(() => chart(c, 'Runs') !== null); + expect(chart(c, 'Runs')!.querySelector('circle.tsc-point[data-series="ok"]')).not.toBeNull(); + expect(chart(c, 'Latency p95')!.querySelector('circle.tsc-point[data-series="a1"]')).not.toBeNull(); + }); + + it('draws per-agent p95 and error rate as one unsummed series per agent', async () => { + const w = chartWindow('24h', NOW); + const t = w.to - HOUR; + daemon([ + rollup(t, 'a1', 'runs', 4), + rollup(t, 'a2', 'runs', 2), + rollup(t, 'a1', 'p95_latency_ms', 900), + rollup(t, 'a2', 'p95_latency_ms', 400), + rollup(t, 'a1', 'error_rate', 0.5), + rollup(t, 'a2', 'error_rate', 0.25), + ]); + const c = render(); + await waitFor(() => chart(c, 'Latency p95') !== null); + for (const title of ['Latency p95', 'Error rate']) { + const points = chart(c, title)!.querySelectorAll('circle.tsc-point'); + expect([...points].map((p) => p.getAttribute('data-series'))).toEqual(['a1', 'a2']); + // Unsummed: the two agents sit at different heights in the one bucket. + expect(points[0]!.getAttribute('cy')).not.toBe(points[1]!.getAttribute('cy')); + expect(chart(c, title)!.querySelector('.tsc-legend')?.textContent).toBe('Support botBilling bot'); + } + }); + + it('notes the agents left off a per-agent chart past five', async () => { + const w = chartWindow('24h', NOW); + const ids = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7']; + daemon(ids.flatMap((id, i) => [rollup(w.from, id, 'runs', i + 1), rollup(w.from, id, 'p95_latency_ms', 100)])); + const c = render(); + await waitFor(() => chart(c, 'Latency p95') !== null); + expect(chart(c, 'Latency p95')!.querySelectorAll('circle.tsc-point')).toHaveLength(5); + expect(chart(c, 'Latency p95')!.querySelector('.mon-chart-note')?.textContent).toContain('2 more agents not shown'); + }); + + it('offers an agent= id the fleet does not list in the agent select', async () => { + window.history.replaceState(null, '', '/#/monitoring?agent=ghost-agent'); + daemon(); + const c = render(); + await waitFor(() => fleetRow(c, 'Support bot') !== undefined); + const select = c.querySelector('.mon-agent-filter select') as HTMLSelectElement; + expect(select.value).toBe('ghost-agent'); + expect([...select.options].map((o) => o.value)).toEqual(['', 'a1', 'a2', 'ghost-agent']); + }); + + it('navigates to Traces with the clicked bucket as from/to, plus the agent when filtered', async () => { + window.history.replaceState(null, '', '/#/monitoring?agent=a1'); + const w = chartWindow('24h', NOW); + daemon([rollup(w.from, 'a1', 'runs', 1)]); + const c = render(); + await waitFor(() => chart(c, 'Runs') !== null); + const plot = chart(c, 'Runs')!.querySelector('rect.tsc-plot')!; + // jsdom lays the 600 px chart out at x = 0; the plot starts after the 48 px y-axis gutter. + act(() => plot.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 50 }))); + expect(window.location.hash).toBe(`#/traces?from=${w.from}&to=${w.from + HOUR}&agent=a1`); + }); + + it('keeps window and agent in the URL and queries with them', async () => { + window.history.replaceState(null, '', '/#/monitoring?window=7d&agent=a1'); + const { calls } = daemon(); + const c = render(); + await waitFor(() => calls('/api/v1/insights').some((u) => u.includes('granularity=day'))); + const chartCall = calls('/api/v1/insights').find((u) => u.includes('granularity=day'))!; + expect(chartCall).toContain('agent_id=a1'); + const pressed = () => c.querySelector('.mon-windows [aria-pressed="true"]')?.textContent; + expect(pressed()).toBe('7d'); + + const button = (label: string) => + [...c.querySelectorAll('.mon-windows button')].find((b) => b.textContent === label) as HTMLButtonElement; + act(() => button('1h').click()); + expect(window.location.hash).toBe('#/monitoring?window=1h&agent=a1'); + await waitFor(() => pressed() === '1h'); + await waitFor(() => calls('/api/v1/insights').some((u) => u.includes('granularity=hour') && u.includes('agent_id=a1'))); + act(() => button('24h').click()); + expect(window.location.hash).toBe('#/monitoring?agent=a1'); + }); + + it('polls the fleet and charts every 15 s', async () => { + const { calls } = daemon(); + const c = render(); + await waitFor(() => fleetRow(c, 'Support bot') !== undefined); + const agents = calls('/api/v1/agents').length; + const insights = calls('/api/v1/insights').length; + await act(async () => { + vi.advanceTimersByTime(MONITORING_POLL_MS - 1); + }); + expect(calls('/api/v1/agents').length).toBe(agents); + await act(async () => { + vi.advanceTimersByTime(1); + }); + await waitFor(() => calls('/api/v1/agents').length === agents + 1); + expect(calls('/api/v1/insights').length).toBe(insights + 2); + }); + + it('shows the daemon-down state with the URL kept and recovers in place', async () => { + window.history.replaceState(null, '', '/#/monitoring?window=7d'); + const { state } = daemon(); + state.down = true; + const c = render(); + await waitFor(() => c.textContent!.includes('lousho observe start')); + expect(window.location.hash).toBe('#/monitoring?window=7d'); + + state.down = false; + await act(async () => { + vi.advanceTimersByTime(5000); + }); + await waitFor(() => fleetRow(c, 'Support bot') !== undefined); + expect(c.textContent).not.toContain('lousho observe start'); + expect(window.location.hash).toBe('#/monitoring?window=7d'); + }); +}); diff --git a/apps/agent-forge/src/screens/monitoring/__tests__/monitoringSeries.test.ts b/apps/agent-forge/src/screens/monitoring/__tests__/monitoringSeries.test.ts new file mode 100644 index 00000000..5bedfacd --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/__tests__/monitoringSeries.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import type { ObserveRollupRow } from '../../../observe/types'; +import { perAgentSeries, runsSeries } from '../monitoringSeries'; + +const T = Date.UTC(2026, 9, 25, 12); + +const row = (agent_id: string, metric: string, value: number, bucket_start = T): ObserveRollupRow => ({ + bucket_start, + granularity: 'hour', + agent_id, + metric, + value, +}); + +describe('perAgentSeries', () => { + it('keeps two agents in the same bucket as two series, never summed (p95_latency_ms)', () => { + const rows = [row('a1', 'runs', 5), row('a2', 'runs', 2), row('a1', 'p95_latency_ms', 900), row('a2', 'p95_latency_ms', 400)]; + const { series, omitted } = perAgentSeries(rows, 'p95_latency_ms', (id) => id); + expect(omitted).toBe(0); + expect(series.map((s) => ({ id: s.id, points: s.points }))).toEqual([ + { id: 'a1', points: [{ t: T, v: 900 }] }, + { id: 'a2', points: [{ t: T, v: 400 }] }, + ]); + }); + + it('keeps two agents in the same bucket as two series, never summed (error_rate, scaled)', () => { + const rows = [row('a1', 'error_rate', 0.5), row('a2', 'error_rate', 0.25)]; + const { series } = perAgentSeries(rows, 'error_rate', (id) => id, (v) => v * 100); + expect(series).toHaveLength(2); + expect(Object.fromEntries(series.map((s) => [s.id, s.points]))).toEqual({ + a1: [{ t: T, v: 50 }], + a2: [{ t: T, v: 25 }], + }); + }); + + it('caps at 5 agents, busiest first, and counts the rest as omitted', () => { + const ids = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7']; + const rows = ids.flatMap((id, i) => [row(id, 'runs', i + 1), row(id, 'p95_latency_ms', 100)]); + const { series, omitted } = perAgentSeries(rows, 'p95_latency_ms', (id) => id); + expect(series.map((s) => s.id)).toEqual(['a7', 'a6', 'a5', 'a4', 'a3']); + expect(omitted).toBe(2); + }); +}); + +describe('runsSeries', () => { + it('sums the additive runs/errors across agents per bucket', () => { + const rows = [row('a1', 'runs', 5), row('a2', 'runs', 3), row('a1', 'errors', 1), row('a2', 'errors', 1)]; + const [ok, error] = runsSeries(rows); + expect(ok!.points).toEqual([{ t: T, v: 6 }]); + expect(error!.points).toEqual([{ t: T, v: 2 }]); + }); +}); diff --git a/apps/agent-forge/src/screens/monitoring/monitoring.css b/apps/agent-forge/src/screens/monitoring/monitoring.css new file mode 100644 index 00000000..d8451333 --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/monitoring.css @@ -0,0 +1,102 @@ +/* + * Monitoring screen (#/monitoring): fleet table over the window / agent + * toolbar and the chart grid. Colours from src/theme.css tokens only. + */ + +.mon-screen { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; + padding: 16px; + font-family: var(--font-ui); + color: var(--text); +} + +.mon-agent { + font-weight: 600; +} +.mon-meta { + font-weight: 400; + color: var(--text-faint); +} +.mon-dropped { + padding: 0 6px; + border-radius: 999px; + color: var(--warning); + background: var(--warning-soft); + font-weight: 600; +} +.mon-link { + color: var(--text); + text-decoration: underline; +} + +.mon-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.mon-windows { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.mon-window { + border-radius: 0; + font-family: var(--font-mono); +} +.mon-window.active { + background: var(--accent-soft); + color: var(--text); + font-weight: 600; +} +.mon-agent-filter { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.mon-loading { + padding: 24px; + color: var(--text-muted); +} + +.mon-charts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 12px; +} +.mon-chart { + min-width: 0; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} +.mon-chart-title { + margin: 0 0 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.4px; +} +.mon-chart-note { + margin: 6px 0 0; + font-size: 11px; + color: var(--text-faint); +} + +@media (max-width: 480px) { + .mon-screen { + padding: 12px; + } + .mon-charts { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/agent-forge/src/screens/monitoring/monitoringSeries.ts b/apps/agent-forge/src/screens/monitoring/monitoringSeries.ts new file mode 100644 index 00000000..6d90bce2 --- /dev/null +++ b/apps/agent-forge/src/screens/monitoring/monitoringSeries.ts @@ -0,0 +1,126 @@ +/** + * Pure helpers for the Monitoring screen: the window choices, the bucket-aligned + * chart window, and the rollup rows -> TimeSeriesChart series mapping. + * + * Additive metrics (runs, errors, cost_usd, input/output tokens) are summed + * across agents per bucket. `p95_latency_ms` and `error_rate` are per-agent + * values and are never summed: they become one series per agent, one point per + * bucket (TimeSeriesChart sums points that share a bucket). + */ +import type { ObserveRollupRow } from '../../observe/types'; +import type { SeriesColor, TimeSeries, TimeSeriesPoint } from '../../ui/TimeSeriesChart'; + +export const WINDOWS = ['1h', '24h', '7d', '30d'] as const; +export type MonitoringWindow = (typeof WINDOWS)[number]; +const DEFAULT_WINDOW: MonitoringWindow = '24h'; + +const HOUR = 3_600_000; +const DAY = 86_400_000; +const WINDOW_MS: Record = { '1h': HOUR, '24h': DAY, '7d': 7 * DAY, '30d': 30 * DAY }; + +/** Per-agent series beyond this many are left off the chart (with a note). */ +const MAX_AGENT_SERIES = 5; +const AGENT_COLORS: SeriesColor[] = ['accent', 'info', 'warning', 'success', 'danger']; + +/** `?window=`: one of {@link WINDOWS}; anything else is the default. */ +export function parseWindow(s: string | null): MonitoringWindow { + return (WINDOWS as readonly string[]).includes(s ?? '') ? (s as MonitoringWindow) : DEFAULT_WINDOW; +} + +/** The default window is not written to the URL. */ +export function formatWindow(w: MonitoringWindow): string | null { + return w === DEFAULT_WINDOW ? null : w; +} + +export interface ChartWindow { + from: number; + to: number; + bucketMs: number; + granularity: 'hour' | 'day'; +} + +/** + * Hour buckets for 1h/24h, day buckets for 7d/30d, aligned to the daemon's UTC + * rollup buckets: from the bucket holding `now - window` through the one holding `now`. + */ +export function chartWindow(window: MonitoringWindow, now: number): ChartWindow { + const granularity = window === '1h' || window === '24h' ? 'hour' : 'day'; + const bucketMs = granularity === 'hour' ? HOUR : DAY; + const to = now - (now % bucketMs) + bucketMs; + const start = Math.max(0, now - WINDOW_MS[window]); + const from = start - (start % bucketMs); + return { from, to, bucketMs, granularity }; +} + +/** Sum `metric` across agents into one point per bucket. */ +function summed(rows: ObserveRollupRow[], metric: string): TimeSeriesPoint[] { + const byBucket = new Map(); + for (const row of rows) { + if (row.metric === metric) byBucket.set(row.bucket_start, (byBucket.get(row.bucket_start) ?? 0) + row.value); + } + return [...byBucket.entries()].sort(([a], [b]) => a - b).map(([t, v]) => ({ t, v })); +} + +/** Runs split into ok (runs - errors) and error, summed across agents. */ +export function runsSeries(rows: ObserveRollupRow[]): TimeSeries[] { + const errors = new Map(summed(rows, 'errors').map((p) => [p.t, p.v])); + const runs = summed(rows, 'runs'); + return [ + { id: 'ok', label: 'OK', color: 'success', points: runs.map((p) => ({ t: p.t, v: Math.max(0, p.v - (errors.get(p.t) ?? 0)) })) }, + { id: 'error', label: 'Error', color: 'danger', points: runs.map((p) => ({ t: p.t, v: errors.get(p.t) ?? 0 })) }, + ]; +} + +export function tokenSeries(rows: ObserveRollupRow[]): TimeSeries[] { + return [ + { id: 'input', label: 'Input', color: 'accent', points: summed(rows, 'input_tokens') }, + { id: 'output', label: 'Output', color: 'info', points: summed(rows, 'output_tokens') }, + ]; +} + +export function costSeries(rows: ObserveRollupRow[]): TimeSeries[] { + return [{ id: 'cost', label: 'Cost', color: 'accent', points: summed(rows, 'cost_usd') }]; +} + +export interface PerAgentSeries { + series: TimeSeries[]; + /** Agents with data that were left off (over {@link MAX_AGENT_SERIES}). */ + omitted: number; +} + +/** + * One series per agent for a non-additive metric, busiest agents (by runs in + * the rows) first, capped at {@link MAX_AGENT_SERIES}. `scale` maps the stored + * value (e.g. a 0..1 rate to percent). + */ +export function perAgentSeries( + rows: ObserveRollupRow[], + metric: string, + agentLabel: (agentId: string) => string, + scale: (v: number) => number = (v) => v, +): PerAgentSeries { + const runs = new Map(); + const points = new Map(); + for (const row of rows) { + if (row.metric === 'runs') runs.set(row.agent_id, (runs.get(row.agent_id) ?? 0) + row.value); + if (row.metric !== metric) continue; + const list = points.get(row.agent_id) ?? []; + list.push({ t: row.bucket_start, v: scale(row.value) }); + points.set(row.agent_id, list); + } + const ids = [...points.keys()].sort((a, b) => (runs.get(b) ?? 0) - (runs.get(a) ?? 0) || a.localeCompare(b)); + const series = ids.slice(0, MAX_AGENT_SERIES).map((id, i) => ({ + id, + label: agentLabel(id), + color: AGENT_COLORS[i % AGENT_COLORS.length]!, + points: points.get(id)!.sort((a, b) => a.t - b.t), + })); + return { series, omitted: Math.max(0, ids.length - MAX_AGENT_SERIES) }; +} + +/** The Traces deep link for a clicked bucket (`from`/`to` half-open ms, plus the agent when filtered). */ +export function bucketTracesPath(from: number, to: number, agentId: string): string { + const q = new URLSearchParams({ from: String(from), to: String(to) }); + if (agentId) q.set('agent', agentId); + return `/traces?${q.toString()}`; +} diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx index 8b6a1cf4..d87714db 100644 --- a/apps/agent-forge/src/screens/routes.tsx +++ b/apps/agent-forge/src/screens/routes.tsx @@ -6,6 +6,7 @@ import { SettingsPanel } from '../components/SettingsPanel'; import { ObserveView, type ObserveTab } from '../observe/ObserveView'; import { AgentsScreen } from './agents/AgentsScreen'; import { TraceScreen } from './traces/TraceScreen'; +import { MonitoringScreen } from './monitoring/MonitoringScreen'; import { useLeaveAgentsGuard } from './agents/useLeaveAgentsGuard'; import { Placeholder } from './Placeholder'; @@ -62,12 +63,10 @@ export function useScreen(): ScreenId { return screen ?? 'agents'; } -/** Until the Phase 3 screens land, Traces/Monitoring/Scores are the existing observe panels, one per route. */ -const OBSERVE_TAB: Partial> = { traces: 'traces', monitoring: 'fleet', scores: 'scores' }; +/** Until the Phase 3 screens land, Traces/Scores are the existing observe panels, one per route. */ +const OBSERVE_TAB: Partial> = { traces: 'traces', scores: 'scores' }; const TAB_PATH: Record = { traces: '/traces', - insights: '/monitoring', - fleet: '/monitoring', scores: '/evals/scores', }; @@ -110,6 +109,7 @@ export function Screens() { let content: ReactNode; // Same element type at the same position for all three, so the drill-down filter survives a route change. if (screen === 'trace') content = ; + else if (screen === 'monitoring') content = ; else if (observeTab) content = navigate(TAB_PATH[tab])} />; else if (placeholder) content = ; else