diff --git a/tests/kora_cli/test_heartbeat_panel_drift_fixes.py b/tests/kora_cli/test_heartbeat_panel_drift_fixes.py new file mode 100644 index 000000000000..6fa77f2d9e2f --- /dev/null +++ b/tests/kora_cli/test_heartbeat_panel_drift_fixes.py @@ -0,0 +1,179 @@ +"""Source-pin tests for KR-FRONTEND-CLEANUP Part A. + +CC#1's KR-FEAT-HEARTBEAT ST2 (PR #118) added 4 fields to the +heartbeat surface that the FE consumers didn't handle: + * HeartbeatStatus enum gained "unknown" (probe pending) + * HeartbeatService.last_check_at became nullable + * HeartbeatService.error: string | null + * HeartbeatServicesResponse.cache_warming: boolean + +This file pins that HeartbeatPanel.tsx + DashboardPage.tsx render +all four. Follows the CC#2 source-grep pattern (no FE test runner +in the repo) established by KR-MCP-CLIENTS-HEALTH-DISPLAY (#117). +""" + +import re +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PANEL_PATH = _REPO_ROOT / "web" / "src" / "pages" / "HeartbeatPanel.tsx" +_DASHBOARD_PATH = _REPO_ROOT / "web" / "src" / "pages" / "DashboardPage.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 + + +# ---- 1. source files exist ---------------------------------------- + + +def test_panel_source_exists(): + assert _PANEL_PATH.is_file(), f"missing: {_PANEL_PATH}" + + +def test_dashboard_source_exists(): + assert _DASHBOARD_PATH.is_file(), f"missing: {_DASHBOARD_PATH}" + + +# ---- 2. "unknown" status arm handled -------------------------------- + + +def test_panel_status_tone_map_includes_unknown(): + """HeartbeatStatus enum gained "unknown" — the panel's STATUS_TONE + map must include it so TypeScript exhaustiveness holds and the + badge gets a tone. Catches a future enum addition that skips + this consumer.""" + src = _PANEL_PATH.read_text() + # The literal "unknown:" must appear as a STATUS_TONE map entry + # (left-hand side of a key/value pair inside the Record literal). + assert re.search(r"\bunknown\s*:", src), ( + "HeartbeatPanel.tsx STATUS_TONE / STATUS_LABEL maps must " + 'include the "unknown" arm' + ) + + +def test_panel_status_icon_handles_unknown(): + """StatusIcon switch must include a "unknown" case so TS + exhaustiveness check passes and the icon renders.""" + src = _PANEL_PATH.read_text() + assert re.search(r'case "unknown":', src), ( + 'HeartbeatPanel.tsx StatusIcon must have a `case "unknown":` arm' + ) + + +def test_dashboard_heartbeat_counts_include_unknown(): + """The dashboard's HeartbeatCardBody aggregator must bucket + "unknown" — otherwise the Record literal + is incomplete and tsc fails.""" + src = _DASHBOARD_PATH.read_text() + # The counts object literal must initialize an `unknown:` field. + assert re.search( + r"counts:\s*Record\s*=\s*\{[^}]*\bunknown\s*:", + src, + re.DOTALL, + ), ( + "DashboardPage HeartbeatCardBody counts object must include " + 'the "unknown" key (matches Record)' + ) + + +# ---- 3. nullable last_check_at handled ----------------------------- + + +def test_format_timestamp_accepts_null(): + """formatTimestamp + formatRelative must accept `string | null` + so HeartbeatService.last_check_at: string | null type-checks. + Catches a regression that drops the null arm from the helper + signatures.""" + src = _PANEL_PATH.read_text() + # Both helpers must declare nullable parameter types. + assert re.search( + r"function formatTimestamp\(iso:\s*string\s*\|\s*null\)", + src, + ), "formatTimestamp must accept string | null" + assert re.search( + r"function formatRelative\(iso:\s*string\s*\|\s*null\)", + src, + ), "formatRelative must accept string | null" + + +def test_format_relative_null_path_says_never_checked(): + """The null branch of formatRelative should produce a user- + facing "never checked" label (consistent with the MCP-clients + health-display pattern from #117), not an empty string.""" + src = _PANEL_PATH.read_text() + assert '"never checked"' in src, ( + 'formatRelative null path should return "never checked" ' + "(matches the MCP-clients health-display convention)" + ) + + +# ---- 4. error field rendered in expanded view ---------------------- + + +def test_panel_renders_service_error_field(): + """KR-FEAT-HEARTBEAT ST2 error field rendered in the expanded + detail view. Source-pin: branch on service.error !== null + and render in a
 for multi-line legibility (mirrors
+    MCP-clients last_error pattern from #117)."""
+    src = _PANEL_PATH.read_text()
+    assert "service.error" in src, (
+        "HeartbeatPanel.tsx must reference service.error in render"
+    )
+    # Must appear inside a JSX expression (rendered as text node)
+    assert re.search(r"\{[^{}]*service\.error[^{}]*\}", src), (
+        "service.error should be rendered as a JSX child expression"
+    )
+
+
+def test_panel_error_rendering_uses_no_dangerously_set_inner_html():
+    """The error field is operator-readable text from a probe; same
+    untrusted-input contract as MCP-clients last_error. React's
+    default escaping handles defanging; this guard catches a
+    future edit that switches to dangerouslySetInnerHTML."""
+    code = _strip_ts_comments(_PANEL_PATH.read_text())
+    assert "dangerouslySetInnerHTML" not in code, (
+        "HeartbeatPanel.tsx must not use dangerouslySetInnerHTML — "
+        "error field is untrusted probe output"
+    )
+
+
+# ---- 5. cache_warming banner -------------------------------------
+
+
+def test_panel_renders_cache_warming_banner():
+    """KR-FEAT-HEARTBEAT ST2 cache_warming flag: render a "Probes
+    warming up…" banner so the operator sees the warming context
+    instead of misreading an empty/sparse list as an outage."""
+    src = _PANEL_PATH.read_text()
+    assert "data.cache_warming" in src, (
+        "HeartbeatPanel.tsx must branch on data.cache_warming"
+    )
+    assert "warming" in src.lower(), (
+        "HeartbeatPanel.tsx should render a warming-state affordance"
+    )
+
+
+def test_dashboard_card_suppresses_outage_tone_when_warming():
+    """The dashboard card's headline tone must NOT go destructive
+    when cache_warming is true — an empty heartbeat list during
+    daemon cold-start isn't a real outage. Source-pin: the
+    headlineClass derivation branches on data.cache_warming."""
+    src = _DASHBOARD_PATH.read_text()
+    # Crude but effective: the cache_warming branch must appear in
+    # HeartbeatCardBody's logic, and it must influence the headline
+    # class (text-muted-foreground), not just be a side affordance.
+    hb_body_idx = src.find("function HeartbeatCardBody")
+    next_fn_idx = src.find("\nfunction ", hb_body_idx + 1)
+    body_slice = src[hb_body_idx:next_fn_idx]
+    assert "data.cache_warming" in body_slice, (
+        "HeartbeatCardBody must read data.cache_warming"
+    )
+    assert "text-muted-foreground" in body_slice, (
+        "HeartbeatCardBody should mute the headline tone during "
+        "warming (no false outage signal)"
+    )
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index c95a304d8426..baede927744e 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -529,27 +529,32 @@ function truncateMiddle(value: string, head: number): string {
 }
 
 function HeartbeatCardBody({ data }: { data: HeartbeatServicesResponse }) {
-  // Aggregate per-status counts. "5 services / 1 degraded / 0 unhealthy"
-  // (the spec §3(c) example) matches what the panel itself shows in its
-  // summary strip, kept consistent so the dashboard card + panel agree.
+  // Aggregate per-status counts. Matches the panel's summary strip
+  // so the dashboard card + panel agree. KR-FEAT-HEARTBEAT ST2
+  // added the "unknown" arm (probe pending; cold-start, auth-missing,
+  // or in-flight) — it's bucketed separately so the operator can
+  // distinguish "scheduler not done yet" from "service down".
   const total = data.services.length;
   const counts: Record = {
     healthy: 0,
     degraded: 0,
     unhealthy: 0,
+    unknown: 0,
   };
   for (const s of data.services) counts[s.status]++;
   // Worst-status tone drives the headline number colour: unhealthy >
-  // degraded > healthy. operator scans the dashboard for "is anything
-  // wrong" and this surfaces it without making them squint at chips.
+  // degraded > healthy. "unknown" is intentionally NOT a worst-
+  // status driver (it's pending, not failing) — cache_warming
+  // suppresses any pseudo-outage signal when the daemon just booted.
   const worst =
     counts.unhealthy > 0
       ? "unhealthy"
       : counts.degraded > 0
         ? "degraded"
         : "healthy";
-  const headlineClass =
-    worst === "unhealthy"
+  const headlineClass = data.cache_warming
+    ? "text-muted-foreground"
+    : worst === "unhealthy"
       ? "text-destructive"
       : worst === "degraded"
         ? "text-warning"
@@ -560,6 +565,7 @@ function HeartbeatCardBody({ data }: { data: HeartbeatServicesResponse }) {
         {total}
         
           service{total === 1 ? "" : "s"}
+          {data.cache_warming ? " · warming" : ""}
         
       
       
@@ -572,6 +578,9 @@ function HeartbeatCardBody({ data }: { data: HeartbeatServicesResponse }) { {counts.unhealthy > 0 && ( {counts.unhealthy} unhealthy )} + {counts.unknown > 0 && ( + {counts.unknown} pending + )}
); diff --git a/web/src/pages/HeartbeatPanel.tsx b/web/src/pages/HeartbeatPanel.tsx index 0198d2a020d8..af035a80fbed 100644 --- a/web/src/pages/HeartbeatPanel.tsx +++ b/web/src/pages/HeartbeatPanel.tsx @@ -6,8 +6,10 @@ import { CheckCircle2, ChevronDown, ChevronRight, + CircleDashed, Cloud, HelpCircle, + Hourglass, RefreshCw, } from "lucide-react"; import { Badge } from "@nous-research/ui/ui/components/badge"; @@ -24,10 +26,24 @@ import type { HeartbeatStatus, } from "@/lib/api"; -const STATUS_TONE: Record = { +const STATUS_TONE: Record< + HeartbeatStatus, + "success" | "warning" | "destructive" | "outline" +> = { healthy: "success", degraded: "warning", unhealthy: "destructive", + // KR-FEAT-HEARTBEAT ST2: "unknown" = probe hasn't completed a + // roundtrip yet (cold-start, auth-missing, or in-flight). + // Muted outline so it reads as "pending" not "broken". + unknown: "outline", +}; + +const STATUS_LABEL: Record = { + healthy: "healthy", + degraded: "degraded", + unhealthy: "unhealthy", + unknown: "probe pending", }; function StatusIcon({ status }: { status: HeartbeatStatus }) { @@ -38,18 +54,20 @@ function StatusIcon({ status }: { status: HeartbeatStatus }) { return ; case "unhealthy": return ; + case "unknown": + return ; } } -function formatTimestamp(iso: string): string { +function formatTimestamp(iso: string | null): 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 ""; +function formatRelative(iso: string | null): string { + if (!iso) return "never checked"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const deltaMs = d.getTime() - Date.now(); @@ -110,9 +128,13 @@ function ServiceRow({ service, expanded, onToggle }: ServiceRowProps) { {service.name} - {service.status} + + {STATUS_LABEL[service.status]} + - {service.latency_ms} ms · last checked{" "} + {service.latency_ms !== null + ? `${service.latency_ms} ms · ` + : "— · "} {formatRelative(service.last_check_at)} @@ -136,8 +158,24 @@ function ServiceRow({ service, expanded, onToggle }: ServiceRowProps) { )}
- last_check_at: {formatTimestamp(service.last_check_at)} + last_check_at:{" "} + {formatTimestamp(service.last_check_at)}
+ {/* KR-FEAT-HEARTBEAT ST2 error field. Plain-text + rendering (React default escaping) — same + MCP-clients pattern as #117. Sanitized at the + backend (per api.ts type comment) but FE still + must not interpret as HTML. */} + {service.error !== null && ( +
+ + error + +
+                  {service.error}
+                
+
+ )} )} @@ -198,6 +236,7 @@ export default function HeartbeatPanel() { healthy: data.services.filter((s) => s.status === "healthy").length, degraded: data.services.filter((s) => s.status === "degraded").length, unhealthy: data.services.filter((s) => s.status === "unhealthy").length, + unknown: data.services.filter((s) => s.status === "unknown").length, } : null; @@ -271,6 +310,12 @@ export default function HeartbeatPanel() { {counts.unhealthy} unhealthy + {counts.unknown > 0 && ( + + + {counts.unknown} probe pending + + )} )} @@ -280,6 +325,26 @@ export default function HeartbeatPanel() { + {/* KR-FEAT-HEARTBEAT ST2: cache_warming banner. The + daemon just started and the first probe cycle hasn't + completed — the empty/sparse services list isn't a + "real" outage signal. */} + {data.cache_warming && ( + + + +
+
Probes warming up…
+
+ Heartbeat scheduler started recently; the first + probe cycle hasn't completed yet. Service health + will populate once probes return. +
+
+
+
+ )} + {/* ── Services list ──────────────────────────────────── */} {data.services.length === 0 ? (