diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 33dbf61..06ad035 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -54,6 +54,7 @@ status, onboarding checklist state, and the fail-closed error payloads (#635). | `/` | Home / operator overview | | `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) | | `/api/v1/system/health` | Structured read-only system health (#634) | +| `/system-health` | System-health dashboard — readiness, version/uptime, dependencies, MCP namespaces, stale-runtime parity (#639) | | `/queue` | Live PR and issue queue dashboard (#429) | | `/api/queue` | JSON queue export with pagination metadata | | `/projects` | Project registry list with status and onboarding progress (#427, #635) | @@ -233,6 +234,37 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the checkout is behind merged safety-gate changes. Restart guidance links to #420; no tokens or MCP restart actions are exposed. +## System-health dashboard (#639) + +`/system-health` renders the same snapshot the `/api/v1/system/health` API +returns, so the page and the API can never disagree. Cards: overall readiness, +stale-runtime parity, version and uptime, dependency probes, MCP namespaces, +probe errors (only when present), and recovery pointers. `?deep=1` opts into +the network probe exactly as the API does; the plain page load stays cheap. + +Field authority and honesty rules: + +* `ready` and `readiness_complete` are shown separately. A snapshot whose + required probes never ran is not the same as one that ran them and passed, + and the page never collapses the two into an unproven green. +* A probe that did not run appears under **Not probed**, never as healthy. +* `stale_runtime.mutation_safe` is displayed verbatim from the API. When the + runtime is stale, or when parity is indeterminate, the page warns and does + not claim mutation safety. +* MCP namespaces are reported `unproven`: the web process runs outside the + IDE-managed MCP client and cannot prove that path (#543). + +Redaction is split by field kind. Free text — probe details, readiness and +parity reasons, probe errors — passes through `system_health.redact`. +Structured fields — commit SHAs, probe names, statuses, timestamps — are +HTML-escaped only, because `redact`'s opaque-token rule matches any run of 32 +or more characters and would otherwise blank every 40-character git SHA, which +is precisely the evidence the parity view exists to show. + +The dashboard is read-only: no restart, reload, or process-kill control. Those +arrive in Phase 2 (#642). Recovery guidance points at the sanctioned client +reconnect / operator restart path — never a manual daemon kill (#630). + ## Deployment boundary (#435) MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused** diff --git a/tests/test_webui_system_health_dashboard.py b/tests/test_webui_system_health_dashboard.py new file mode 100644 index 0000000..cd52cd8 --- /dev/null +++ b/tests/test_webui_system_health_dashboard.py @@ -0,0 +1,341 @@ +"""Tests for the system-health dashboard view (#639). + +Covers the acceptance criteria directly: the page renders the health DTO +fields (AC1), degraded dependencies are visible (AC2), stale runtime is warned +prominently and never rendered as mutation-safe (AC3), healthy and degraded +fixtures both render (AC4), and the shell carries a nav entry (AC5). +""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.deployment_boundary import scan_text_for_client_secrets +from webui.layout import NAV_ITEMS, render_page +from webui.system_health import ( + STATUS_DEGRADED, + STATUS_DOWN, + STATUS_OK, + STATUS_SKIPPED, + STATUS_UNPROVEN, + DependencyProbe, + StaleRuntime, + SystemHealthSnapshot, + VersionInfo, +) +from webui.system_health_views import render_system_health_page + +DASHBOARD_PATH = "/system-health" + + +def _version(*, known: bool = True) -> VersionInfo: + return VersionInfo( + git_sha="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd" if known else None, + git_describe="v0.4.1-12-g1c455b6" if known else None, + control_plane_schema_version=4 if known else None, + python_version="3.13.1", + known=known, + ) + + +def _parity(*, stale: bool = False, determinable: bool = True) -> StaleRuntime: + if stale: + return StaleRuntime( + daemon_head="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + checkout_head="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + remote_head="cccccccccccccccccccccccccccccccccccccccc", + stale=True, + determinable=True, + mutation_safe=False, + reasons=("runtime, checkout, and remote commits disagree",), + ) + if not determinable: + return StaleRuntime( + daemon_head=None, + checkout_head=None, + remote_head=None, + stale=False, + determinable=False, + mutation_safe=False, + reasons=("local checkout HEAD could not be read",), + ) + return StaleRuntime( + daemon_head="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", + checkout_head="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", + remote_head="1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", + stale=False, + determinable=True, + mutation_safe=True, + reasons=(), + ) + + +def _snapshot( + *, + status: str = STATUS_OK, + ready: bool = True, + readiness_complete: bool = True, + readiness_reasons: tuple[str, ...] = (), + dependencies: tuple[DependencyProbe, ...] | None = None, + parity: StaleRuntime | None = None, + namespaces: tuple[dict, ...] = (), + probe_errors: tuple[str, ...] = (), + version_known: bool = True, +) -> SystemHealthSnapshot: + if dependencies is None: + dependencies = ( + DependencyProbe( + name="control_plane_db", + kind="sqlite", + status=STATUS_OK, + detail="schema version 4", + required=True, + latency_ms=1.25, + metadata={"schema_version": 4}, + ), + ) + return SystemHealthSnapshot( + status=status, + ready=ready, + readiness_complete=readiness_complete, + readiness_reasons=readiness_reasons, + service="mcp-control-plane-webui", + mode="read-only", + version=_version(known=version_known), + started_at="2026-07-23T19:50:47+00:00", + uptime_seconds=3661.5, + timestamp="2026-07-23T20:51:48+00:00", + deep_probes_requested=False, + dependencies=dependencies, + mcp_namespaces=namespaces, + stale_runtime=parity if parity is not None else _parity(), + probe_errors=probe_errors, + ) + + +class TestHealthyRender(unittest.TestCase): + """AC1 / AC4 — every health DTO field reaches the page.""" + + def setUp(self): + self.html = render_system_health_page(_snapshot()) + + def test_readiness_fields_render(self): + self.assertIn("System health", self.html) + self.assertIn("Ready", self.html) + self.assertIn("mcp-control-plane-webui", self.html) + self.assertIn("read-only", self.html) + self.assertIn("2026-07-23T20:51:48+00:00", self.html) + + def test_version_and_uptime_render(self): + self.assertIn("1c455b6ec0f9cb761fe6248de68c17e061fb5ecd", self.html) + self.assertIn("v0.4.1-12-g1c455b6", self.html) + self.assertIn("3.13.1", self.html) + self.assertIn("3661.500s", self.html) + self.assertIn("1.02h", self.html) + + def test_dependency_row_renders_with_latency(self): + self.assertIn("control_plane_db", self.html) + self.assertIn("sqlite", self.html) + self.assertIn("schema version 4", self.html) + self.assertIn("1.2 ms", self.html) + + def test_healthy_page_shows_no_stale_warning(self): + self.assertNotIn("Stale runtime:", self.html) + self.assertNotIn("Staleness", self.html) + + def test_unknown_version_is_labelled_not_faked(self): + html = render_system_health_page(_snapshot(version_known=False)) + self.assertIn("unknown", html) + self.assertIn("unresolved", html) + + +class TestDegradedRender(unittest.TestCase): + """AC2 — a degraded or unrun dependency is visible, not swallowed.""" + + def setUp(self): + self.deps = ( + DependencyProbe( + name="control_plane_db", + kind="sqlite", + status=STATUS_OK, + detail="schema version 4", + required=True, + latency_ms=0.9, + ), + DependencyProbe( + name="repository", + kind="git", + status=STATUS_DOWN, + detail="repository root is not a git checkout", + required=True, + latency_ms=4.0, + ), + DependencyProbe( + name="gitea", + kind="http", + status=STATUS_SKIPPED, + detail="deep probe not requested", + required=False, + ), + ) + self.html = render_system_health_page( + _snapshot( + status=STATUS_DEGRADED, + ready=False, + readiness_complete=False, + readiness_reasons=("required dependency 'repository' is down",), + dependencies=self.deps, + ) + ) + + def test_degraded_banner_names_the_dependency(self): + self.assertIn("Degraded dependencies:", self.html) + self.assertIn("repository", self.html) + + def test_not_run_probe_is_reported_separately(self): + self.assertIn("Not probed:", self.html) + self.assertIn("gitea", self.html) + self.assertIn("not counted", self.html) + + def test_not_ready_headline_and_reason(self): + self.assertIn("Not ready", self.html) + self.assertIn("required dependency 'repository' is down", self.html) + + def test_degraded_status_badge_present(self): + self.assertIn("badge-health-degraded", self.html) + self.assertIn("badge-health-down", self.html) + + def test_ready_but_incomplete_is_not_shown_as_plain_ready(self): + html = render_system_health_page( + _snapshot(ready=True, readiness_complete=False) + ) + self.assertIn("Ready (incomplete evidence)", html) + + +class TestStaleRuntimeWarning(unittest.TestCase): + """AC3 — staleness is prominent and never claims mutation safety.""" + + def test_stale_runtime_warns_and_denies_mutation_safety(self): + html = render_system_health_page(_snapshot(parity=_parity(stale=True))) + self.assertIn("Stale runtime:", html) + self.assertIn("do not treat this runtime as mutation-safe", html) + self.assertIn("Mutation safeFalse", html) + + def test_indeterminate_parity_is_not_reported_safe(self): + html = render_system_health_page( + _snapshot(parity=_parity(determinable=False)) + ) + self.assertIn("Staleness", html) + self.assertIn("Mutation safeFalse", html) + self.assertIn("DeterminableFalse", html) + + def test_healthy_parity_reports_mutation_safe_true(self): + html = render_system_health_page(_snapshot()) + self.assertIn("Mutation safeTrue", html) + + +class TestNamespacesAndErrors(unittest.TestCase): + def test_unproven_namespace_rows_render(self): + html = render_system_health_page( + _snapshot( + namespaces=( + { + "namespace": "gitea-author", + "required_tool": "gitea_lock_issue", + "status": STATUS_UNPROVEN, + "ide_namespace_proven": False, + "reason": "the web console cannot invoke the IDE-managed MCP client", + }, + ) + ) + ) + self.assertIn("gitea-author", html) + self.assertIn("gitea_lock_issue", html) + self.assertIn("badge-health-unproven", html) + + def test_no_namespaces_degrades_gracefully(self): + html = render_system_health_page(_snapshot(namespaces=())) + self.assertIn("No MCP namespaces are declared.", html) + + def test_probe_errors_render_when_present(self): + html = render_system_health_page( + _snapshot(probe_errors=("probe raised: disk offline",)) + ) + self.assertIn("Probe errors", html) + self.assertIn("disk offline", html) + + def test_probe_error_card_absent_when_clean(self): + self.assertNotIn("Probe errors", render_system_health_page(_snapshot())) + + +class TestReadOnlyAndRedaction(unittest.TestCase): + def test_no_restart_or_kill_controls(self): + html = render_system_health_page(_snapshot()) + self.assertNotIn("", html) + self.assertIn("<script>", html) + + +class TestNavAndRoute(unittest.TestCase): + """AC5 — the shell links the dashboard, and the route serves it.""" + + def setUp(self): + self.client = TestClient(create_app()) + + def test_nav_contains_system_health(self): + self.assertIn((DASHBOARD_PATH, "System health"), NAV_ITEMS) + + def test_rendered_shell_links_dashboard(self): + page = render_page(title="Home", body_html="

x

") + self.assertIn(f'href="{DASHBOARD_PATH}"', page) + + def test_route_renders_dashboard(self): + response = self.client.get(DASHBOARD_PATH) + self.assertEqual(response.status_code, 200) + self.assertIn("System health", response.text) + self.assertIn("Stale-runtime parity", response.text) + + def test_route_is_read_only(self): + self.assertEqual(self.client.post(DASHBOARD_PATH).status_code, 405) + + def test_live_page_leaks_no_client_secret(self): + findings = scan_text_for_client_secrets(self.client.get(DASHBOARD_PATH).text) + self.assertEqual(findings, []) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/webui/app.py b/webui/app.py index 2a79d6c..9a2b801 100644 --- a/webui/app.py +++ b/webui/app.py @@ -51,6 +51,7 @@ from webui.system_health import ( process_uptime, snapshot_to_dict as system_health_to_dict, ) +from webui.system_health_views import render_system_health_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) @@ -121,6 +122,24 @@ async def api_system_health(request: Request) -> JSONResponse: return JSONResponse(payload, status_code=200 if snapshot.ready else 503) +async def system_health(request: Request) -> HTMLResponse: + """Read-only system-health dashboard (#639). + + Shares the #634 snapshot loader with the JSON API so the page can never + disagree with it. `?deep=1` opts into the network probe exactly as the API + does; the default page load stays cheap. The response is always 200: this + is an operator view that must render the degraded state, not withhold it. + """ + deep = _truthy_flag(request.query_params.get("deep")) + snapshot = load_system_health(deep=deep) + return HTMLResponse( + render_page( + title="System health", + body_html=render_system_health_page(snapshot), + ) + ) + + async def queue(_request: Request) -> HTMLResponse: snapshot = load_queue_snapshot() return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot))) @@ -433,6 +452,7 @@ def create_app(*, bind_host: str | None = None) -> Starlette: Route("/", home, methods=["GET"]), Route("/health", health, methods=["GET"]), Route(SYSTEM_HEALTH_API_PATH, api_system_health, methods=["GET"]), + Route("/system-health", system_health, methods=["GET"]), Route("/queue", queue, methods=["GET"]), Route("/api/queue", api_queue, methods=["GET"]), Route("/projects", projects, methods=["GET"]), diff --git a/webui/layout.py b/webui/layout.py index 47bedc1..82e92ff 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -4,6 +4,7 @@ from __future__ import annotations NAV_ITEMS = ( ("/", "Home"), + ("/system-health", "System health"), ("/queue", "Queue"), ("/projects", "Projects"), ("/prompts", "Prompts"), @@ -161,6 +162,25 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: .badge-in-review {{ color: #9ec8f0; border-color: #3d5f7a; }} .badge-duplicate {{ color: #e0c27a; border-color: #6b5730; }} .badge-stale {{ color: #c9b8e8; border-color: #5a4a78; }} + .badge-health-ok {{ color: #8fd19e; border-color: #3d6b4a; }} + .badge-health-degraded {{ color: #e0c27a; border-color: #6b5730; }} + .badge-health-down {{ color: #f0a8a8; border-color: #7a3b3b; }} + .badge-health-skipped {{ color: var(--muted); }} + .badge-health-unproven {{ color: #c9b8e8; border-color: #5a4a78; }} + .health-card {{ + margin: 1.25rem 0; + padding: 0.85rem 1rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + }} + .health-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }} + .health-card h4 {{ margin: 1rem 0 0.35rem; font-size: 0.92rem; color: var(--muted); }} + .health-headline {{ color: var(--text); font-size: 1rem; margin: 0 0 0.5rem; }} + .health-degraded {{ border-left-color: #e0c27a; }} + .health-stale {{ border-left-color: #f0a8a8; }} + ul.reasons {{ margin: 0.35rem 0; padding-left: 1.15rem; color: var(--muted); font-size: 0.9rem; }} + ul.reasons li {{ margin-bottom: 0.3rem; }} {extra_head} diff --git a/webui/system_health_views.py b/webui/system_health_views.py new file mode 100644 index 0000000..e7ba492 --- /dev/null +++ b/webui/system_health_views.py @@ -0,0 +1,307 @@ +"""HTML views for the system-health dashboard (#639). + +Renders the read-only :class:`~webui.system_health.SystemHealthSnapshot` +produced by the Phase 1 system-health API (#634). The page offers no restart, +reload, or process-kill control: those are Phase 2 work, and manual process +kills are the contamination path #630 exists to prevent. + +Every free-text field passes through :func:`webui.system_health.redact` before +it reaches HTML, so a probe detail that captured a token or a credentialed URL +cannot leak through the dashboard even though the API redacts it already. +""" + +from __future__ import annotations + +import html + +from webui.system_health import ( + STATUS_DEGRADED, + STATUS_DOWN, + STATUS_OK, + STATUS_SKIPPED, + STATUS_UNPROVEN, + DependencyProbe, + SystemHealthSnapshot, + redact, +) + +_STATUS_BADGE_CLASS = { + STATUS_OK: "badge-health-ok", + STATUS_DEGRADED: "badge-health-degraded", + STATUS_DOWN: "badge-health-down", + STATUS_SKIPPED: "badge-health-skipped", + STATUS_UNPROVEN: "badge-health-unproven", +} + + +def _safe(value: object) -> str: + """Escape free text for HTML after redacting anything secret-shaped. + + Use this for every value that can carry arbitrary text — probe details, + reasons, probe errors — because those are where a credential could ride + along. + """ + return html.escape(redact(str(value))) + + +def _esc(value: object) -> str: + """Escape a structured field for HTML without redacting it. + + Commit SHAs, probe names, statuses, and timestamps are enumerated or + machine-generated, never credential-bearing. They must not go through + :func:`redact`: its opaque-token rule matches any 32-plus-character run, + so a 40-character git SHA would render as ``[redacted]`` and the parity + view — the one thing an operator reads this page for — would be blank. + """ + return html.escape(str(value)) + + +def _status_badge(status: str) -> str: + css = _STATUS_BADGE_CLASS.get(status, "badge-health-unproven") + return f'{_esc(status)}' + + +def _reason_list(reasons: tuple[str, ...], *, empty: str) -> str: + if not reasons: + return f"

{html.escape(empty)}

" + items = "".join(f"
  • {_safe(reason)}
  • " for reason in reasons) + return f"" + + +def _readiness_card(snapshot: SystemHealthSnapshot) -> str: + """Overall readiness. + + ``ready`` and ``readiness_complete`` are shown separately on purpose: a + snapshot whose required probes never ran is not the same as one that ran + them and passed, and collapsing the two would render an unproven green. + """ + if snapshot.ready and snapshot.readiness_complete: + headline = "Ready" + elif snapshot.ready: + headline = "Ready (incomplete evidence)" + else: + headline = "Not ready" + + return ( + "
    " + f"

    Readiness {_status_badge(snapshot.status)}

    " + f"

    {html.escape(headline)}

    " + "" + f"" + f"" + f"" + "" + f"" + "" + f"" + f"" + "
    Service{_esc(snapshot.service)}
    Mode{_esc(snapshot.mode)}
    Ready{_esc(snapshot.ready)}
    Readiness evidence complete{_esc(snapshot.readiness_complete)}
    Deep probes requested{_esc(snapshot.deep_probes_requested)}
    Observed at{_esc(snapshot.timestamp)}
    " + "

    Readiness reasons

    " + f"{_reason_list(snapshot.readiness_reasons, empty='No readiness objections recorded.')}" + "
    " + ) + + +def _version_card(snapshot: SystemHealthSnapshot) -> str: + version = snapshot.version + uptime_hours = snapshot.uptime_seconds / 3600.0 + known = ( + "resolved" + if version.known + else "unresolved — version fields could not be read from the checkout" + ) + schema = version.control_plane_schema_version + return ( + "
    " + "

    Version and uptime

    " + "" + f"" + "" + f"" + "" + f"" + f"" + f"" + f"" + "" + f"" + "
    Git SHA{_esc(version.git_sha or 'unknown')}
    Git describe{_esc(version.git_describe or 'unknown')}
    Control-plane schema{_esc(schema if schema is not None else 'unknown')}
    Python{_esc(version.python_version)}
    Version status{html.escape(known)}
    Started at{_esc(snapshot.started_at)}
    Uptime{snapshot.uptime_seconds:.3f}s ({uptime_hours:.2f}h)
    " + "
    " + ) + + +def _dependency_rows(probes: tuple[DependencyProbe, ...]) -> str: + if not probes: + return "

    No dependency probes were reported.

    " + rows = [] + for probe in probes: + latency = ( + f"{probe.latency_ms:.1f} ms" if probe.latency_ms is not None else "n/a" + ) + rows.append( + "" + f"{_esc(probe.name)}" + f"{_esc(probe.kind)}" + f"{_status_badge(probe.status)}" + f"{_esc('required' if probe.required else 'optional')}" + f"{html.escape(latency)}" + f"{_safe(probe.detail)}" + "" + ) + return ( + "" + "" + "" + "" + f"{''.join(rows)}
    DependencyKindStatusRequirementLatencyDetail
    " + ) + + +def _dependency_card(snapshot: SystemHealthSnapshot) -> str: + degraded = [probe for probe in snapshot.dependencies if probe.ran and not probe.healthy] + not_run = [probe for probe in snapshot.dependencies if not probe.ran] + + banner = "" + if degraded: + names = ", ".join(sorted(probe.name for probe in degraded)) + banner += ( + "

    Degraded dependencies: " + f"{_esc(names)}

    " + ) + if not_run: + names = ", ".join(sorted(probe.name for probe in not_run)) + banner += ( + "

    Not probed: " + f"{_esc(names)} — these contribute no evidence and are not counted " + "as healthy.

    " + ) + + return ( + "
    " + "

    Dependencies

    " + f"{banner}" + f"{_dependency_rows(snapshot.dependencies)}" + "

    Details are redacted at the API boundary and again " + "before rendering; credentials are never displayed.

    " + "
    " + ) + + +def _namespace_card(snapshot: SystemHealthSnapshot) -> str: + if not snapshot.mcp_namespaces: + body = "

    No MCP namespaces are declared.

    " + else: + rows = [] + for entry in snapshot.mcp_namespaces: + rows.append( + "" + f"{_esc(entry.get('namespace'))}" + f"{_esc(entry.get('required_tool'))}" + f"{_status_badge(str(entry.get('status') or STATUS_UNPROVEN))}" + f"{_esc(entry.get('ide_namespace_proven'))}" + f"{_safe(entry.get('reason'))}" + "" + ) + body = ( + "" + "" + "" + "" + f"{''.join(rows)}
    NamespaceRequired toolStatusIDE-provenReason
    " + ) + return ( + "
    " + "

    MCP namespaces

    " + f"{body}" + "

    The web process runs outside the IDE-managed MCP " + "client, so namespace health is reported as unproven rather than " + "guessed (#543).

    " + "
    " + ) + + +def _stale_runtime_card(snapshot: SystemHealthSnapshot) -> str: + stale = snapshot.stale_runtime + if stale.stale: + warning = ( + "

    Stale runtime: " + "the running code, the checkout, and the remote-tracking commit " + "disagree. Capability gates may be evaluating obsolete code — " + "do not treat this runtime as mutation-safe.

    " + ) + elif not stale.determinable: + warning = ( + "

    Staleness " + "indeterminate: parity could not be proven, so this " + "runtime is not reported as mutation-safe.

    " + ) + else: + warning = "" + + return ( + "
    " + "

    Stale-runtime parity

    " + f"{warning}" + "" + "" + f"" + "" + f"" + "" + f"" + f"" + f"" + f"" + "
    Daemon head{_esc(stale.daemon_head or 'unknown')}
    Checkout head{_esc(stale.checkout_head or 'unknown')}
    Remote head{_esc(stale.remote_head or 'unknown')}
    Stale{_esc(stale.stale)}
    Determinable{_esc(stale.determinable)}
    Mutation safe{_esc(stale.mutation_safe)}
    " + f"{_reason_list(stale.reasons, empty='Runtime, checkout, and remote agree.')}" + "
    " + ) + + +def _probe_error_card(snapshot: SystemHealthSnapshot) -> str: + if not snapshot.probe_errors: + return "" + return ( + "
    " + "

    Probe errors

    " + f"{_reason_list(snapshot.probe_errors, empty='')}" + "
    " + ) + + +def _recovery_card() -> str: + """Sanctioned recovery pointers only — never a manual process kill (#630).""" + return ( + "
    " + "

    Recovery

    " + "

    This dashboard is read-only. Restart and reload " + "controls arrive in Phase 2 (#642); until then recovery runs through " + "the sanctioned client reconnect / operator restart path.

    " + "" + "
    " + ) + + +def render_system_health_page(snapshot: SystemHealthSnapshot) -> str: + """Render the full system-health dashboard body.""" + return ( + "

    System health

    " + "

    Read-only view of the Phase 1 system-health API " + "(/api/v1/system/health). Reload this page to refresh; " + "nothing here polls or mutates on your behalf.

    " + f"{_readiness_card(snapshot)}" + f"{_stale_runtime_card(snapshot)}" + f"{_version_card(snapshot)}" + f"{_dependency_card(snapshot)}" + f"{_namespace_card(snapshot)}" + f"{_probe_error_card(snapshot)}" + f"{_recovery_card()}" + )