Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions tests/kora_cli/test_heartbeat_panel_drift_fixes.py
Original file line number Diff line number Diff line change
@@ -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<HeartbeatStatus, number> 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<HeartbeatStatus,\s*number>\s*=\s*\{[^}]*\bunknown\s*:",
src,
re.DOTALL,
), (
"DashboardPage HeartbeatCardBody counts object must include "
'the "unknown" key (matches Record<HeartbeatStatus, number>)'
)


# ---- 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 <pre> 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)"
)
23 changes: 16 additions & 7 deletions web/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeartbeatStatus, number> = {
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"
Expand All @@ -560,6 +565,7 @@ function HeartbeatCardBody({ data }: { data: HeartbeatServicesResponse }) {
{total}
<span className="text-xs text-muted-foreground font-normal ml-1.5">
service{total === 1 ? "" : "s"}
{data.cache_warming ? " · warming" : ""}
</span>
</div>
<div className="flex flex-wrap gap-1.5 text-xs">
Expand All @@ -572,6 +578,9 @@ function HeartbeatCardBody({ data }: { data: HeartbeatServicesResponse }) {
{counts.unhealthy > 0 && (
<Badge tone="destructive">{counts.unhealthy} unhealthy</Badge>
)}
{counts.unknown > 0 && (
<Badge tone="outline">{counts.unknown} pending</Badge>
)}
</div>
</div>
);
Expand Down
79 changes: 72 additions & 7 deletions web/src/pages/HeartbeatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,10 +26,24 @@ import type {
HeartbeatStatus,
} from "@/lib/api";

const STATUS_TONE: Record<HeartbeatStatus, "success" | "warning" | "destructive"> = {
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<HeartbeatStatus, string> = {
healthy: "healthy",
degraded: "degraded",
unhealthy: "unhealthy",
unknown: "probe pending",
};

function StatusIcon({ status }: { status: HeartbeatStatus }) {
Expand All @@ -38,18 +54,20 @@ function StatusIcon({ status }: { status: HeartbeatStatus }) {
return <AlertTriangle className="h-4 w-4 text-warning" />;
case "unhealthy":
return <AlertOctagon className="h-4 w-4 text-destructive" />;
case "unknown":
return <CircleDashed className="h-4 w-4 text-muted-foreground" />;
}
}

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();
Expand Down Expand Up @@ -110,9 +128,13 @@ function ServiceRow({ service, expanded, onToggle }: ServiceRowProps) {
<span className="font-medium uppercase tracking-wide">
{service.name}
</span>
<Badge tone={STATUS_TONE[service.status]}>{service.status}</Badge>
<Badge tone={STATUS_TONE[service.status]}>
{STATUS_LABEL[service.status]}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{service.latency_ms} ms · last checked{" "}
{service.latency_ms !== null
? `${service.latency_ms} ms · `
: "— · "}
{formatRelative(service.last_check_at)}
</span>
</button>
Expand All @@ -136,8 +158,24 @@ function ServiceRow({ service, expanded, onToggle }: ServiceRowProps) {
)}
</dl>
<div className="text-xs text-muted-foreground pt-1">
<code>last_check_at</code>: {formatTimestamp(service.last_check_at)}
<code>last_check_at</code>:{" "}
{formatTimestamp(service.last_check_at)}
</div>
{/* 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 && (
<div className="flex gap-2 text-xs pt-1">
<span className="text-muted-foreground min-w-[140px]">
error
</span>
<pre className="font-mono text-destructive whitespace-pre-wrap break-all flex-1 m-0">
{service.error}
</pre>
</div>
)}
</div>
)}
</CardContent>
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -271,6 +310,12 @@ export default function HeartbeatPanel() {
<AlertOctagon className="h-3.5 w-3.5 text-destructive" />
{counts.unhealthy} unhealthy
</span>
{counts.unknown > 0 && (
<span className="flex items-center gap-1.5 text-xs">
<CircleDashed className="h-3.5 w-3.5 text-muted-foreground" />
{counts.unknown} probe pending
</span>
)}
</>
)}
<span className="text-xs text-muted-foreground ml-auto">
Expand All @@ -280,6 +325,26 @@ export default function HeartbeatPanel() {
</CardContent>
</Card>

{/* 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 && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="py-3 flex items-start gap-3 text-sm">
<Hourglass className="h-4 w-4 mt-0.5 text-primary" />
<div>
<div className="font-medium">Probes warming up…</div>
<div className="text-xs text-muted-foreground mt-0.5">
Heartbeat scheduler started recently; the first
probe cycle hasn't completed yet. Service health
will populate once probes return.
</div>
</div>
</CardContent>
</Card>
)}

{/* ── Services list ──────────────────────────────────── */}
{data.services.length === 0 ? (
<Card>
Expand Down