From 152ee283990117bc367831a12be39380a38606a7 Mon Sep 17 00:00:00 2001 From: CC#2 Kora Frontend Date: Thu, 21 May 2026 21:55:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-HB-PANEL=20=E2=80=94=20heartbe?= =?UTF-8?q?at=20dashboard=20frontend=20shell=20(stub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-facing dashboard for the SaaS backends Joshua's work depends on: Vercel / Sentry / Doppler / Supabase / Fly. Stub-then-real pattern proven across 12+ prior CC#2 panel buckets (CHARTER, COST, HEALTH, etc.). Real polling lands in the KR-FEAT-HEARTBEAT follow-on after KR-D-DAEMON ST2 ships the heartbeat scheduler. Branch base: feature/phase2-upgrades (NOT main) per bucket §0. §2 K-DG verifications (grep'd vs bucket assumptions): * Panel location: web/src/pages/ (NOT web/src/pages/admin/ as bucket §2 hinted) — matches bucket §3(b)'s "wherever HEALTH-PANEL lives" clause, which is web/src/pages/HealthRollupPage.tsx. * API client: web/src/lib/api.ts (NOT web/src/api/ as bucket §2 hinted) — matches every other CC#2 panel. * No "panels manifest" file exists; routing lives in App.tsx. * Dashboard v2 just merged (PR #95 4d1bc11... actually e76cf12) — new card added to row 2 grid. FE test framework: bucket §4 asks for one but the project has zero FE-test infrastructure (no test runner in package.json, no .test.* files). Skipped per established CC#2 pattern across 12+ prior buckets (backend tests + tsc + vite + manual smoke). PR body documents the gap. Backend: * GET /api/heartbeat/services in kora_cli/web_server.py. Hardcoded sample of 5 services matching bucket §3 stub verbatim: 4 healthy (vercel/doppler/supabase/fly) + 1 degraded (sentry, 12 unresolved issues). stub: True flag drives the FE banner. Frontend: * pages/HeartbeatPanel.tsx — - Aggregate summary strip: total + per-status counts + generated-at relative - Per-service row: status icon + uppercase name + status pill + latency_ms + last-checked-relative; expandable detail panel shows the service-specific details dict as key/value pairs + the absolute last_check_at timestamp - STUB banner (yellow/orange) renders when stub: true with the KR-FEAT-HEARTBEAT flip-in note - Empty state for services: [] (defensive — stub always has 5 but the real poller might temporarily return []) * api.ts — HeartbeatStatus type + HeartbeatService + HeartbeatServicesResponse interfaces + getHeartbeatServices client. * App.tsx — /heartbeat route + nav entry (Heart icon) between /health-rollup and /boot-status. Operator scans the physical-health-of-services next to operational-state-of-Kora. * DashboardPage.tsx — new Heartbeat card on row 2. Grid bumped from lg:grid-cols-4 to lg:grid-cols-5 (the 5th card). Card body aggregates total + per-status pills; headline tone tracks the worst status (unhealthy → destructive, degraded → warning, else foreground) so operator scans the dashboard for "is anything wrong" and gets a colour cue without squinting at chips. ALL_SOURCES extended → footer count = 12 sources / 1 stubbed today (the stub flag adds 1 to stubbed; other sources unchanged). Tests: tests/kora_cli/test_web_server_heartbeat.py — 8 tests covering all 6 §4 scenarios plus extras: * All-5-canonical-services pin (sentry/vercel/doppler/supabase/fly) * Sentry-is-degraded contract guard (the dashboard "1 degraded" aggregate depends on this stub assertion) * Other-four-healthy counterpart guard * Per-service details-key shape spot-check (catches a future stub edit that drops a documented detail key) * Cron-regression sanity 159/159 across 15 admin-panel test files (was 151/151 before, +8 new). tsc -b + vite build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- kora_cli/web_server.py | 82 ++++++ tests/kora_cli/test_web_server_heartbeat.py | 165 +++++++++++ web/src/App.tsx | 8 + web/src/lib/api.ts | 24 ++ web/src/pages/DashboardPage.tsx | 77 ++++- web/src/pages/HeartbeatPanel.tsx | 307 ++++++++++++++++++++ 6 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 tests/kora_cli/test_web_server_heartbeat.py create mode 100644 web/src/pages/HeartbeatPanel.tsx diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index be6d096179d9..3d1e961eb1c3 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -4640,6 +4640,88 @@ async def _iter(): ) +# --------------------------------------------------------------------------- +# Backend service heartbeat (KR-HB-PANEL) +# --------------------------------------------------------------------------- +# +# v1 stub: hardcoded sample of 5 backend services (Vercel / Sentry / +# Doppler / Supabase / Fly) so the operator-facing dashboard can ship +# before the Python heartbeat module that talks to each service's API +# lands (KR-FEAT-HEARTBEAT follow-on, post-KR-D-DAEMON ST2). +# +# The ``stub: True`` flag is the explicit "this is sample data, not +# real polling" signal — the frontend renders a banner when True so +# operators never get misled during a real outage. +# +# Flip-over: when KR-FEAT-HEARTBEAT lands and a HeartbeatPoller +# emits per-service status, replace this body with a projection of +# the live state and drop the ``stub`` flag. Page UI is unchanged. + + +@app.get("/api/heartbeat/services") +async def get_heartbeat_services(): + """Return per-service heartbeat status for Joshua's backend stack. + + v1 stub. Replace body with a projection of the live + HeartbeatPoller state once KR-FEAT-HEARTBEAT lands. + + Service status enum: ``healthy`` | ``degraded`` | ``unhealthy``. + Each service surfaces a small ``details`` dict — shape varies per + service (e.g. Sentry carries ``unresolved_issues``; Supabase + carries ``connections_pct``); FE renders as expandable key/value. + """ + return { + "services": [ + { + "name": "vercel", + "status": "healthy", + "last_check_at": "2026-05-22T18:00:00Z", + "latency_ms": 142, + "details": { + "deployments_last_24h": 8, + "error_rate_24h": 0.0, + }, + }, + { + "name": "sentry", + "status": "degraded", + "last_check_at": "2026-05-22T18:00:00Z", + "latency_ms": 230, + "details": {"unresolved_issues": 12}, + }, + { + "name": "doppler", + "status": "healthy", + "last_check_at": "2026-05-22T18:00:00Z", + "latency_ms": 95, + "details": { + "projects_total": 3, + "oldest_secret_age_days": 47, + }, + }, + { + "name": "supabase", + "status": "healthy", + "last_check_at": "2026-05-22T18:00:00Z", + "latency_ms": 38, + "details": {"connections_pct": 14}, + }, + { + "name": "fly", + "status": "healthy", + "last_check_at": "2026-05-22T18:00:00Z", + "latency_ms": 88, + "details": { + "apps_running": 2, + "deploys_last_24h": 1, + }, + }, + ], + "generated_at": "2026-05-22T18:00:05Z", + "stub": True, + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_web_server_heartbeat.py b/tests/kora_cli/test_web_server_heartbeat.py new file mode 100644 index 000000000000..3dab70341b03 --- /dev/null +++ b/tests/kora_cli/test_web_server_heartbeat.py @@ -0,0 +1,165 @@ +"""Tests for the KR-HB-PANEL stub endpoint. + +Bucket §4 scenarios: + 1. GET /api/heartbeat/services returns 200 + 2. Top-level shape (services + generated_at + stub:true) + 3. All 5 expected services present + 4. Each service entry has the required keys + valid status enum + 5. status:degraded sample matches the bucket §3 documented stub + 6. Cron-regression sanity +""" + +import pytest + + +_VALID_STATUS = {"healthy", "degraded", "unhealthy"} +_EXPECTED_SERVICES = {"vercel", "sentry", "doppler", "supabase", "fly"} + + +@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.get_heartbeat_services() + 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.get_heartbeat_services() + assert set(result.keys()) == {"services", "generated_at", "stub"} + assert isinstance(result["services"], list) + assert isinstance(result["generated_at"], str) + assert result["stub"] is True + + +# ---- 3. All 5 expected services present ------------------------------ + + +@pytest.mark.asyncio +async def test_all_five_expected_services_present(_isolate_config): + """Pin the canonical 5-service list (Vercel / Sentry / Doppler / + Supabase / Fly). A future stub edit that drops one would silently + break the dashboard aggregate count test, so catch it here.""" + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + names = {s["name"] for s in result["services"]} + assert names == _EXPECTED_SERVICES + + +# ---- 4. Per-entry shape + status enum -------------------------------- + + +@pytest.mark.asyncio +async def test_each_service_entry_has_required_keys(_isolate_config): + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + for service in result["services"]: + assert set(service.keys()) == { + "name", + "status", + "last_check_at", + "latency_ms", + "details", + } + assert isinstance(service["name"], str) and service["name"] + assert service["status"] in _VALID_STATUS, ( + f"{service['name']}: status={service['status']!r} not in " + f"{_VALID_STATUS}" + ) + assert isinstance(service["latency_ms"], int) + assert service["latency_ms"] >= 0 + assert isinstance(service["last_check_at"], str) + assert isinstance(service["details"], dict) + + +# ---- 5. Bucket §3 documented stub values pinned ---------------------- + + +@pytest.mark.asyncio +async def test_sentry_is_degraded_in_stub_per_spec(_isolate_config): + """The bucket §3 stub pins sentry as the one degraded service (with + 12 unresolved_issues). The dashboard card's "1 degraded" aggregate + depends on this — pin it so a future stub edit can't silently + flip the count.""" + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + sentry = next(s for s in result["services"] if s["name"] == "sentry") + assert sentry["status"] == "degraded" + assert sentry["details"]["unresolved_issues"] == 12 + + +@pytest.mark.asyncio +async def test_other_four_services_healthy_in_stub(_isolate_config): + """Counterpart to the sentry-degraded pin: the other 4 are healthy + per bucket §3. Dashboard aggregate: 4 healthy / 1 degraded / 0 + unhealthy.""" + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + for service in result["services"]: + if service["name"] != "sentry": + assert service["status"] == "healthy", ( + f"{service['name']}: expected healthy in stub, got " + f"{service['status']!r}" + ) + + +@pytest.mark.asyncio +async def test_details_payloads_match_documented_shape(_isolate_config): + """Spot-check each service's documented detail keys are present + (without pinning exact values — values are stub data that the + follow-on real-poller PR will overwrite).""" + from kora_cli import web_server + + expected_keys: dict[str, set[str]] = { + "vercel": {"deployments_last_24h", "error_rate_24h"}, + "sentry": {"unresolved_issues"}, + "doppler": {"projects_total", "oldest_secret_age_days"}, + "supabase": {"connections_pct"}, + "fly": {"apps_running", "deploys_last_24h"}, + } + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + by_name = {s["name"]: s for s in result["services"]} + for name, keys in expected_keys.items(): + assert keys <= set(by_name[name]["details"].keys()), ( + f"{name}: missing detail key(s) " + f"{keys - set(by_name[name]['details'].keys())}" + ) + + +# ---- 6. Cron-regression sanity -------------------------------------- + + +@pytest.mark.asyncio +async def test_cron_endpoint_still_works_with_heartbeat_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 77f2f53b5bd6..c64b1328ee83 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -80,6 +80,7 @@ import MCPPage from "@/pages/MCPPage"; import IdentityPage from "@/pages/IdentityPage"; import OperationalStatePage from "@/pages/OperationalStatePage"; import HealthRollupPage from "@/pages/HealthRollupPage"; +import HeartbeatPanel from "@/pages/HeartbeatPanel"; import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; @@ -133,6 +134,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/sessions": SessionsPage, "/operational-state": OperationalStatePage, "/health-rollup": HealthRollupPage, + "/heartbeat": HeartbeatPanel, "/boot-status": BootStatusPage, "/dr-state": DRStatePage, "/cost-state": CostStatePage, @@ -189,6 +191,12 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Health", icon: HeartPulse, }, + { + path: "/heartbeat", + labelKey: "heartbeat", + label: "Heartbeat", + icon: Heart, + }, { path: "/boot-status", labelKey: "bootStatus", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d935adac743b..a85b9bf2543f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -118,6 +118,8 @@ export const api = { getRunbooks: () => fetchJSON("/api/runbooks"), getRunbookContent: (id: string) => fetchText(`/api/runbooks/${encodeURIComponent(id)}/content`), + getHeartbeatServices: () => + fetchJSON("/api/heartbeat/services"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1402,3 +1404,25 @@ export const DIAG_BUNDLE_URL = "/api/diag-bundle"; export function diagBundleHref(): string { return `${HERMES_BASE_PATH}${DIAG_BUNDLE_URL}`; } + +// Backend service heartbeat (KR-HB-PANEL). +// status enum: healthy | degraded | unhealthy. Per-service "details" +// shape varies (Sentry has unresolved_issues, Supabase has +// connections_pct, etc.) — surfaced as an opaque Record so each FE +// renderer can read the keys it knows about; unknown keys render as +// plain key/value pairs. +export type HeartbeatStatus = "healthy" | "degraded" | "unhealthy"; + +export interface HeartbeatService { + name: string; + status: HeartbeatStatus; + last_check_at: string; + latency_ms: number; + details: Record; +} + +export interface HeartbeatServicesResponse { + services: HeartbeatService[]; + generated_at: string; + stub: boolean; +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 3636d7002ca7..9e756533f3da 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import { BookOpenCheck, CheckCircle2, DollarSign, + Heart, HeartPulse, Hourglass, Info, @@ -40,6 +41,8 @@ import type { DRStateResponse, HealthRollupResponse, HealthStatus, + HeartbeatServicesResponse, + HeartbeatStatus, KoraAssignedSeaTicketsResponse, KoraControlObservedStateResponse, OperationalStateResponse, @@ -65,6 +68,8 @@ interface DashboardData { charter: LoadStatus; recentEvents: LoadStatus; runbooks: LoadStatus; + // KR-HB-PANEL — backend service heartbeat (stub) + heartbeat: LoadStatus; } const INITIAL_DATA: DashboardData = { @@ -79,6 +84,7 @@ const INITIAL_DATA: DashboardData = { charter: { state: "loading" }, recentEvents: { state: "loading" }, runbooks: { state: "loading" }, + heartbeat: { state: "loading" }, }; const HEALTH_TONE: Record = { @@ -493,6 +499,55 @@ function truncateMiddle(value: string, head: number): string { return `${value.slice(0, head)}…${value.slice(-4)}`; } +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. + const total = data.services.length; + const counts: Record = { + healthy: 0, + degraded: 0, + unhealthy: 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. + const worst = + counts.unhealthy > 0 + ? "unhealthy" + : counts.degraded > 0 + ? "degraded" + : "healthy"; + const headlineClass = + worst === "unhealthy" + ? "text-destructive" + : worst === "degraded" + ? "text-warning" + : "text-foreground"; + return ( +
+
+ {total} + + service{total === 1 ? "" : "s"} + +
+
+ {counts.healthy > 0 && ( + {counts.healthy} healthy + )} + {counts.degraded > 0 && ( + {counts.degraded} degraded + )} + {counts.unhealthy > 0 && ( + {counts.unhealthy} unhealthy + )} +
+
+ ); +} + // ── Hero ───────────────────────────────────────────────────────────────── interface HealthHeroProps { @@ -631,6 +686,8 @@ export default function DashboardPage() { loadOne("charter", () => api.getCharter()), loadOne("recentEvents", () => api.getChainEvents({ limit: 5 })), loadOne("runbooks", () => api.getRunbooks()), + // KR-HB-PANEL + loadOne("heartbeat", () => api.getHeartbeatServices()), ]); if (isManual) { setRefreshing(false); @@ -660,6 +717,7 @@ export default function DashboardPage() { data.charter, data.recentEvents, data.runbooks, + data.heartbeat, ]; const anyStubbed = ALL_SOURCES.some((s) => isStubbed(s)); @@ -796,8 +854,8 @@ export default function DashboardPage() { - {/* ── Row 2: newer surfaces (KR-P2-DASHBOARD-V2) ───────────────── */} -
+ {/* ── Row 2: newer surfaces (KR-P2-DASHBOARD-V2 + KR-HB-PANEL) ── */} +
)} + + + void loadOne("heartbeat", () => api.getHeartbeatServices()) + } + > + {data.heartbeat.state === "ready" && ( + + )} +
{/* ── Bottom strip: links to other (non-admin-panel) pages ─────── */} diff --git a/web/src/pages/HeartbeatPanel.tsx b/web/src/pages/HeartbeatPanel.tsx new file mode 100644 index 000000000000..0198d2a020d8 --- /dev/null +++ b/web/src/pages/HeartbeatPanel.tsx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Activity, + AlertOctagon, + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + Cloud, + HelpCircle, + RefreshCw, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Toast } from "@/components/Toast"; +import { useToast } from "@/hooks/useToast"; +import { api } from "@/lib/api"; +import type { + HeartbeatService, + HeartbeatServicesResponse, + HeartbeatStatus, +} from "@/lib/api"; + +const STATUS_TONE: Record = { + healthy: "success", + degraded: "warning", + unhealthy: "destructive", +}; + +function StatusIcon({ status }: { status: HeartbeatStatus }) { + switch (status) { + case "healthy": + return ; + case "degraded": + return ; + case "unhealthy": + return ; + } +} + +function formatTimestamp(iso: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +function formatRelative(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const deltaMs = d.getTime() - Date.now(); + const absSec = Math.abs(deltaMs) / 1000; + if (absSec < 60) { + const n = Math.round(absSec); + return deltaMs < 0 ? `${n}s ago` : `in ${n}s`; + } + const absMin = absSec / 60; + if (absMin < 60) { + const n = Math.round(absMin); + return deltaMs < 0 ? `${n}m ago` : `in ${n}m`; + } + const absHr = absMin / 60; + if (absHr < 24) { + const n = Math.round(absHr); + return deltaMs < 0 ? `${n}h ago` : `in ${n}h`; + } + const absDay = absHr / 24; + const n = Math.round(absDay); + return deltaMs < 0 ? `${n}d ago` : `in ${n}d`; +} + +function formatDetailValue(value: unknown): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "number") { + // Show small floats with reasonable precision; ints as-is. + return Number.isInteger(value) ? String(value) : value.toFixed(2); + } + if (typeof value === "boolean") return value ? "yes" : "no"; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +interface ServiceRowProps { + service: HeartbeatService; + expanded: boolean; + onToggle: () => void; +} + +function ServiceRow({ service, expanded, onToggle }: ServiceRowProps) { + const detailEntries = Object.entries(service.details); + return ( + + + + + {expanded && ( +
+
+ {detailEntries.length === 0 ? ( +
+ no details surfaced +
+ ) : ( + detailEntries.map(([key, value]) => ( +
+
+ {key} +
+
{formatDetailValue(value)}
+
+ )) + )} +
+
+ last_check_at: {formatTimestamp(service.last_check_at)} +
+
+ )} +
+
+ ); +} + +export default function HeartbeatPanel() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [expandedNames, setExpandedNames] = useState>(new Set()); + const { toast, showToast } = useToast(); + + const loadHeartbeat = useCallback( + (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + api + .getHeartbeatServices() + .then((resp) => setData(resp)) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + showToast(`Failed to load heartbeat: ${msg}`, "error"); + }) + .finally(() => { + if (isManual) setRefreshing(false); + }); + }, + [showToast], + ); + + useEffect(() => { + loadHeartbeat(false); + }, [loadHeartbeat]); + + const toggleExpand = useCallback((name: string) => { + setExpandedNames((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + if (data === null && !loadError) { + return ( +
+ +
+ ); + } + + // Aggregate counts for the header summary strip. + const counts = data + ? { + 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, + } + : null; + + return ( +
+ + +
+
+

Backend Service Heartbeat

+

+ Health of the SaaS backends Joshua's work depends on. +

+
+ +
+ + {loadError && ( + + + +
+
Failed to load heartbeat
+
{loadError}
+
+
+
+ )} + + {data?.stub && ( + + + +
+
+ STUB — real data wires in via KR-FEAT-HEARTBEAT +
+
+ Values shown are hardcoded sample data, not live polling. + The Python heartbeat module that talks to each service's API + lands as a follow-on bucket after KR-D-DAEMON ST2. +
+
+
+
+ )} + + {data && ( + <> + {/* ── Aggregate summary strip ─────────────────────────── */} + + + + + {data.services.length} service{data.services.length === 1 ? "" : "s"} + + {counts && ( + <> + + + {counts.healthy} healthy + + + + {counts.degraded} degraded + + + + {counts.unhealthy} unhealthy + + + )} + + generated {formatRelative(data.generated_at)} ( + {formatTimestamp(data.generated_at)}) + + + + + {/* ── Services list ──────────────────────────────────── */} + {data.services.length === 0 ? ( + + + + No services configured. + + + ) : ( +
+ {data.services.map((s) => ( + toggleExpand(s.name)} + /> + ))} +
+ )} + + )} +
+ ); +}