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
82 changes: 82 additions & 0 deletions kora_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
165 changes: 165 additions & 0 deletions tests/kora_cli/test_web_server_heartbeat.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -133,6 +134,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/sessions": SessionsPage,
"/operational-state": OperationalStatePage,
"/health-rollup": HealthRollupPage,
"/heartbeat": HeartbeatPanel,
"/boot-status": BootStatusPage,
"/dr-state": DRStatePage,
"/cost-state": CostStatePage,
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ export const api = {
getRunbooks: () => fetchJSON<RunbooksManifest>("/api/runbooks"),
getRunbookContent: (id: string) =>
fetchText(`/api/runbooks/${encodeURIComponent(id)}/content`),
getHeartbeatServices: () =>
fetchJSON<HeartbeatServicesResponse>("/api/heartbeat/services"),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
Expand Down Expand Up @@ -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<string, unknown>;
}

export interface HeartbeatServicesResponse {
services: HeartbeatService[];
generated_at: string;
stub: boolean;
}
Loading