diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index cdec563defd7..1cd13724a2af 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -5992,6 +5992,109 @@ async def get_daemon_snapshot(): return {"error": "no_snapshot", "stale": True} return snap +# Panel-view instrumentation sink (KR-PANEL-USE-INSTRUMENTATION) +# --------------------------------------------------------------------------- +# +# Per Council R3 lock + sub-cut (c): records which top-level pages / +# panels the operator opens. Over time the JSONL accretes usage data +# that informs any future panel-design decisions — no shape changes +# happen blind. +# +# Path B chosen (PM confirmed): separate ``${KORA_HOME}/panel_views.jsonl`` +# file rather than extending the audit log's SeamName Literal. The +# audit log is a forensic/compliance surface (Pydantic ``extra="forbid"`` +# + tight SeamName Literal kept intentionally narrow); panel_views are +# operator-UX telemetry with a different lifecycle, different +# consumers, and likely different retention semantics. Mixing them +# would muddle both contracts (e.g., a future +# ``read_audit_entries(seam=None)`` query would surface panel-views +# unexpectedly — a latent contract violation). +# +# Write discipline mirrors ``kora_cli/audit/jsonl_sink.py``: +# * Best-effort: OSError → WARN-log + return (never crash the +# frontend; instrumentation must never break operator UX) +# * mkdir(parents=True, exist_ok=True) before append (KORA_HOME +# may not exist on fresh installs) +# * Atomic single-line append per request +# +# Reader is out-of-scope for this bucket — we just write; consumers +# come later when we have data to act on. + +PANEL_VIEWS_LOG_FILENAME = "panel_views.jsonl" +_PANEL_NAME_MAX = 128 +_SESSION_ID_MAX = 64 + + +def _resolve_panel_views_log_path() -> Path: + """Resolve to ``/panel_views.jsonl``. Re-resolves on + every call so monkeypatch in tests works without ContextVar + plumbing (per the #137 fixture-isolation lesson).""" + return get_kora_home() / PANEL_VIEWS_LOG_FILENAME + + +@app.post("/api/panel_view") +async def emit_panel_view(payload: Dict[str, Any]) -> Dict[str, Any]: + """Operator-UX telemetry sink: record a single panel view. + + Fire-and-forget from the FE ``usePanelView`` hook (zero + operator-visible latency target). Validation: + + * ``panel_name`` — required non-empty string, truncated to + ``_PANEL_NAME_MAX`` chars. Empty → 400 since an unattributed + view event has no analytical value. + * ``session_id`` — optional, truncated to ``_SESSION_ID_MAX`` + chars. Missing → recorded as ``"unknown"`` so cold-tab + emits still produce countable rows. + + Returns ``{"ok": True}`` on accepted writes. Returns ``{"ok": True, + "warning": "write_failed"}`` on JSONL append failure so the FE's + fire-and-forget POST doesn't surface an error and confuse the + operator (instrumentation MUST NOT break UX). + """ + panel_name_raw = payload.get("panel_name", "") + panel_name = ( + str(panel_name_raw).strip()[:_PANEL_NAME_MAX] + if panel_name_raw is not None + else "" + ) + if not panel_name: + # 400 here is intentional — empty panel_name is a FE bug, not + # a transient runtime condition. Surfaces in dev quickly. + raise HTTPException(status_code=400, detail="panel_name required") + + session_id_raw = payload.get("session_id") + if not session_id_raw: + session_id = "unknown" + else: + session_id = str(session_id_raw).strip()[:_SESSION_ID_MAX] or "unknown" + + from datetime import datetime, timezone + + entry = { + "kind": "panel_view", + "panel_name": panel_name, + "session_id": session_id, + "emitted_at": datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + } + + log_path = _resolve_panel_views_log_path() + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except OSError as exc: + _log.warning( + "[kora.panel_view] write failed (%s): %r — FE caller " + "swallows; instrumentation must never break UX", + log_path, + exc, + ) + return {"ok": True, "warning": "write_failed"} + + return {"ok": True} + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) diff --git a/tests/kora_cli/test_web_server_panel_view.py b/tests/kora_cli/test_web_server_panel_view.py new file mode 100644 index 000000000000..e238029316b9 --- /dev/null +++ b/tests/kora_cli/test_web_server_panel_view.py @@ -0,0 +1,346 @@ +"""Tests for the KR-PANEL-USE-INSTRUMENTATION /api/panel_view sink. + +Per Council R3 lock + sub-cut (c): records which top-level pages / +panels the operator opens. Path B chosen (PM confirmed): separate +``${KORA_HOME}/panel_views.jsonl`` file rather than extending the +audit log's SeamName Literal. + +Scenarios: + 1. POST with valid payload → 200 + JSONL line appended with + {kind, panel_name, session_id, emitted_at} + 2. Empty panel_name → 400 (FE bug, not transient runtime + condition — surfaces in dev quickly) + 3. Oversized panel_name (>128 chars) → truncated to 128 + 4. Missing session_id → recorded as "unknown" (cold tabs still + produce countable rows) + 5. Empty/blank session_id → "unknown" + 6. Oversized session_id (>64 chars) → truncated to 64 + 7. Multiple POSTs → multiple JSONL lines appended (append-only + semantic) + 8. Each entry has the required keys + valid emitted_at ISO shape + 9. JSONL file is created on first write (KORA_HOME may not exist + on fresh installs) + 10. JSONL write to read-only path → graceful warning, still + returns ok:true (instrumentation must never break UX) + 11. SECURITY: no FE-supplied "kind" field can override the + hardcoded kind="panel_view" + 12. Front-end FS pin: usePanelView hook source exists + posts to + /api/panel_view + 13. Front-end source-pin: every top-level page/panel imports + usePanelView (instrumented inventory matches the 34 panels) +""" + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List + +import pytest +from fastapi import HTTPException + +from tests.kora_cli._panel_test_helpers import isolated_kora_home + + +PANEL_VIEWS_FILENAME = "panel_views.jsonl" + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_HOOK_PATH = _REPO_ROOT / "web" / "src" / "hooks" / "usePanelView.ts" +_PAGES_DIR = _REPO_ROOT / "web" / "src" / "pages" + + +@pytest.fixture +def env(tmp_path, monkeypatch): + return isolated_kora_home(tmp_path, monkeypatch) + + +def _read_log_lines(env_path: Path) -> List[Dict[str, Any]]: + log_path = env_path / PANEL_VIEWS_FILENAME + if not log_path.is_file(): + return [] + return [ + json.loads(line) for line in log_path.read_text().splitlines() if line.strip() + ] + + +# ---- 1. Happy path ----------------------------------------------- + + +@pytest.mark.asyncio +async def test_valid_payload_appends_jsonl_line(env): + from kora_cli import web_server + + result = await web_server.emit_panel_view( + {"panel_name": "AlertsPanel", "session_id": "sess-abc-123"} + ) + assert result == {"ok": True} + + rows = _read_log_lines(env) + assert len(rows) == 1 + row = rows[0] + assert row["kind"] == "panel_view" + assert row["panel_name"] == "AlertsPanel" + assert row["session_id"] == "sess-abc-123" + assert "emitted_at" in row + + +# ---- 2. Validation: empty panel_name → 400 ---------------------- + + +@pytest.mark.asyncio +async def test_empty_panel_name_returns_400(env): + from kora_cli import web_server + + with pytest.raises(HTTPException) as exc_info: + await web_server.emit_panel_view({"panel_name": "", "session_id": "s"}) + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_missing_panel_name_returns_400(env): + from kora_cli import web_server + + with pytest.raises(HTTPException) as exc_info: + await web_server.emit_panel_view({"session_id": "s"}) + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_whitespace_only_panel_name_returns_400(env): + from kora_cli import web_server + + with pytest.raises(HTTPException) as exc_info: + await web_server.emit_panel_view({"panel_name": " "}) + assert exc_info.value.status_code == 400 + + +# ---- 3. Oversized panel_name truncated -------------------------- + + +@pytest.mark.asyncio +async def test_oversized_panel_name_truncated_to_128(env): + from kora_cli import web_server + + long_name = "X" * 500 + await web_server.emit_panel_view({"panel_name": long_name}) + rows = _read_log_lines(env) + assert len(rows[0]["panel_name"]) == 128 + + +# ---- 4-6. session_id semantics ---------------------------------- + + +@pytest.mark.asyncio +async def test_missing_session_id_recorded_as_unknown(env): + from kora_cli import web_server + + await web_server.emit_panel_view({"panel_name": "AlertsPanel"}) + rows = _read_log_lines(env) + assert rows[0]["session_id"] == "unknown" + + +@pytest.mark.asyncio +async def test_empty_session_id_recorded_as_unknown(env): + from kora_cli import web_server + + await web_server.emit_panel_view( + {"panel_name": "AlertsPanel", "session_id": ""} + ) + rows = _read_log_lines(env) + assert rows[0]["session_id"] == "unknown" + + +@pytest.mark.asyncio +async def test_oversized_session_id_truncated_to_64(env): + from kora_cli import web_server + + long_sid = "s" * 500 + await web_server.emit_panel_view( + {"panel_name": "AlertsPanel", "session_id": long_sid} + ) + rows = _read_log_lines(env) + assert len(rows[0]["session_id"]) == 64 + + +# ---- 7. Append-only semantic ----------------------------------- + + +@pytest.mark.asyncio +async def test_multiple_posts_append_separate_lines(env): + from kora_cli import web_server + + for i in range(5): + await web_server.emit_panel_view( + {"panel_name": f"Panel{i}", "session_id": f"s{i}"} + ) + rows = _read_log_lines(env) + assert len(rows) == 5 + assert [r["panel_name"] for r in rows] == [f"Panel{i}" for i in range(5)] + + +# ---- 8. Entry shape --------------------------------------------- + + +@pytest.mark.asyncio +async def test_entry_shape_has_required_keys_and_iso_timestamp(env): + from kora_cli import web_server + + await web_server.emit_panel_view( + {"panel_name": "AlertsPanel", "session_id": "s1"} + ) + rows = _read_log_lines(env) + row = rows[0] + assert set(row.keys()) == {"kind", "panel_name", "session_id", "emitted_at"} + # Z-suffixed UTC ISO, matches the writer's strftime + assert re.match( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", row["emitted_at"] + ) + + +# ---- 9. KORA_HOME doesn't exist yet ---------------------------- + + +@pytest.mark.asyncio +async def test_writes_when_kora_home_doesnt_exist(tmp_path, monkeypatch): + """Fresh install: KORA_HOME may not have been created. Endpoint + must mkdir(parents=True, exist_ok=True) before append.""" + missing_home = tmp_path / "freshly_provisioned" / ".kora" + assert not missing_home.exists() + + monkeypatch.setenv("HERMES_HOME", str(missing_home)) + monkeypatch.setenv("KORA_HOME", str(missing_home)) + monkeypatch.setattr("kora_constants.get_kora_home", lambda: missing_home) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: missing_home) + monkeypatch.setattr("kora_cli.web_server.get_kora_home", lambda: missing_home) + + from kora_cli import web_server + + result = await web_server.emit_panel_view( + {"panel_name": "AlertsPanel"} + ) + assert result == {"ok": True} + log_path = missing_home / PANEL_VIEWS_FILENAME + assert log_path.is_file() + + +# ---- 10. Write failure returns ok:true with warning ----------- + + +@pytest.mark.asyncio +async def test_write_failure_returns_ok_true_with_warning(tmp_path, monkeypatch): + """OSError on write must NOT crash the FE caller; instrumentation + must never break UX. Endpoint logs + returns ok:true with a + warning field so the operator-facing path stays green.""" + # Point at a path inside a read-only directory so the open() raises. + readonly_dir = tmp_path / "readonly" + readonly_dir.mkdir(mode=0o555) # r-x for owner only + try: + monkeypatch.setenv("KORA_HOME", str(readonly_dir)) + monkeypatch.setattr("kora_constants.get_kora_home", lambda: readonly_dir) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: readonly_dir) + monkeypatch.setattr( + "kora_cli.web_server.get_kora_home", lambda: readonly_dir + ) + + from kora_cli import web_server + + result = await web_server.emit_panel_view( + {"panel_name": "AlertsPanel"} + ) + # FE never sees a 500; the warning is for forensic JSON-parse + # in case operator queries the response. + assert result.get("ok") is True + assert result.get("warning") == "write_failed" + finally: + # Restore mode so pytest can clean up tmp_path + readonly_dir.chmod(0o755) + + +# ---- 11. SECURITY: FE-supplied "kind" can't override ---------- + + +@pytest.mark.asyncio +async def test_fe_supplied_kind_field_ignored(env): + """The hardcoded kind="panel_view" must NOT be overrideable by + FE payload — protects downstream JSONL queries that filter on + kind from being polluted by a malicious / buggy FE that injects + a different kind value to evade filters.""" + from kora_cli import web_server + + await web_server.emit_panel_view( + { + "panel_name": "AlertsPanel", + "session_id": "s1", + "kind": "audit", # attempted override + } + ) + rows = _read_log_lines(env) + assert rows[0]["kind"] == "panel_view" + + +# ---- 12. Frontend hook source pin ------------------------------ + + +def test_use_panel_view_hook_source_exists(): + assert _HOOK_PATH.is_file(), f"missing: {_HOOK_PATH}" + + +def test_use_panel_view_posts_to_panel_view_endpoint(): + src = _HOOK_PATH.read_text() + # POST to /api/panel_view via fetchJSON wrapper + assert "/api/panel_view" in src + assert 'method: "POST"' in src + # panel_name + session_id in body + assert "panel_name" in src and "session_id" in src + + +def test_use_panel_view_swallows_errors(): + """Instrumentation must never break UX — .catch on the POST + must be present.""" + src = _HOOK_PATH.read_text() + assert ".catch(" in src + + +# ---- 13. Every top-level page/panel calls the hook ----------- + + +def test_every_top_level_page_imports_use_panel_view(): + """Inventory pin: every web/src/pages/*.tsx must import + + invoke usePanelView with its file's component name. Prevents + a future page being added without instrumentation.""" + missing_import = [] + missing_call = [] + expected_pages = sorted(p.stem for p in _PAGES_DIR.glob("*.tsx")) + + for name in expected_pages: + src = (_PAGES_DIR / f"{name}.tsx").read_text() + if "usePanelView" not in src: + missing_import.append(name) + continue + if f'usePanelView("{name}")' not in src: + missing_call.append(name) + + assert not missing_import, ( + f"pages missing usePanelView import: {missing_import}" + ) + assert not missing_call, ( + f"pages with usePanelView import but no matching call " + f"`usePanelView(\"\")`: {missing_call}" + ) + + +def test_panel_inventory_count_matches_expected(): + """Inventory: count of top-level pages should match the spec's + instrumented count. A drift means either a new page was added + (good — but should appear in the next PR's instrumentation + audit) or a page was removed (also should be reflected). Pin + catches both directions.""" + pages = list(_PAGES_DIR.glob("*.tsx")) + # Current count is 34 per the instrumentation pass. Update this + # number alongside any page-set change so the pin stays accurate. + assert len(pages) == 34, ( + f"top-level page count drifted: found {len(pages)}, " + f"expected 34 (KR-PANEL-USE-INSTRUMENTATION snapshot). " + f"Update this assertion when adding/removing pages so the " + f"instrumentation audit stays accurate." + ) diff --git a/web/src/hooks/usePanelView.ts b/web/src/hooks/usePanelView.ts new file mode 100644 index 000000000000..d337bd5ecc38 --- /dev/null +++ b/web/src/hooks/usePanelView.ts @@ -0,0 +1,83 @@ +// Panel-view instrumentation hook — KR-PANEL-USE-INSTRUMENTATION. +// +// Per Council R3 lock sub-cut (c): every top-level *Page.tsx / +// *Panel.tsx calls usePanelView at mount so the backend +// /api/panel_view sink accretes operator-UX telemetry that +// informs any future panel-design decisions. +// +// Fire-and-forget semantics: +// * Failures are silently swallowed — instrumentation MUST +// NEVER break operator UX (the panel still renders even if +// the POST fails / the daemon is unreachable / sessionStorage +// is denied by browser policy). +// * Empty useEffect deps (only panelName, which is a +// compile-time constant per call site) so re-renders don't +// produce duplicate emits. NOTE: React 18+ strict-mode runs +// effects twice in dev — that's a documented dev-only quirk +// and irrelevant for prod telemetry analysis. +// +// Session-id discipline: +// * sessionStorage key "kora_session_id" (per-tab scope; resets +// on tab close). +// * Auto-generated UUID on first access if missing, so analytics +// can group views by tab session rather than every event being +// "unknown". +// * Falls back to "unknown" if sessionStorage throws (private +// browsing / strict cookie modes) so the emit still goes +// through. + +import { useEffect } from "react"; +import { fetchJSON } from "@/lib/api"; + +const SESSION_ID_KEY = "kora_session_id"; + +function getOrCreateSessionId(): string { + try { + const existing = window.sessionStorage.getItem(SESSION_ID_KEY); + if (existing) return existing; + // Per-tab uuid — crypto.randomUUID is available in modern browsers + // (Chromium 92+ / Firefox 95+ / Safari 15.4+); both Kora's + // supported targets. Fallback to Math.random base36 for the + // unlikely older-browser case so we don't crash the hook. + const next = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `tab-${Math.random().toString(36).slice(2, 10)}-${Date.now()}`; + window.sessionStorage.setItem(SESSION_ID_KEY, next); + return next; + } catch { + return "unknown"; + } +} + +/** + * Emits a panel_view event when the component mounts. + * + * Usage in any top-level Page/Panel (NOT internal components): + * export default function AlertsPanel() { + * usePanelView("AlertsPanel"); + * // ... component body ... + * } + * + * The panel_name string should match the component name verbatim + * so downstream queries against panel_views.jsonl can group by + * file/component without extra mapping. + */ +export function usePanelView(panelName: string): void { + useEffect(() => { + const sessionId = getOrCreateSessionId(); + fetchJSON("/api/panel_view", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + panel_name: panelName, + session_id: sessionId, + }), + }).catch(() => { + // Silent failure — instrumentation must never break UX. + // The daemon may be down, the browser may have denied the + // request, the user may have content-blockers; none of + // those should surface to the operator. + }); + }, [panelName]); +} diff --git a/web/src/pages/AgentActivityPanel.tsx b/web/src/pages/AgentActivityPanel.tsx index 4e37ae864fc2..5e4936c7c1ee 100644 --- a/web/src/pages/AgentActivityPanel.tsx +++ b/web/src/pages/AgentActivityPanel.tsx @@ -22,6 +22,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import { formatLatency, formatRelative, @@ -168,6 +169,8 @@ function CallRow({ call, expanded, onToggle }: CallRowProps) { } export default function AgentActivityPanel() { + usePanelView("AgentActivityPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/AlertsPanel.tsx b/web/src/pages/AlertsPanel.tsx index 58925facbab4..ccfd60b07632 100644 --- a/web/src/pages/AlertsPanel.tsx +++ b/web/src/pages/AlertsPanel.tsx @@ -29,6 +29,7 @@ import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { usePanelView } from "@/hooks/usePanelView"; import type { Alert, AlertCategory, @@ -234,6 +235,8 @@ function SeverityGroup({ } export default function AlertsPanel() { + usePanelView("AlertsPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index c97af1deed26..22148a0b78c9 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -26,6 +26,7 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; const PERIODS = [ { label: "7d", days: 7 }, { label: "30d", days: 30 }, @@ -393,6 +394,8 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) { } export default function AnalyticsPage() { + usePanelView("AnalyticsPage"); + const [days, setDays] = useState(30); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); diff --git a/web/src/pages/BootStatusPage.tsx b/web/src/pages/BootStatusPage.tsx index c217b78fad96..461448cbc942 100644 --- a/web/src/pages/BootStatusPage.tsx +++ b/web/src/pages/BootStatusPage.tsx @@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { BootHistoryEntry, BootOutcome, @@ -265,6 +266,8 @@ function HistoryTable({ history, limit }: HistoryTableProps) { } export default function BootStatusPage() { + usePanelView("BootStatusPage"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/CapabilitiesPage.tsx b/web/src/pages/CapabilitiesPage.tsx index 2fe70c7c705f..cef30198f613 100644 --- a/web/src/pages/CapabilitiesPage.tsx +++ b/web/src/pages/CapabilitiesPage.tsx @@ -16,6 +16,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { CapabilitiesResponse, CapabilityGroup, @@ -104,6 +105,8 @@ function CapGroupCard({ group }: CapGroupCardProps) { } export default function CapabilitiesPage() { + usePanelView("CapabilitiesPage"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/ChainEventsPage.tsx b/web/src/pages/ChainEventsPage.tsx index 989a8557357a..d8d4d73426fa 100644 --- a/web/src/pages/ChainEventsPage.tsx +++ b/web/src/pages/ChainEventsPage.tsx @@ -16,6 +16,7 @@ import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import type { ChainEvent, ChainEventsResponse } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; // Built-in prefix presets sourced from the 33+ kora.* event_type // literals shipped on the substrate side (foundation/0159). Picking // these by category covers ~90% of operator investigation cases. @@ -192,6 +193,8 @@ function ChainEventRowDisplay({ event, expanded, onToggle }: ChainEventRowProps) } export default function ChainEventsPage() { + usePanelView("ChainEventsPage"); + const [prefix, setPrefix] = useState(DEFAULT_PREFIX); const [events, setEvents] = useState([]); const [nextBeforeTs, setNextBeforeTs] = useState(null); diff --git a/web/src/pages/CharterPage.tsx b/web/src/pages/CharterPage.tsx index 7b918c12cb00..7b9d9879b237 100644 --- a/web/src/pages/CharterPage.tsx +++ b/web/src/pages/CharterPage.tsx @@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { ActiveConstitution, CharterCapabilityGroup, @@ -345,6 +346,8 @@ function CapMatrixSection({ groups, substrateTier }: CapMatrixSectionProps) { } export default function CharterPage() { + usePanelView("CharterPage"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index d257531f23e0..296da08e5f91 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -37,6 +37,7 @@ import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; function buildWsUrl( token: string, resume: string | null, @@ -104,7 +105,12 @@ function terminalLineHeightForWidth(layoutWidthPx: number): number { return layoutWidthPx < 1024 ? 1.02 : 1.15; } -export default function ChatPage({ isActive = true }: { isActive?: boolean }) { +export default function ChatPage({ + isActive = true, +}: { + isActive?: boolean; +}) { + usePanelView("ChatPage"); const hostRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index d24dbd1fd944..8794075d4003 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -53,6 +53,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ @@ -103,6 +104,8 @@ function CategoryIcon({ /* ------------------------------------------------------------------ */ export default function ConfigPage() { + usePanelView("ConfigPage"); + const [config, setConfig] = useState | null>(null); const [schema, setSchema] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index d5dffbc314bc..92ccb956ae2e 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -19,6 +19,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; function formatTime(iso?: string | null): string { if (!iso) return "—"; const d = new Date(iso); @@ -96,6 +97,8 @@ const STATUS_TONE: Record = { }; export default function CronPage() { + usePanelView("CronPage"); + const [jobs, setJobs] = useState([]); const [profiles, setProfiles] = useState([]); const [selectedProfile, setSelectedProfile] = useState("all"); diff --git a/web/src/pages/DRStatePage.tsx b/web/src/pages/DRStatePage.tsx index 1d17c36c2864..34b0b08bb374 100644 --- a/web/src/pages/DRStatePage.tsx +++ b/web/src/pages/DRStatePage.tsx @@ -20,6 +20,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { DRCurrent, DRMatchStatus, @@ -309,6 +310,8 @@ function hasNonMonotonicJump(history: EpochHistoryEntry[]): boolean { } export default function DRStatePage() { + usePanelView("DRStatePage"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 66e2cb4b0153..684e9b08701c 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -67,6 +67,7 @@ import type { } from "@/lib/api"; import { AlertsBanner } from "@/components/AlertsBanner"; +import { usePanelView } from "@/hooks/usePanelView"; type LoadStatus = | { state: "loading" } | { state: "ready"; data: T } @@ -973,6 +974,8 @@ function isStubbed(s: LoadStatus): boolean { } export default function DashboardPage() { + usePanelView("DashboardPage"); + const [data, setData] = useState(INITIAL_DATA); const [refreshing, setRefreshing] = useState(false); const { toast, showToast } = useToast(); diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx index fa929377b1c7..06198e3099e2 100644 --- a/web/src/pages/DocsPage.tsx +++ b/web/src/pages/DocsPage.tsx @@ -5,6 +5,7 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { cn } from "@/lib/utils"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/"; const DS_BUTTON_OUTLINED_LINK_CN = cn( @@ -16,6 +17,8 @@ const DS_BUTTON_OUTLINED_LINK_CN = cn( ); export default function DocsPage() { + usePanelView("DocsPage"); + const { t } = useI18n(); const { setEnd } = usePageHeader(); diff --git a/web/src/pages/EmailPanel.tsx b/web/src/pages/EmailPanel.tsx index b52e39e999c3..224975427a31 100644 --- a/web/src/pages/EmailPanel.tsx +++ b/web/src/pages/EmailPanel.tsx @@ -26,6 +26,7 @@ import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { usePanelView } from "@/hooks/usePanelView"; import type { EmailDirection, EmailHandledStatus, @@ -292,6 +293,8 @@ function matchesFilter(message: EmailMessage, filter: Filter): boolean { } export default function EmailPanel() { + usePanelView("EmailPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/EnvPage.tsx b/web/src/pages/EnvPage.tsx index f411e79cd5ce..e58aa35be3aa 100644 --- a/web/src/pages/EnvPage.tsx +++ b/web/src/pages/EnvPage.tsx @@ -38,6 +38,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; /* ------------------------------------------------------------------ */ /* Provider grouping */ /* ------------------------------------------------------------------ */ @@ -487,6 +488,8 @@ function ProviderGroupCard({ /* ------------------------------------------------------------------ */ export default function EnvPage() { + usePanelView("EnvPage"); + const [vars, setVars] = useState | null>(null); const [edits, setEdits] = useState>({}); const [revealed, setRevealed] = useState>({}); diff --git a/web/src/pages/HealthRollupPage.tsx b/web/src/pages/HealthRollupPage.tsx index b4087267dd5c..d78456c36434 100644 --- a/web/src/pages/HealthRollupPage.tsx +++ b/web/src/pages/HealthRollupPage.tsx @@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { HealthRollupResponse, HealthStatus, @@ -207,6 +208,8 @@ function SubsignalCard({ name, signal }: { name: string; signal: Subsignal }) { } export default function HealthRollupPage() { + usePanelView("HealthRollupPage"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/HeartbeatPanel.tsx b/web/src/pages/HeartbeatPanel.tsx index 81037474cac6..ec7bc6bf6bb1 100644 --- a/web/src/pages/HeartbeatPanel.tsx +++ b/web/src/pages/HeartbeatPanel.tsx @@ -20,6 +20,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import { formatRelative as formatRelativeShared, formatTimestamp, @@ -165,6 +166,8 @@ function ServiceRow({ service, expanded, onToggle }: ServiceRowProps) { } export default function HeartbeatPanel() { + usePanelView("HeartbeatPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/IdentityPage.tsx b/web/src/pages/IdentityPage.tsx index f63a2b09fb9a..00e32232da6f 100644 --- a/web/src/pages/IdentityPage.tsx +++ b/web/src/pages/IdentityPage.tsx @@ -20,6 +20,7 @@ import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import type { GatewayPlatformIdentity } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; // The canonical default display_name lives in gateway/config.py // (PlatformConfig.display_name). The API returns the resolved effective // value, so this page never hardcodes the literal. @@ -76,6 +77,8 @@ function initialCardState(p: GatewayPlatformIdentity): CardState { } export default function IdentityPage() { + usePanelView("IdentityPage"); + const [platforms, setPlatforms] = useState(null); const [loadError, setLoadError] = useState(null); const [drafts, setDrafts] = useState>({}); diff --git a/web/src/pages/KoraControlPage.tsx b/web/src/pages/KoraControlPage.tsx index 3fff373bd34e..5c925c97a7fd 100644 --- a/web/src/pages/KoraControlPage.tsx +++ b/web/src/pages/KoraControlPage.tsx @@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { KoraControlCommand, KoraControlLifecycleState, @@ -293,6 +294,8 @@ function activeSorted(commands: KoraControlCommand[]): KoraControlCommand[] { } export default function KoraControlPage() { + usePanelView("KoraControlPage"); + const [data, setData] = useState( null, ); diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index bfe1be3ec7ae..d8e5936377da 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -18,6 +18,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; const FILES = ["agent", "errors", "gateway"] as const; const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const; const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const; @@ -53,6 +54,8 @@ const segmentedClass = "w-fit max-w-full flex-wrap justify-start self-start"; export default function LogsPage() { + usePanelView("LogsPage"); + const [file, setFile] = useState<(typeof FILES)[number]>("agent"); const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL"); const [component, setComponent] = diff --git a/web/src/pages/MCPClientsPanel.tsx b/web/src/pages/MCPClientsPanel.tsx index fa41140491bb..88e83901325e 100644 --- a/web/src/pages/MCPClientsPanel.tsx +++ b/web/src/pages/MCPClientsPanel.tsx @@ -24,6 +24,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { MCPClient, MCPClientStatus, @@ -284,6 +285,8 @@ function MCPClientRow({ client, expanded, onToggle }: MCPClientRowProps) { } export default function MCPClientsPanel() { + usePanelView("MCPClientsPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/MCPPage.tsx b/web/src/pages/MCPPage.tsx index 837afa2b9c1b..edf9724c039c 100644 --- a/web/src/pages/MCPPage.tsx +++ b/web/src/pages/MCPPage.tsx @@ -20,6 +20,7 @@ import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import type { MCPProbeTool, MCPServer } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; interface ProbeState { loading: boolean; tools: MCPProbeTool[] | null; @@ -56,6 +57,8 @@ function deriveEnabledSet( } export default function MCPPage() { + usePanelView("MCPPage"); + const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState(null); diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 134ff3eab386..119b101d71ba 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -33,6 +33,7 @@ import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; +import { usePanelView } from "@/hooks/usePanelView"; const PERIODS = [ { label: "7d", days: 7 }, { label: "30d", days: 30 }, @@ -765,6 +766,8 @@ function ModelSettingsPanel({ /* ──────────────────────────────────────────────────────────────────── */ export default function ModelsPage() { + usePanelView("ModelsPage"); + const [days, setDays] = useState(30); const [data, setData] = useState(null); const [aux, setAux] = useState(null); diff --git a/web/src/pages/OperationalStatePage.tsx b/web/src/pages/OperationalStatePage.tsx index a602ca9fdc2d..a41a31c8ccec 100644 --- a/web/src/pages/OperationalStatePage.tsx +++ b/web/src/pages/OperationalStatePage.tsx @@ -13,6 +13,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { ClaimPermission, DegradationReason, @@ -57,6 +58,8 @@ function uppercaseLabel(value: string): string { } export default function OperationalStatePage() { + usePanelView("OperationalStatePage"); + const [state, setState] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index cb72551d55a7..eebac77be156 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -21,10 +21,13 @@ import { PluginSlot } from "@/plugins"; import { cn } from "@/lib/utils"; import { usePageHeader } from "@/contexts/usePageHeader"; +import { usePanelView } from "@/hooks/usePanelView"; /** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */ const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__"; export default function PluginsPage() { + usePanelView("PluginsPage"); + const [hub, setHub] = useState(null); const [loading, setLoading] = useState(true); const [installId, setInstallId] = useState(""); diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index af00c96f6d6b..eac67d185918 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -18,6 +18,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; +import { usePanelView } from "@/hooks/usePanelView"; // Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously // invalid names (uppercase, spaces, …) before round-tripping a doomed POST. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; @@ -52,6 +53,8 @@ function ProfilesLoadingSpinner() { } export default function ProfilesPage() { + usePanelView("ProfilesPage"); + const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); diff --git a/web/src/pages/ReasoningPanel.tsx b/web/src/pages/ReasoningPanel.tsx index 44a1addc7fe7..e96ab885e5c8 100644 --- a/web/src/pages/ReasoningPanel.tsx +++ b/web/src/pages/ReasoningPanel.tsx @@ -23,6 +23,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import { formatLatency, formatRelative, @@ -323,6 +324,8 @@ function matchesFilter(call: ReasoningCall, filter: Filter): boolean { } export default function ReasoningPanel() { + usePanelView("ReasoningPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/RunbooksPage.tsx b/web/src/pages/RunbooksPage.tsx index f0031f573c0d..2e67ed395d9c 100644 --- a/web/src/pages/RunbooksPage.tsx +++ b/web/src/pages/RunbooksPage.tsx @@ -20,6 +20,7 @@ import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import type { RunbookEntry, RunbooksManifest } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; function formatTimestamp(iso: string | null): string { if (!iso) return "—"; const d = new Date(iso); @@ -192,6 +193,8 @@ function ContentPane({ runbook, content, loading, error, onPrint }: ContentPaneP } export default function RunbooksPage() { + usePanelView("RunbooksPage"); + const [manifest, setManifest] = useState(null); const [manifestError, setManifestError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/SeaTicketsPage.tsx b/web/src/pages/SeaTicketsPage.tsx index c46a6174d22e..91ce0b07553a 100644 --- a/web/src/pages/SeaTicketsPage.tsx +++ b/web/src/pages/SeaTicketsPage.tsx @@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; +import { usePanelView } from "@/hooks/usePanelView"; import type { Criticality, FailedOrBlockedTicket, @@ -104,6 +105,8 @@ function failureChips( } export default function SeaTicketsPage() { + usePanelView("SeaTicketsPage"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index f7d24e9d7299..77fc2e2bfbe3 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -50,6 +50,7 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; +import { usePanelView } from "@/hooks/usePanelView"; const SOURCE_CONFIG: Record = { cli: { icon: Terminal, color: "text-primary" }, @@ -409,6 +410,8 @@ function SessionRow({ } export default function SessionsPage() { + usePanelView("SessionsPage"); + const [sessions, setSessions] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index e48d4fe0c5a3..dd0a32166d34 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -31,6 +31,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { usePanelView } from "@/hooks/usePanelView"; /* ------------------------------------------------------------------ */ /* Types & helpers */ /* ------------------------------------------------------------------ */ @@ -94,6 +95,8 @@ function toolsetIcon( /* ------------------------------------------------------------------ */ export default function SkillsPage() { + usePanelView("SkillsPage"); + const [skills, setSkills] = useState([]); const [toolsets, setToolsets] = useState([]); const [loading, setLoading] = useState(true); diff --git a/web/src/pages/SlackDMPanel.tsx b/web/src/pages/SlackDMPanel.tsx index 2caa25592dfe..076bebe90af4 100644 --- a/web/src/pages/SlackDMPanel.tsx +++ b/web/src/pages/SlackDMPanel.tsx @@ -24,6 +24,7 @@ import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { usePanelView } from "@/hooks/usePanelView"; import type { SlackDMDirection, SlackDMHandledStatus, @@ -245,6 +246,8 @@ function matchesFilter(message: SlackDMMessage, filter: Filter): boolean { } export default function SlackDMPanel() { + usePanelView("SlackDMPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false); diff --git a/web/src/pages/WebhookEventsPanel.tsx b/web/src/pages/WebhookEventsPanel.tsx index 69c04b937749..ba4648b0c56b 100644 --- a/web/src/pages/WebhookEventsPanel.tsx +++ b/web/src/pages/WebhookEventsPanel.tsx @@ -21,6 +21,7 @@ import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { usePanelView } from "@/hooks/usePanelView"; import type { WebhookEvent, WebhookEventStatus, @@ -151,6 +152,8 @@ const FILTER_OPTIONS: Array<{ value: StatusFilter; label: string }> = [ ]; export default function WebhookEventsPanel() { + usePanelView("WebhookEventsPanel"); + const [data, setData] = useState(null); const [loadError, setLoadError] = useState(null); const [refreshing, setRefreshing] = useState(false);