diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index ad3ef66..15c00e4 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) |
@@ -258,6 +259,37 @@ Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`,
surfaces are backed by #636). Mutating methods on stub routes still fail closed
with `read-only-mvp`.
+## 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..417e4ae
--- /dev/null
+++ b/tests/test_webui_system_health_dashboard.py
@@ -0,0 +1,345 @@
+"""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 render_page
+from webui.nav import iter_nav_items
+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 safe | False |
", 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 safe | False |
", html)
+ self.assertIn("| Determinable | False |
", html)
+
+ def test_healthy_parity_reports_mutation_safe_true(self):
+ html = render_system_health_page(_snapshot())
+ self.assertIn("| Mutation safe | True |
", 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("