diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index 3c214f2d7488..4522a8dfb879 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -4908,6 +4908,104 @@ async def list_recent_webhook_events(): } +# --------------------------------------------------------------------------- +# Agent activity lens (KR-AGENT-ACTIVITY-PANEL) +# --------------------------------------------------------------------------- +# +# Operator-facing observability for OTHER agents calling Kora via the +# /mcp endpoint (Feature 4). Pairs with CC#3's KR-MCP-RUNTIME-SURFACE +# bucket — ST2 will swap this stub for real per-call ledger reads +# from kora_cli/listeners/mcp.py (PR #101 stubbed kora__daemon_status +# only; ST2 adds the full kora__* tool surface). +# +# v1 stub: 5 representative calls per bucket §3 verbatim, deliberately +# spanning ok / capability_denied / denied_prod_only so operator sees +# what failures look like. stub:true keeps the FE banner visible. +# +# SECURITY (3-layer contract, same pattern as KR-MCP-3 / WEBHOOK-EVENTS): +# 1. ``result_summary`` is a SHORT TEXTUAL summary — never raw JSON +# payloads. Backend test asserts no embedded {/[/" sequences that +# would indicate a JSON dump leaked into the summary line. +# 2. ``caller_actor_kind`` is a LABEL (claude_pm / kora_drone_7 / +# etc.) — never bearer-token-shaped or token-hash-shaped. Backend +# test asserts the field doesn't match base64/hex patterns of +# typical token shapes. +# 3. TS interface enforces both contracts at compile time. + + +@app.get("/api/agent-activity/recent") +async def list_recent_agent_activity(): + """Return recent agent-driven MCP tool calls for the operator lens. + + v1 stub — pinned shape so CC#3's KR-MCP-RUNTIME-SURFACE ST2 can + swap the body without touching the FE. + + Per-call fields: + id — opaque call id + tool_name — kora__* MCP tool invoked + caller_actor_kind — LABEL only (claude_pm, kora_drone_N, etc.); + never a token or token hash + called_at — ISO-8601 timestamp + duration_ms — int (>= 0) + status — ok | capability_denied | denied_prod_only | + tool_not_found | handler_error | timeout + result_summary — short TEXTUAL summary; never raw JSON + """ + return { + "calls": [ + { + "id": "stub-1", + "tool_name": "kora__get_operational_state", + "caller_actor_kind": "claude_pm", + "called_at": "2026-05-22T17:58:42Z", + "duration_ms": 124, + "status": "ok", + "result_summary": "state: RUNNING, 0 active sea_tickets", + }, + { + "id": "stub-2", + "tool_name": "kora__create_sea_ticket", + "caller_actor_kind": "claude_pm", + "called_at": "2026-05-22T17:51:08Z", + "duration_ms": 832, + "status": "ok", + "result_summary": "ticket: sea_abc123", + }, + { + "id": "stub-3", + "tool_name": "kora__request_state_transition", + "caller_actor_kind": "kora_drone_7", + "called_at": "2026-05-22T17:44:19Z", + "duration_ms": 67, + "status": "capability_denied", + "result_summary": "required: cap_kora_state_transition", + }, + { + "id": "stub-4", + "tool_name": "kora__get_recent_chain_events", + "caller_actor_kind": "claude_pm", + "called_at": "2026-05-22T17:42:55Z", + "duration_ms": 198, + "status": "ok", + "result_summary": "20 events returned", + }, + { + "id": "stub-5", + "tool_name": "kora__send_webhook_test_event", + "caller_actor_kind": "claude_pm", + "called_at": "2026-05-22T17:40:11Z", + "duration_ms": 12, + "status": "denied_prod_only", + "result_summary": "dev-only tool refused on prd environment", + }, + ], + "stub": True, + "generated_at": "2026-05-22T18:00:00Z", + "total_recent_24h": 23, + "by_caller_24h": {"claude_pm": 19, "kora_drone_7": 4}, + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_web_server_agent_activity.py b/tests/kora_cli/test_web_server_agent_activity.py new file mode 100644 index 000000000000..20a2215e6a43 --- /dev/null +++ b/tests/kora_cli/test_web_server_agent_activity.py @@ -0,0 +1,273 @@ +"""Tests for the KR-AGENT-ACTIVITY-PANEL stub endpoint. + +Bucket §4 scenarios: + 1. GET /api/agent-activity/recent returns 200 + 2. Top-level shape (calls + stub:true + generated_at + total_recent_24h + + by_caller_24h) + 3. 5 representative stub calls present + 4. Stub covers ok + capability_denied + denied_prod_only so the FE's + status-badge variety is exercised + 5. Per-entry shape + valid status enum + 6. SECURITY: result_summary contains no raw JSON payload (no {}/[] + JSON-shape sequences); covers spec §3 contract + 7. SECURITY: caller_actor_kind doesn't match token / hash shapes + (no long base64/hex runs) + 8. by_caller_24h matches the calls' actual caller distribution + 9. Cron-regression sanity +""" + +import re + +import pytest + + +_VALID_STATUS = { + "ok", + "capability_denied", + "denied_prod_only", + "tool_not_found", + "handler_error", + "timeout", +} + +# Walk-the-whole-payload guard for raw JSON leaks in result_summary. +# A short textual summary like "20 events returned" contains no JSON +# braces or square brackets. A raw payload dump like '{"id": 4}' does. +# Standardized pattern from KR-WEBHOOK-EVENTS guard against full IPv4 +# leaks — pin shape so future stub/real drift can't slip a payload in. +_JSON_LEAK = re.compile(r'[\{\}\[\]]') + +# A bearer-token or token-hash typically presents as a continuous run +# of ≥16 base64/hex characters with no dashes/underscores broken by +# spaces. Caller labels (claude_pm, kora_drone_7) are short, contain +# underscores, and don't reach 16 contiguous chars without separators. +# Hex-only pin catches sha-shaped hashes; base64 pin catches token bodies. +_HEX_TOKEN_PIN = re.compile(r'\b[0-9a-fA-F]{16,}\b') +_BASE64_TOKEN_PIN = re.compile(r'\b[A-Za-z0-9+/]{20,}={0,2}\b') + + +@pytest.fixture(autouse=True) +def _isolate_config(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: tmp_path) + monkeypatch.setattr( + "kora_cli.config.get_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr( + "kora_cli.config.get_env_path", lambda: tmp_path / ".env" + ) + return tmp_path + + +# ---- 1. 200 ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_endpoint_returns_200(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + assert isinstance(result, dict) + + +# ---- 2. Top-level shape ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_response_shape_has_required_keys(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + assert set(result.keys()) == { + "calls", + "stub", + "generated_at", + "total_recent_24h", + "by_caller_24h", + } + assert isinstance(result["calls"], list) + assert isinstance(result["generated_at"], str) + assert isinstance(result["total_recent_24h"], int) + assert isinstance(result["by_caller_24h"], dict) + assert result["stub"] is True + + +# ---- 3. Expected stub calls ----------------------------------------- + + +@pytest.mark.asyncio +async def test_stub_returns_five_representative_calls(_isolate_config): + """Pin the bucket §3 canonical 5-call stub list. CC#3's per-call + ledger will replace the body in KR-MCP-RUNTIME-SURFACE ST2 but + stub shape must stay stable so the FE shipping off this PR keeps + rendering correctly when both run side-by-side during the cut-over.""" + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + assert len(result["calls"]) == 5 + ids = {c["id"] for c in result["calls"]} + assert ids == {"stub-1", "stub-2", "stub-3", "stub-4", "stub-5"} + + +@pytest.mark.asyncio +async def test_stub_covers_ok_and_both_denial_paths(_isolate_config): + """The 5 stub calls deliberately span ok + capability_denied + + denied_prod_only so the operator's first look at the panel + surfaces what failure modes look like. Pin so future stub edits + can't accidentally homogenize to ok-only.""" + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + statuses = {c["status"] for c in result["calls"]} + assert "ok" in statuses + assert "capability_denied" in statuses + assert "denied_prod_only" in statuses + + +# ---- 4. Per-entry shape + enum -------------------------------------- + + +@pytest.mark.asyncio +async def test_each_call_has_required_keys_and_valid_status(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + required = { + "id", + "tool_name", + "caller_actor_kind", + "called_at", + "duration_ms", + "status", + "result_summary", + } + for call in result["calls"]: + assert set(call.keys()) == required + assert call["status"] in _VALID_STATUS, ( + f"{call['id']}: status={call['status']!r} not in {_VALID_STATUS}" + ) + assert isinstance(call["tool_name"], str) + assert call["tool_name"].startswith("kora__"), ( + f"{call['id']}: tool_name={call['tool_name']!r} should be a " + f"kora__* MCP tool name" + ) + assert isinstance(call["caller_actor_kind"], str) and call["caller_actor_kind"] + assert isinstance(call["duration_ms"], int) and call["duration_ms"] >= 0 + assert isinstance(call["called_at"], str) and call["called_at"].endswith("Z") + assert isinstance(call["result_summary"], str) + + +# ---- 5. SECURITY: result_summary contract --------------------------- + + +@pytest.mark.asyncio +async def test_result_summary_contains_no_raw_json_payload(_isolate_config): + """Bucket §3 hard-constraint: result_summary is a SHORT TEXTUAL + summary, never a raw JSON payload dump. Operator gets a glanceable + line ("20 events returned"), not a {} blob that bloats the panel + and risks leaking internal-only fields the real MCP handler may + return. 3-layer security pattern: backend payload + TS interface + + this test.""" + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + for call in result["calls"]: + summary = call["result_summary"] + leaks = _JSON_LEAK.findall(summary) + assert leaks == [], ( + f"{call['id']}: result_summary={summary!r} contains JSON " + f"structural chars {leaks} — contract requires textual " + f"summary only, never raw payload" + ) + + +# ---- 6. SECURITY: caller_actor_kind contract ------------------------ + + +@pytest.mark.asyncio +async def test_caller_actor_kind_is_label_not_token(_isolate_config): + """Bucket §3 hard-constraint: caller_actor_kind is a LABEL + (claude_pm, kora_drone_7, etc.) — never a bearer token or token + hash. If the real handler ever defaults to the auth-token-hash + when no label is found, this guard catches it before it ships.""" + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + for call in result["calls"]: + kind = call["caller_actor_kind"] + assert _HEX_TOKEN_PIN.search(kind) is None, ( + f"{call['id']}: caller_actor_kind={kind!r} matches hex-token " + f"shape (≥16 hex chars) — contract requires a human label" + ) + assert _BASE64_TOKEN_PIN.search(kind) is None, ( + f"{call['id']}: caller_actor_kind={kind!r} matches base64-token " + f"shape (≥20 b64 chars) — contract requires a human label" + ) + # Belt+braces: kind should be short and look like a snake-case + # identifier (lowercase + underscores + optional trailing digit). + assert len(kind) <= 40, ( + f"{call['id']}: caller_actor_kind={kind!r} is implausibly long " + f"for a label ({len(kind)} chars)" + ) + + +@pytest.mark.asyncio +async def test_no_token_shaped_strings_anywhere_in_payload(_isolate_config): + """Walk-the-whole-payload guard (standardizing the pattern from + KR-WEBHOOK-EVENTS #109's full-IPv4 sweep). Asserts no field + anywhere in the response — top-level, per-call, or any future + nested dict — contains a bearer-token-shaped run of characters. + Catches a future drift like adding "auth_token_hash" to a call + entry or stuffing a session id into result_summary.""" + from kora_cli import web_server + import json as _json + + result = await web_server.list_recent_agent_activity() + blob = _json.dumps(result) + hex_leaks = _HEX_TOKEN_PIN.findall(blob) + b64_leaks = _BASE64_TOKEN_PIN.findall(blob) + assert hex_leaks == [], ( + f"payload contains hex-token-shaped string(s): {hex_leaks} — " + f"agent-activity surface must never carry credential material " + f"(bucket §3 SECURITY contract)" + ) + assert b64_leaks == [], ( + f"payload contains base64-token-shaped string(s): {b64_leaks} — " + f"agent-activity surface must never carry credential material " + f"(bucket §3 SECURITY contract)" + ) + + +# ---- 7. by_caller_24h matches the calls' caller distribution ------- + + +@pytest.mark.asyncio +async def test_by_caller_24h_keys_overlap_visible_callers(_isolate_config): + """by_caller_24h's keys must be the same set of caller labels that + appear in the visible window — otherwise the dashboard's + per-caller breakdown shows names that don't reconcile to any + individual call entry. Total-counts can differ (visible window is + a subset of 24h) but the label set must overlap.""" + from kora_cli import web_server + + result = await web_server.list_recent_agent_activity() + visible_callers = {c["caller_actor_kind"] for c in result["calls"]} + breakdown_callers = set(result["by_caller_24h"].keys()) + assert visible_callers.issubset(breakdown_callers), ( + f"visible callers {visible_callers - breakdown_callers} are missing " + f"from by_caller_24h breakdown {breakdown_callers}" + ) + # Per-caller counts non-negative + sum reconciles to total_recent_24h + assert all(v >= 0 for v in result["by_caller_24h"].values()) + assert sum(result["by_caller_24h"].values()) == result["total_recent_24h"] + + +# ---- 8. Cron-regression sanity ------------------------------------- + + +@pytest.mark.asyncio +async def test_cron_endpoint_still_works_with_agent_activity_registered(_isolate_config): + from kora_cli import web_server + + jobs = await web_server.list_cron_jobs(profile="all") + assert isinstance(jobs, list) diff --git a/web/src/App.tsx b/web/src/App.tsx index a6e6ed6bd173..cbff6685da95 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -54,6 +54,7 @@ import { UserCircle, Users, Waves, + Workflow, Wrench, X, Zap, @@ -85,6 +86,7 @@ import HealthRollupPage from "@/pages/HealthRollupPage"; import HeartbeatPanel from "@/pages/HeartbeatPanel"; import MCPClientsPanel from "@/pages/MCPClientsPanel"; import WebhookEventsPanel from "@/pages/WebhookEventsPanel"; +import AgentActivityPanel from "@/pages/AgentActivityPanel"; import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; @@ -141,6 +143,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/heartbeat": HeartbeatPanel, "/mcp-clients": MCPClientsPanel, "/webhook-events": WebhookEventsPanel, + "/agent-activity": AgentActivityPanel, "/boot-status": BootStatusPage, "/dr-state": DRStatePage, "/cost-state": CostStatePage, @@ -209,6 +212,12 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Webhook Events", icon: Inbox, }, + { + path: "/agent-activity", + labelKey: "agentActivity", + label: "Agent Activity", + icon: Workflow, + }, { path: "/mcp-clients", labelKey: "mcpClients", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 709c4caa3f17..4c2de3b3b65f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -124,6 +124,8 @@ export const api = { fetchJSON("/api/mcp/clients/list"), getRecentWebhookEvents: () => fetchJSON("/api/webhooks/events/recent"), + getRecentAgentActivity: () => + fetchJSON("/api/agent-activity/recent"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1490,3 +1492,39 @@ export interface WebhookEventsResponse { generated_at: string; total_recent_24h: number; } + +// Agent activity lens (KR-AGENT-ACTIVITY-PANEL). +// SECURITY CONTRACT (3-layer, same shape as KR-MCP-3 / WEBHOOK-EVENTS): +// * result_summary is a SHORT TEXTUAL summary — never raw JSON +// payloads. Backend tests enforce; FE renders verbatim. +// * caller_actor_kind is a LABEL (claude_pm / kora_drone_N / etc.) +// — never bearer-token-shaped or token-hash-shaped. Backend +// tests enforce against base64/hex patterns. +// * This TS type is the third enforcement layer — fields are +// declared as plain strings with the wire-contract documented; +// no separate "raw_payload" or "auth_token" fields exist. +export type AgentCallStatus = + | "ok" + | "capability_denied" + | "denied_prod_only" + | "tool_not_found" + | "handler_error" + | "timeout"; + +export interface AgentCall { + id: string; + tool_name: string; + caller_actor_kind: string; // label only — never a token or hash + called_at: string; + duration_ms: number; + status: AgentCallStatus; + result_summary: string; // textual summary — never raw JSON +} + +export interface AgentActivityResponse { + calls: AgentCall[]; + stub: boolean; + generated_at: string; + total_recent_24h: number; + by_caller_24h: Record; +} diff --git a/web/src/pages/AgentActivityPanel.tsx b/web/src/pages/AgentActivityPanel.tsx new file mode 100644 index 000000000000..221552869c88 --- /dev/null +++ b/web/src/pages/AgentActivityPanel.tsx @@ -0,0 +1,430 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertOctagon, + AlertTriangle, + Ban, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + HelpCircle, + RefreshCw, + ShieldOff, + Timer, + Workflow, + XCircle, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Toast } from "@/components/Toast"; +import { useToast } from "@/hooks/useToast"; +import { api } from "@/lib/api"; +import type { + AgentActivityResponse, + AgentCall, + AgentCallStatus, +} from "@/lib/api"; + +const STATUS_ORDER: AgentCallStatus[] = [ + "ok", + "capability_denied", + "denied_prod_only", + "tool_not_found", + "handler_error", + "timeout", +]; + +const STATUS_TONE: Record< + AgentCallStatus, + "success" | "warning" | "destructive" | "outline" +> = { + ok: "success", + capability_denied: "warning", + denied_prod_only: "warning", + tool_not_found: "outline", + handler_error: "destructive", + timeout: "destructive", +}; + +const STATUS_LABEL: Record = { + ok: "ok", + capability_denied: "capability denied", + denied_prod_only: "denied · prod-only", + tool_not_found: "tool not found", + handler_error: "handler error", + timeout: "timeout", +}; + +function StatusIcon({ status }: { status: AgentCallStatus }) { + switch (status) { + case "ok": + return ; + case "capability_denied": + return ; + case "denied_prod_only": + return ; + case "tool_not_found": + return ; + case "handler_error": + return ; + case "timeout": + return ; + } +} + +function formatTimestamp(iso: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +function formatRelative(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const deltaMs = d.getTime() - Date.now(); + const absSec = Math.abs(deltaMs) / 1000; + if (absSec < 60) { + const n = Math.round(absSec); + return deltaMs < 0 ? `${n}s ago` : `in ${n}s`; + } + const absMin = absSec / 60; + if (absMin < 60) { + const n = Math.round(absMin); + return deltaMs < 0 ? `${n}m ago` : `in ${n}m`; + } + const absHr = absMin / 60; + if (absHr < 24) { + const n = Math.round(absHr); + return deltaMs < 0 ? `${n}h ago` : `in ${n}h`; + } + const absDay = absHr / 24; + const n = Math.round(absDay); + return deltaMs < 0 ? `${n}d ago` : `in ${n}d`; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +// Visual "expensive call" bar — purely cosmetic, capped at 1000ms. +// Anything ≥ 1s gets a full bar to signal "this took real time". +function durationBarWidth(ms: number): string { + const capped = Math.min(ms, 1000); + return `${(capped / 1000) * 100}%`; +} + +interface CallRowProps { + call: AgentCall; + expanded: boolean; + onToggle: () => void; +} + +function CallRow({ call, expanded, onToggle }: CallRowProps) { + const isSlow = call.duration_ms >= 500; + return ( + + + + + {expanded && ( +
+
+ + called_at + + {formatTimestamp(call.called_at)} +
+
+ + duration + + + {call.duration_ms} ms + + + + +
+
+ + result_summary + + {call.result_summary} +
+
+ id + {call.id} +
+
+ )} +
+
+ ); +} + +export default function AgentActivityPanel() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [statusFilter, setStatusFilter] = useState("all"); + const [callerFilter, setCallerFilter] = useState("all"); + const { toast, showToast } = useToast(); + + const loadActivity = useCallback( + (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + api + .getRecentAgentActivity() + .then((resp) => setData(resp)) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + showToast(`Failed to load agent activity: ${msg}`, "error"); + }) + .finally(() => { + if (isManual) setRefreshing(false); + }); + }, + [showToast], + ); + + useEffect(() => { + loadActivity(false); + }, [loadActivity]); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // Derive caller options + status counts (from full unfiltered list). + const { callerOptions, statusCounts } = useMemo(() => { + if (!data) return { callerOptions: [] as string[], statusCounts: {} as Record }; + const callers = new Set(); + const counts: Record = {}; + for (const c of data.calls) { + callers.add(c.caller_actor_kind); + counts[c.status] = (counts[c.status] ?? 0) + 1; + } + return { + callerOptions: Array.from(callers).sort(), + statusCounts: counts as Record, + }; + }, [data]); + + const filteredCalls = useMemo(() => { + if (!data) return []; + return data.calls.filter((c) => { + if (statusFilter !== "all" && c.status !== statusFilter) return false; + if (callerFilter !== "all" && c.caller_actor_kind !== callerFilter) return false; + return true; + }); + }, [data, statusFilter, callerFilter]); + + if (data === null && !loadError) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Agent Activity

