From f6d67514d16cc7acd67818c6a7d6bdf4878af711 Mon Sep 17 00:00:00 2001 From: "Claude (CC#2)" Date: Fri, 22 May 2026 17:45:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-ALERTS-PANEL=20=E2=80=94=20uni?= =?UTF-8?q?fied=20operator-attention=20lens=20(stub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single aggregating panel that pulls "things requiring attention" from the 12 existing panels into one place. Today each panel has its own destructive-tone trigger (cost-ladder warned, daemon paused, dead-letters > 5, reasoning halted, capability denials > 10) — operator has to glance at 12 cards to spot trouble. This panel surfaces aggregate alerts at the TOP of the dashboard. Real alert generation is DEFERRED to a backend bucket once the source panels can expose their alert state to a central collector. Stub-then-real, same pattern as the prior 7 stub panels. Single-PR scope: * GET /api/alerts/current stub — 4 representative alerts deliberately spanning critical / warning / info AND four distinct categories so the operator's first look exercises: - severity sort order (critical → warning → info) - banner border-tone mapping (red / yellow / blue) - category icon variety (DollarSign / PauseCircle / Inbox / Workflow) - click-through nav via source_panel_route stub:true keeps the FE banner visible. * AlertsPanel.tsx — title + stub banner + aggregate strip + severity-grouped rows (critical first, then warning, then info) with category icons + expandable detail + "Open {source_panel}" link button → navigates via react-router. Empty state: green CheckCircle2 + "No active alerts. Daemon healthy." (positive reinforcement; no false-alarm trigger from absent data). * AlertsBanner.tsx (NEW component) — compact dashboard-top banner. Hidden when alerts.length === 0 OR data not loaded. Severity-counted summary inline. Worst-severity border tone drives at-a-glance attention. Dismiss button uses SESSIONSTORAGE (per-tab; resets on tab close) — explicitly NOT localStorage (which would persist across browser sessions and wrongly silence future alerts). Dismissal is keyed on the alert id-set hash so new alerts re-trigger the banner even within an already-dismissed tab. * Dashboard placement: banner renders ABOVE the existing stub-data notice and the HealthHero, becoming the FIRST visible signal on the page when alerts are active. When inactive, dashboard unchanged. * Route /alerts + nav entry at the TOP of the sidebar per spec §1(d) — priority position. AlertTriangle icon. 3-layer security contract: 1. title + detail rendered as PLAIN TEXT (React default child escaping). Real alert text may eventually quote source-panel state which could in theory contain user content. FE pins via dangerouslySetInnerHTML grep on BOTH AlertsPanel.tsx and AlertsBanner.tsx. 2. Walk-payload sweeps: Anthropic key shapes (sk-ant-), Slack token shapes (xox*-), HMAC-secret shapes (32+ hex), email PII, raw Slack user IDs. Defense-in-depth — alert strings are operator-authored at the source-panel level today but future automated alert generators could leak. 3. TS interface enforces shape: typed severity + open-enum category (so backend can add new categories without an FE deploy); no raw_payload / user_message companion fields. Spec divergence flagged: source_panel_route uses the FLAT / FE convention (e.g. "/cost-state", "/operational-state", "/webhook-events", "/agent-activity") rather than the spec's /admin/ form. Every panel in this branch mounts at the flat route per App.tsx; using /admin/ would 404 on click-through. Backend test pins the flat shape so a future stub edit can't silently break navigation. Tests: * tests/kora_cli/test_web_server_alerts.py — 16 tests: shape, 4-alert stub pin, severity tier diversity, per-entry schema + valid severity enum + source_panel_route flat-form pin, all 3 security guards (walk-payload sweeps for token shapes / PII; FE source-pins for dangerouslySetInnerHTML on both AlertsPanel + AlertsBanner; sessionStorage-not- localStorage pin), empty-state positive-reinforcement source-pin, banner-hides-when-empty pin, by_severity reconciliation, cron-regression sanity. * Full admin-panel regression: 303/303 across 25 suites. * tsc -b + vite build both clean. Refs: * rafe-walker/kora-docs 17_cc_bucket_prompts/KR-ALERTS-PANEL_operator_attention_lens.md Co-Authored-By: Claude Opus 4.7 (1M context) --- kora_cli/web_server.py | 120 ++++++ tests/kora_cli/test_web_server_alerts.py | 360 ++++++++++++++++++ web/src/App.tsx | 13 + web/src/components/AlertsBanner.tsx | 129 +++++++ web/src/lib/api.ts | 47 +++ web/src/pages/AlertsPanel.tsx | 464 +++++++++++++++++++++++ web/src/pages/DashboardPage.tsx | 15 + 7 files changed, 1148 insertions(+) create mode 100644 tests/kora_cli/test_web_server_alerts.py create mode 100644 web/src/components/AlertsBanner.tsx create mode 100644 web/src/pages/AlertsPanel.tsx diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index b1df2a715d9a..3bfa0fa108a7 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -5459,6 +5459,126 @@ async def list_recent_reasoning(): } +# --------------------------------------------------------------------------- +# Unified operator-attention lens (KR-ALERTS-PANEL) +# --------------------------------------------------------------------------- +# +# Aggregates "needs operator attention" signals from across the 12 +# existing panels into one place — operator scans ONE banner instead +# of 12 destructive-tone cards. Each panel already has its own +# headline-destructive trigger; this endpoint will (when real-data +# flips) collect those triggers from a central alert-collector. +# +# v1 stub: 4 representative alerts per bucket §1(a) verbatim, +# spanning all three severity tiers (critical / warning / info) and +# four distinct categories so the operator's first look exercises +# the severity sort + category icon mapping + click-through nav. +# +# Real alert generation is DEFERRED: needs source panels to expose +# their alert state to a central collector (separate backend bucket). +# Same stub-then-real pattern as HB-PANEL / MCP-3 / WEBHOOK-EVENTS / +# AGENT-ACTIVITY / SLACK-DM / EMAIL / REASONING. +# +# 3-layer SECURITY contract (same shape as prior panels): +# 1. ``title`` + ``detail`` rendered as PLAIN TEXT by the FE — +# React's default child escaping defangs HTML/markdown/script. +# FE pins via dangerouslySetInnerHTML grep. Real alert text +# may eventually quote source-panel state which could in +# theory contain user content. +# 2. NO PII / secret patterns: walk-payload regex catches +# Anthropic key shapes, Slack token shapes, email addresses, +# raw Slack user IDs. Defense-in-depth even though alert +# strings are operator-authored at the source-panel level. +# 3. TS interface declares typed severity + category enums; no +# ``raw_payload`` / ``user_message`` companion fields exist +# on the Alert type. + + +@app.get("/api/alerts/current") +async def list_current_alerts(): + """Return currently-active operator-attention alerts. + + v1 stub — pinned shape so the deferred alert-collector backend + can swap the body without touching the FE. + + Per-alert fields: + id — opaque id + severity — "critical" | "warning" | "info" + category — alert category (drives the icon mapping); + cost_ladder | operational_state | + webhook_dead_letter | agent_capability_denied + | reasoning_halted | service_unhealthy | + boot_gate_failure + title — short headline (single line, bold) + detail — secondary explanation (rendered as text) + source_panel — short id of the originating panel + source_panel_route — FE route to navigate to; uses the flat + ``/`` convention established by + every prior panel in this branch + (not the bucket-spec's ``/admin/``) + first_seen_at — ISO-8601 when this alert first fired + """ + return { + "alerts": [ + { + "id": "stub-1", + "severity": "warning", + "category": "cost_ladder", + "title": "Budget at 78% of monthly cap", + "detail": ( + "Reasoning model downshifted opus → sonnet " + "at warn_75 rung" + ), + "source_panel": "cost", + "source_panel_route": "/cost-state", + "first_seen_at": "2026-05-22T17:48:00Z", + }, + { + "id": "stub-2", + "severity": "critical", + "category": "operational_state", + "title": "Operator paused Kora 12 min ago", + "detail": ( + "Slack DM handler dropping messages; reasoning " + "engine refusing calls" + ), + "source_panel": "ops", + "source_panel_route": "/operational-state", + "first_seen_at": "2026-05-22T17:48:00Z", + }, + { + "id": "stub-3", + "severity": "warning", + "category": "webhook_dead_letter", + "title": "8 webhook dead-letters in last 24h", + "detail": ( + "Threshold 5 exceeded; check signing-secret match" + ), + "source_panel": "webhook_events", + "source_panel_route": "/webhook-events", + "first_seen_at": "2026-05-22T13:00:00Z", + }, + { + "id": "stub-4", + "severity": "info", + "category": "agent_capability_denied", + "title": "12 capability_denied responses in 24h", + "detail": ( + "Unconfigured caller actor_kinds — review " + "mcp_callers.yaml" + ), + "source_panel": "agent_activity", + "source_panel_route": "/agent-activity", + "first_seen_at": "2026-05-22T08:00:00Z", + }, + ], + "stub": True, + "generated_at": "2026-05-22T18:00:00Z", + "total_active": 4, + "by_severity": {"critical": 1, "warning": 2, "info": 1}, + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_web_server_alerts.py b/tests/kora_cli/test_web_server_alerts.py new file mode 100644 index 000000000000..bd470487abb9 --- /dev/null +++ b/tests/kora_cli/test_web_server_alerts.py @@ -0,0 +1,360 @@ +"""Tests for the KR-ALERTS-PANEL stub endpoint + banner. + +Bucket §2 scenarios: + 1. GET /api/alerts/current returns 200 + 2. Top-level shape (alerts + stub:true + generated_at + + total_active + by_severity) + 3. 4 representative stub alerts present + 4. Stub spans all 3 severity tiers (critical / warning / info) + so the operator's first look exercises the severity sort + + banner border-tone mapping + 5. Per-entry shape + valid severity enum + source_panel_route + uses the flat ``/`` FE convention (not /admin/) + 6. SECURITY: walk-payload sweeps for token shapes (Anthropic + sk-ant-, Slack xox*-, HMAC hex), email PII, raw Slack U-IDs + 7. SECURITY: companion FE pin — AlertsPanel.tsx never uses + dangerouslySetInnerHTML for title/detail + 8. SECURITY: companion FE pin — AlertsBanner.tsx uses + sessionStorage (per-tab dismissal), NOT localStorage + (which would persist across sessions and silence alerts + wrongly) + 9. Empty state: AlertsPanel renders positive-reinforcement + CheckCircle2 + "Daemon healthy" when alerts.length === 0 + 10. by_severity sum reconciles to total_active + 11. Cron-regression sanity +""" + +import re +from pathlib import Path + +import pytest + + +_VALID_SEVERITY = {"critical", "warning", "info"} + +# Walk-payload guards — same shapes as the prior panels. +_ANTHROPIC_KEY_SHAPE = re.compile(r"\bsk-ant-[A-Za-z0-9_-]{16,}\b") +_SLACK_TOKEN_SHAPE = re.compile(r"\bxox[abprs]-[0-9A-Za-z-]{8,}\b") +_HEX_SECRET_SHAPE = re.compile(r"\b[0-9a-fA-F]{32,}\b") +_EMAIL_ADDRESS = re.compile( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" +) +_RAW_SLACK_USER_ID = re.compile(r"\bU[A-Z0-9]{8,}\b") + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PANEL_PATH = _REPO_ROOT / "web" / "src" / "pages" / "AlertsPanel.tsx" +_BANNER_PATH = _REPO_ROOT / "web" / "src" / "components" / "AlertsBanner.tsx" + + +def _strip_ts_comments(src: str) -> str: + src = re.sub(r"\{/\*.*?\*/\}", "", src, flags=re.DOTALL) + src = re.sub(r"/\*.*?\*/", "", src, flags=re.DOTALL) + src = re.sub(r"(^|[^:])//[^\n]*", r"\1", src) + return src + + +@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_current_alerts() + 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_current_alerts() + assert set(result.keys()) == { + "alerts", + "stub", + "generated_at", + "total_active", + "by_severity", + } + assert isinstance(result["alerts"], list) + assert isinstance(result["generated_at"], str) + assert isinstance(result["total_active"], int) + assert isinstance(result["by_severity"], dict) + assert result["stub"] is True + + +# ---- 3. Expected stub alerts ---------------------------------------- + + +@pytest.mark.asyncio +async def test_stub_returns_four_representative_alerts(_isolate_config): + """Pin the bucket §1(a) canonical 4-alert stub list. The deferred + real-data collector will swap the body but shape must stay + stable so the FE banner + panel render correctly during + cut-over.""" + from kora_cli import web_server + + result = await web_server.list_current_alerts() + assert len(result["alerts"]) == 4 + ids = {a["id"] for a in result["alerts"]} + assert ids == {"stub-1", "stub-2", "stub-3", "stub-4"} + + +@pytest.mark.asyncio +async def test_stub_spans_all_three_severity_tiers(_isolate_config): + """The 4 stub alerts deliberately span critical + warning + info + so the operator's first look exercises: + * severity sort order (critical → warning → info) + * banner border-tone mapping (red / yellow / blue) + * category icon variety + Pin so a future stub edit can't homogenize to one severity tier + that would mask the visual differentiation.""" + from kora_cli import web_server + + result = await web_server.list_current_alerts() + severities = {a["severity"] for a in result["alerts"]} + assert severities == {"critical", "warning", "info"} + + +# ---- 4. Per-entry shape + enums + route convention ----------------- + + +@pytest.mark.asyncio +async def test_each_alert_has_required_keys_and_valid_enums(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_current_alerts() + required = { + "id", + "severity", + "category", + "title", + "detail", + "source_panel", + "source_panel_route", + "first_seen_at", + } + for alert in result["alerts"]: + assert set(alert.keys()) == required, ( + f"{alert.get('id', '?')}: keys mismatch {set(alert.keys())}" + ) + assert alert["severity"] in _VALID_SEVERITY + assert isinstance(alert["category"], str) and alert["category"] + assert isinstance(alert["title"], str) and alert["title"] + assert isinstance(alert["detail"], str) + assert isinstance(alert["source_panel"], str) and alert["source_panel"] + assert isinstance(alert["source_panel_route"], str) + assert isinstance(alert["first_seen_at"], str) and alert[ + "first_seen_at" + ].endswith("Z") + + +@pytest.mark.asyncio +async def test_source_panel_route_uses_flat_fe_convention(_isolate_config): + """The bucket spec uses /admin/ in its example payload + but every panel in this branch is mounted at the FLAT + / route (App.tsx ROUTES table). source_panel_route is + handed to react-router's ; if it points at a + non-existent /admin/ route the click-through 404s. + Pin the flat shape so a future stub edit reverting to the + spec's /admin/ form doesn't break navigation.""" + from kora_cli import web_server + + result = await web_server.list_current_alerts() + for alert in result["alerts"]: + route = alert["source_panel_route"] + assert route.startswith("/"), ( + f"{alert['id']}: source_panel_route={route!r} must be an " + f"absolute FE path" + ) + assert not route.startswith("/admin/"), ( + f"{alert['id']}: source_panel_route={route!r} uses the " + f"/admin/ prefix — this branch mounts panels at the flat " + f"/ route per App.tsx" + ) + + +# ---- 5. SECURITY: walk-payload sweeps ------------------------------ + + +@pytest.mark.asyncio +async def test_no_token_shapes_anywhere_in_payload(_isolate_config): + """Bucket §1(a) SECURITY layer 2: walk-payload sweep for token + shapes (Anthropic sk-ant-, Slack xox*-, 32+ hex secrets). + Alert text could in theory quote source-panel state which may + contain credential material; this catches that defense-in-depth.""" + from kora_cli import web_server + import json as _json + + result = await web_server.list_current_alerts() + blob = _json.dumps(result) + anthropic_leaks = _ANTHROPIC_KEY_SHAPE.findall(blob) + slack_leaks = _SLACK_TOKEN_SHAPE.findall(blob) + hex_leaks = _HEX_SECRET_SHAPE.findall(blob) + assert anthropic_leaks == [], ( + f"payload contains Anthropic key shape(s): {anthropic_leaks}" + ) + assert slack_leaks == [], ( + f"payload contains Slack token shape(s): {slack_leaks}" + ) + assert hex_leaks == [], ( + f"payload contains long-hex secret shape(s): {hex_leaks}" + ) + + +@pytest.mark.asyncio +async def test_no_pii_anywhere_in_payload(_isolate_config): + """Walk-payload sweep for PII — email addresses + raw Slack + user IDs. Alert title/detail are operator-authored at the + source-panel level but defense-in-depth catches a future + automated alert generator that quotes user content.""" + from kora_cli import web_server + import json as _json + + result = await web_server.list_current_alerts() + blob = _json.dumps(result) + email_leaks = _EMAIL_ADDRESS.findall(blob) + slack_id_leaks = _RAW_SLACK_USER_ID.findall(blob) + assert email_leaks == [], ( + f"payload contains email address PII: {email_leaks}" + ) + assert slack_id_leaks == [], ( + f"payload contains raw Slack user ID PII: {slack_id_leaks}" + ) + + +# ---- 6. SECURITY: companion FE pins ------------------------------ + + +def test_panel_uses_no_dangerously_set_inner_html(): + """Bucket §1(a) SECURITY layer 1: title + detail rendered as + PLAIN TEXT. This guard catches a future edit that switches to + dangerouslySetInnerHTML for 'rich alert formatting' — real + alert text may quote source-panel state, which is untrusted + in the worst case.""" + code = _strip_ts_comments(_PANEL_PATH.read_text()) + assert "dangerouslySetInnerHTML" not in code, ( + "AlertsPanel.tsx must not use dangerouslySetInnerHTML — " + "alert text is rendered as plain text" + ) + + +def test_banner_uses_no_dangerously_set_inner_html(): + """Same plain-text contract applies to the dashboard banner.""" + code = _strip_ts_comments(_BANNER_PATH.read_text()) + assert "dangerouslySetInnerHTML" not in code, ( + "AlertsBanner.tsx must not use dangerouslySetInnerHTML" + ) + + +def test_panel_renders_title_and_detail_as_child_expressions(): + """Belt+braces: title + detail rendered as JSX child expressions + so React's default escaping kicks in. Catches a future edit + that pipes them through a markdown lib or HTML formatter.""" + src = _PANEL_PATH.read_text() + assert "alert.title" in src and re.search( + r"\{[^{}]*alert\.title[^{}]*\}", src + ), "alert.title should render as a JSX child expression" + assert "alert.detail" in src and re.search( + r"\{[^{}]*alert\.detail[^{}]*\}", src + ), "alert.detail should render as a JSX child expression" + + +# ---- 7. Banner dismissal scope ---------------------------------- + + +def test_banner_uses_sessionStorage_not_localStorage(): + """Bucket §1(c): banner dismissal is per-TAB (resets on tab + close), NOT per-browser-and-persistent. The spec is explicit + that this is just a visual collapse — NOT acknowledged-state — + so the dismissal must NOT survive a tab close (otherwise the + operator could miss a future alert). + + sessionStorage = per-tab; localStorage = per-browser-persistent. + This pin catches a future edit that switches to localStorage.""" + code = _strip_ts_comments(_BANNER_PATH.read_text()) + assert "sessionStorage" in code, ( + "AlertsBanner.tsx must use sessionStorage (per-tab " + "dismissal) per bucket §1(c)" + ) + assert "localStorage" not in code, ( + "AlertsBanner.tsx must NOT use localStorage — that would " + "persist dismissal across browser sessions, wrongly " + "suppressing future alerts" + ) + + +def test_banner_hides_when_no_active_alerts(): + """Bucket §1(c): banner is shown ONLY when alerts.length > 0. + Source-pin: the component returns null when data is empty so + the dashboard layout stays clean (no false-alarm trigger + from absent data, per bucket §4 ship-checklist).""" + src = _BANNER_PATH.read_text() + assert re.search( + r"data\.alerts\.length\s*===\s*0", src + ), ( + "AlertsBanner.tsx should branch on data.alerts.length === 0 " + "and hide the banner in that case" + ) + + +# ---- 8. Empty-state positive reinforcement --------------------- + + +def test_panel_renders_daemon_healthy_empty_state(): + """Bucket §1(b): empty state shows positive reinforcement + ('Daemon healthy.' + green CheckCircle2). Source-pin: the + text + the success-toned icon must both appear in the panel + so a future refactor doesn't silently drop the affordance.""" + src = _PANEL_PATH.read_text() + assert "Daemon healthy" in src, ( + "AlertsPanel.tsx empty state should say 'Daemon healthy.'" + ) + assert "CheckCircle2" in src, ( + "AlertsPanel.tsx empty state should render the green " + "CheckCircle2 icon" + ) + + +# ---- 9. by_severity reconciliation ------------------------------- + + +@pytest.mark.asyncio +async def test_by_severity_sum_reconciles_to_total_active(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_current_alerts() + severity_sum = sum(result["by_severity"].values()) + assert severity_sum == result["total_active"], ( + f"by_severity sums to {severity_sum} but total_active is " + f"{result['total_active']}" + ) + assert set(result["by_severity"].keys()) == _VALID_SEVERITY + + +# ---- 10. Cron-regression sanity -------------------------------- + + +@pytest.mark.asyncio +async def test_cron_endpoint_still_works_with_alerts_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 76ad6a7f91a6..75c862c4f9ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { } from "react-router-dom"; import { Activity, + AlertTriangle, BarChart3, BookOpen, BookOpenCheck, @@ -93,6 +94,7 @@ import AgentActivityPanel from "@/pages/AgentActivityPanel"; import SlackDMPanel from "@/pages/SlackDMPanel"; import EmailPanel from "@/pages/EmailPanel"; import ReasoningPanel from "@/pages/ReasoningPanel"; +import AlertsPanel from "@/pages/AlertsPanel"; import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; @@ -151,6 +153,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/webhook-events": WebhookEventsPanel, "/agent-activity": AgentActivityPanel, "/reasoning": ReasoningPanel, + "/alerts": AlertsPanel, "/slack-dm": SlackDMPanel, "/email": EmailPanel, "/boot-status": BootStatusPage, @@ -185,6 +188,16 @@ function ChatRouteSink() { } const BUILTIN_NAV_REST: NavItem[] = [ + { + // Alerts at the TOP of the rest-of-sidebar per spec §1(d) — + // priority position so the operator sees it first. AlertTriangle + // icon (warning glyph; the banner on /overview already uses + // severity-tinted variants per active state). + path: "/alerts", + labelKey: "alerts", + label: "Alerts", + icon: AlertTriangle, + }, { path: "/", labelKey: "overview", diff --git a/web/src/components/AlertsBanner.tsx b/web/src/components/AlertsBanner.tsx new file mode 100644 index 000000000000..664dbdaaf902 --- /dev/null +++ b/web/src/components/AlertsBanner.tsx @@ -0,0 +1,129 @@ +// Compact alerts banner for the top of DashboardPage. Hidden when +// there are no active alerts (no false-alarm trigger from absent +// data). Dismissible per-tab via sessionStorage — alerts come BACK +// next session because they're derived from source-panel state, not +// acknowledged-and-cleared. +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + AlertOctagon, + AlertTriangle, + ArrowRight, + Info, + X, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import type { AlertsResponse } from "@/lib/api"; + +// sessionStorage key — per-tab semantics: dismissal lives for the +// tab's lifetime and resets on close. The spec explicitly says NOT +// acknowledged-state; just a visual collapse. localStorage would be +// per-browser and persist across tab close (wrong); sessionStorage +// is the right scope. +const DISMISS_KEY = "kora.alerts.banner.dismissed"; + +export function AlertsBanner({ data }: { data: AlertsResponse | null }) { + const [dismissed, setDismissed] = useState(false); + + // Read dismissal once on mount. The key carries a hash of the + // current alert id-set so re-dismissal is required when new + // alerts arrive — otherwise dismissing once would suppress all + // future alerts until tab close. + const alertIdsKey = data + ? data.alerts.map((a) => a.id).sort().join(",") + : ""; + + useEffect(() => { + try { + const stored = sessionStorage.getItem(DISMISS_KEY); + setDismissed(stored !== null && stored === alertIdsKey); + } catch { + // sessionStorage can throw in private-browsing on some + // browsers; treat as "not dismissed" rather than crashing. + setDismissed(false); + } + }, [alertIdsKey]); + + function handleDismiss() { + try { + sessionStorage.setItem(DISMISS_KEY, alertIdsKey); + } catch { + // ignore — best-effort persistence + } + setDismissed(true); + } + + // Hidden when: + // - data not loaded yet (no false-alarm flash before fetch) + // - no active alerts (empty state shown in the full panel, not + // as a banner — dashboard stays clean) + // - operator dismissed for this tab + if (data === null) return null; + if (data.alerts.length === 0) return null; + if (dismissed) return null; + + const critical = data.by_severity.critical ?? 0; + const warning = data.by_severity.warning ?? 0; + const info = data.by_severity.info ?? 0; + + // Banner border tone tracks the worst-severity in the active set + // so the operator's peripheral vision catches it before reading. + const borderClass = + critical > 0 + ? "border-destructive/50 bg-destructive/5" + : warning > 0 + ? "border-warning/50 bg-warning/5" + : "border-primary/40 bg-primary/5"; + + return ( + + + {critical > 0 ? ( + + ) : warning > 0 ? ( + + ) : ( + + )} + + {data.total_active} active alert + {data.total_active === 1 ? "" : "s"} + + {critical > 0 && ( + + + {critical} critical + + )} + {warning > 0 && ( + + + {warning} warning + + )} + {info > 0 && ( + + + {info} info + + )} + + Open alerts + + + + + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c505bdf4f33a..0f103d1a15d0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -132,6 +132,8 @@ export const api = { fetchJSON("/api/email/recent"), getRecentReasoning: () => fetchJSON("/api/reasoning/recent"), + getCurrentAlerts: () => + fetchJSON("/api/alerts/current"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1737,3 +1739,48 @@ export interface ReasoningResponse { by_status_24h: Record; tokens_total_24h: { input: number; output: number }; } + +// Unified operator-attention lens (KR-ALERTS-PANEL). +// 3-layer SECURITY CONTRACT: +// 1. title + detail rendered as PLAIN TEXT — React's default +// child escaping defangs HTML/markdown/script. FE pins via +// dangerouslySetInnerHTML grep. Real alert text may +// eventually quote source-panel state. +// 2. NO PII / secret patterns: backend tests sweep payload for +// Anthropic key shapes, Slack tokens, email addresses, raw +// Slack user IDs. Defense-in-depth. +// 3. This TS type enforces shape; no raw_payload / user_message +// companion fields exist on Alert. +export type AlertSeverity = "critical" | "warning" | "info"; + +// Open enum: backend may add new categories without breaking the FE. +// Known categories drive specific icons; unknown values fall back to +// a generic AlertTriangle icon. +export type AlertCategory = + | "cost_ladder" + | "operational_state" + | "webhook_dead_letter" + | "agent_capability_denied" + | "reasoning_halted" + | "service_unhealthy" + | "boot_gate_failure" + | string; + +export interface Alert { + id: string; + severity: AlertSeverity; + category: AlertCategory; + title: string; // plain text + detail: string; // plain text + source_panel: string; // short id (cost / ops / webhook_events / ...) + source_panel_route: string; // FE route to navigate to + first_seen_at: string; +} + +export interface AlertsResponse { + alerts: Alert[]; + stub: boolean; + generated_at: string; + total_active: number; + by_severity: Record; +} diff --git a/web/src/pages/AlertsPanel.tsx b/web/src/pages/AlertsPanel.tsx new file mode 100644 index 000000000000..9106b729b0b5 --- /dev/null +++ b/web/src/pages/AlertsPanel.tsx @@ -0,0 +1,464 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + AlertOctagon, + AlertTriangle, + ArrowRight, + Brain, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Cloud, + DollarSign, + HelpCircle, + Inbox, + Info, + PauseCircle, + PowerSquare, + RefreshCw, + Workflow, +} from "lucide-react"; +import type { ComponentType } from "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 { + Alert, + AlertCategory, + AlertSeverity, + AlertsResponse, +} from "@/lib/api"; + +const SEVERITY_TONE: Record< + AlertSeverity, + "destructive" | "warning" | "outline" +> = { + critical: "destructive", + warning: "warning", + // "outline" reads as muted-blue against the card chrome — close + // enough to "info" without introducing a new tone in the badge + // palette. + info: "outline", +}; + +const SEVERITY_LABEL: Record = { + critical: "critical", + warning: "warning", + info: "info", +}; + +// Severity sort key — critical first, then warning, then info. +const SEVERITY_ORDER: Record = { + critical: 0, + warning: 1, + info: 2, +}; + +// Map known categories to lucide icons that match the source panel's +// own icon convention. Unknown categories fall back to AlertTriangle +// (the open-enum on AlertCategory means backend can add new ones +// without an FE deploy). +const CATEGORY_ICON: Record> = { + cost_ladder: DollarSign, + operational_state: PauseCircle, + webhook_dead_letter: Inbox, + agent_capability_denied: Workflow, + reasoning_halted: Brain, + service_unhealthy: Cloud, + boot_gate_failure: PowerSquare, +}; + +function categoryIcon(category: AlertCategory): ComponentType<{ className?: string }> { + return CATEGORY_ICON[category] ?? AlertTriangle; +} + +function SeverityIcon({ severity }: { severity: AlertSeverity }) { + switch (severity) { + case "critical": + return ; + case "warning": + return ; + case "info": + 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`; +} + +interface AlertRowProps { + alert: Alert; + expanded: boolean; + onToggle: () => void; +} + +function AlertRow({ alert, expanded, onToggle }: AlertRowProps) { + const Icon = categoryIcon(alert.category); + return ( + + +
+ + + + +
+ + {expanded && ( +
+
+ + first_seen_at + + {formatTimestamp(alert.first_seen_at)} +
+
+ + category + + {alert.category} +
+
+ + source_panel + + {alert.source_panel} +
+
+ + source_panel_route + + {alert.source_panel_route} +
+
+ id + {alert.id} +
+
+ )} +
+
+ ); +} + +interface SeverityGroupProps { + severity: AlertSeverity; + alerts: Alert[]; + expandedIds: Set; + onToggle: (id: string) => void; +} + +function SeverityGroup({ + severity, + alerts, + expandedIds, + onToggle, +}: SeverityGroupProps) { + if (alerts.length === 0) return null; + return ( +
+
+ + + {SEVERITY_LABEL[severity]} + + + ({alerts.length}) + +
+ {alerts.map((a) => ( + onToggle(a.id)} + /> + ))} +
+ ); +} + +export default function AlertsPanel() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const { toast, showToast } = useToast(); + + const loadAlerts = useCallback( + (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + api + .getCurrentAlerts() + .then((resp) => setData(resp)) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + showToast(`Failed to load alerts: ${msg}`, "error"); + }) + .finally(() => { + if (isManual) setRefreshing(false); + }); + }, + [showToast], + ); + + useEffect(() => { + loadAlerts(false); + }, [loadAlerts]); + + 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; + }); + }, []); + + const groupedAlerts = useMemo(() => { + if (!data) return { critical: [], warning: [], info: [] }; + const sorted = [...data.alerts].sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity], + ); + return { + critical: sorted.filter((a) => a.severity === "critical"), + warning: sorted.filter((a) => a.severity === "warning"), + info: sorted.filter((a) => a.severity === "info"), + }; + }, [data]); + + if (data === null && !loadError) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Operator Alerts

+

+ Aggregated "needs attention" signals from across the + 12 panels. +

+
+ +
+ + {loadError && ( + + + +
+
Failed to load alerts
+
{loadError}
+
+
+
+ )} + + {data?.stub && ( + + + +
+
+ STUB — real alert collection wires in via a + deferred backend bucket +
+
+ Values shown are hardcoded sample alerts (deliberately + spanning all three severity tiers + four categories + so the operator sees the severity sort + category + icon mapping + click-through nav). Real-data flip + requires source panels to expose their alert state + to a central collector. +
+
+
+
+ )} + + {data && data.alerts.length === 0 && ( + + + + No active alerts. + + Daemon healthy. + + + + )} + + {data && data.alerts.length > 0 && ( + <> + {/* ── Aggregate strip ────────────────────────────── */} + + + + + {data.total_active} active alert + {data.total_active === 1 ? "" : "s"} + + {data.by_severity.critical > 0 && ( + + + {data.by_severity.critical} critical + + )} + {data.by_severity.warning > 0 && ( + + + {data.by_severity.warning} warning + + )} + {data.by_severity.info > 0 && ( + + + {data.by_severity.info} info + + )} + + generated {formatRelative(data.generated_at)} ( + {formatTimestamp(data.generated_at)}) + + + + + {/* ── Severity groups (critical → warning → info) ── */} + + + + + )} + + {!data && !loadError && ( + + + + No alerts data. + + + )} +
+ ); +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 0b386b04ffe8..66e2cb4b0153 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -63,7 +63,9 @@ import type { EmailResponse, EmailHandledStatus, ReasoningResponse, + AlertsResponse, } from "@/lib/api"; +import { AlertsBanner } from "@/components/AlertsBanner"; type LoadStatus = | { state: "loading" } @@ -98,6 +100,8 @@ interface DashboardData { email: LoadStatus; // KR-REASONING-PANEL — Kora ReasoningEngine activity (stub) reasoning: LoadStatus; + // KR-ALERTS-PANEL — operator-attention banner (stub) + alerts: LoadStatus; } const INITIAL_DATA: DashboardData = { @@ -119,6 +123,7 @@ const INITIAL_DATA: DashboardData = { slackDM: { state: "loading" }, email: { state: "loading" }, reasoning: { state: "loading" }, + alerts: { state: "loading" }, }; const HEALTH_TONE: Record = { @@ -1032,6 +1037,8 @@ export default function DashboardPage() { loadOne("email", () => api.getRecentEmail()), // KR-REASONING-PANEL loadOne("reasoning", () => api.getRecentReasoning()), + // KR-ALERTS-PANEL — drives the top-of-page banner + loadOne("alerts", () => api.getCurrentAlerts()), ]); if (isManual) { setRefreshing(false); @@ -1068,6 +1075,7 @@ export default function DashboardPage() { data.slackDM, data.email, data.reasoning, + data.alerts, ]; const anyStubbed = ALL_SOURCES.some((s) => isStubbed(s)); @@ -1109,6 +1117,13 @@ export default function DashboardPage() { + {/* KR-ALERTS-PANEL banner — TOP placement per spec §1(c). + Self-hides when no active alerts (no false-alarm trigger + from absent data) and when operator dismissed for this tab. */} + + {anyStubbed && (