+

+ Recent agent-driven calls into Kora's /mcp endpoint. +

+
+ +
+ + {loadError && ( + + + +
+
Failed to load agent activity
+
{loadError}
+
+
+
+ )} + + {data?.stub && ( + + + +
+
+ STUB — real data wires in via CC#3's KR-MCP-RUNTIME-SURFACE ST2 +
+
+ Values shown are hardcoded sample calls (deliberately spanning + ok / capability_denied / denied_prod_only so operators see what + failures look like). ST2 swaps the endpoint body to project + from the live per-call ledger maintained by the /mcp handler. +
+
+
+
+ )} + + {data && ( + <> + {/* ── Aggregate summary strip ─────────────────────────── */} + + + + + {data.total_recent_24h} call + {data.total_recent_24h === 1 ? "" : "s"} in last 24h + + {Object.entries(data.by_caller_24h).map(([caller, count]) => ( + + {caller} + {count} + + ))} + + generated {formatRelative(data.generated_at)} ( + {formatTimestamp(data.generated_at)}) + + + + + {/* ── Filters ────────────────────────────────────────── */} + + + + status + +
+ + {STATUS_ORDER.filter((s) => statusCounts[s] > 0).map((s) => ( + + ))} +
+ + caller + + +
+
+ + {/* ── Timeline ───────────────────────────────────────── */} + {data.calls.length === 0 ? ( + + + + No agent activity yet. MCP surface lives at /mcp on port 9119. + + + ) : filteredCalls.length === 0 ? ( + + + + No calls match the current filters. + + + ) : ( +
+ {filteredCalls.map((c) => ( + toggleExpand(c.id)} + /> + ))} +
+ )} + + )} +
+ ); +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 88c510837beb..1d11fb455d0e 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -24,6 +24,7 @@ import { ShieldAlert, ShieldCheck, Waves, + Workflow, } from "lucide-react"; import type { ComponentType } from "react"; import { Badge } from "@nous-research/ui/ui/components/badge"; @@ -52,6 +53,8 @@ import type { RunbooksManifest, WebhookEventsResponse, WebhookEventStatus, + AgentActivityResponse, + AgentCallStatus, } from "@/lib/api"; type LoadStatus = @@ -79,6 +82,8 @@ interface DashboardData { mcpClients: LoadStatus; // KR-WEBHOOK-EVENTS-PANEL — public-port traffic lens (stub) webhookEvents: LoadStatus; + // KR-AGENT-ACTIVITY-PANEL — recent agent-driven /mcp calls (stub) + agentActivity: LoadStatus; } const INITIAL_DATA: DashboardData = { @@ -96,6 +101,7 @@ const INITIAL_DATA: DashboardData = { heartbeat: { state: "loading" }, mcpClients: { state: "loading" }, webhookEvents: { state: "loading" }, + agentActivity: { state: "loading" }, }; const HEALTH_TONE: Record = { @@ -643,6 +649,50 @@ function WebhookEventsCardBody({ data }: { data: WebhookEventsResponse }) { ); } +function AgentActivityCardBody({ data }: { data: AgentActivityResponse }) { + // Operator-attention contract (mirrors WebhookEvents): + // headline goes destructive when denied-class calls cross 10 in the + // visible window OR any handler_error / timeout shows up — both are + // "something is actively breaking, not just noisy". + const counts: Record = { + ok: 0, + capability_denied: 0, + denied_prod_only: 0, + tool_not_found: 0, + handler_error: 0, + timeout: 0, + }; + for (const c of data.calls) counts[c.status]++; + const deniedTotal = counts.capability_denied + counts.denied_prod_only; + const hardFailTotal = counts.handler_error + counts.timeout; + const alert = deniedTotal > 10 || hardFailTotal > 0; + const headlineClass = alert ? "text-destructive" : "text-foreground"; + return ( +
+
+ {data.total_recent_24h} + + call{data.total_recent_24h === 1 ? "" : "s"} / 24h + +
+
+ {counts.ok > 0 && {counts.ok} ok} + {deniedTotal > 0 && ( + {deniedTotal} denied + )} + {counts.handler_error > 0 && ( + + {counts.handler_error} handler-error + + )} + {counts.timeout > 0 && ( + {counts.timeout} timeout + )} +
+
+ ); +} + // ── Hero ───────────────────────────────────────────────────────────────── interface HealthHeroProps { @@ -787,6 +837,8 @@ export default function DashboardPage() { loadOne("mcpClients", () => api.getMCPClients()), // KR-WEBHOOK-EVENTS-PANEL loadOne("webhookEvents", () => api.getRecentWebhookEvents()), + // KR-AGENT-ACTIVITY-PANEL + loadOne("agentActivity", () => api.getRecentAgentActivity()), ]); if (isManual) { setRefreshing(false); @@ -819,6 +871,7 @@ export default function DashboardPage() { data.heartbeat, data.mcpClients, data.webhookEvents, + data.agentActivity, ]; const anyStubbed = ALL_SOURCES.some((s) => isStubbed(s)); @@ -1059,6 +1112,23 @@ export default function DashboardPage() { )} + + + void loadOne("agentActivity", () => + api.getRecentAgentActivity(), + ) + } + > + {data.agentActivity.state === "ready" && ( + + )} + {/* ── Bottom strip: links to other (non-admin-panel) pages ─────── */}