feat(webui): system-health dashboard (Closes #639)
Phase 1 child of the Web Console epic #631. Adds the operator-facing system-health dashboard on top of the read-only system-health API landed by #634, so runtime problems are visible on a surface instead of being discovered late through failed LLM sessions. - webui/system_health_views.py (new): renders the SystemHealthSnapshot as readiness, stale-runtime parity, version/uptime, dependency, MCP namespace, probe-error, and recovery cards. - webui/app.py: GET /system-health, sharing load_system_health() with the JSON API so page and API cannot disagree. ?deep=1 behaves as on the API. - webui/layout.py: nav entry and health card/badge styles. - tests/test_webui_system_health_dashboard.py (new, 26 cases). - docs/webui-local-dev.md: route, field authority, and redaction split. Readiness honesty is preserved from the API: ready and readiness_complete render separately, a probe that did not run is listed under "Not probed" rather than counted healthy, and mutation safety is never claimed when the runtime is stale or parity is indeterminate. Redaction is split by field kind. Free text (probe details, reasons, probe errors) passes through system_health.redact. Structured fields (commit SHAs, probe names, statuses, timestamps) are HTML-escaped only: redact's opaque-token rule matches any run of 32 or more characters, so routing a 40-character git SHA through it rendered "[redacted]" and blanked the parity evidence the page exists to show. Non-goals honored: no restart or reload controls (Phase 2, #642), no manual process-kill guidance (#630). Read-only throughout. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -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**
|
||||
|
||||
@@ -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("<tr><th>Mutation safe</th><td>False</td></tr>", 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("<tr><th>Mutation safe</th><td>False</td></tr>", html)
|
||||
self.assertIn("<tr><th>Determinable</th><td>False</td></tr>", html)
|
||||
|
||||
def test_healthy_parity_reports_mutation_safe_true(self):
|
||||
html = render_system_health_page(_snapshot())
|
||||
self.assertIn("<tr><th>Mutation safe</th><td>True</td></tr>", 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("<button", html)
|
||||
self.assertNotIn("<form", html)
|
||||
self.assertNotIn("pkill", html)
|
||||
self.assertIn("read-only", html)
|
||||
|
||||
def test_recovery_points_at_sanctioned_path(self):
|
||||
html = render_system_health_page(_snapshot())
|
||||
self.assertIn("Reconnect the MCP client", html)
|
||||
self.assertIn("Never kill the daemon process manually", html)
|
||||
|
||||
def test_secret_shaped_detail_is_redacted(self):
|
||||
leaky = DependencyProbe(
|
||||
name="gitea",
|
||||
kind="http",
|
||||
status=STATUS_DOWN,
|
||||
detail="auth failed for token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
required=False,
|
||||
latency_ms=12.0,
|
||||
)
|
||||
html = render_system_health_page(_snapshot(dependencies=(leaky,)))
|
||||
self.assertNotIn("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", html)
|
||||
|
||||
def test_html_in_detail_is_escaped(self):
|
||||
hostile = DependencyProbe(
|
||||
name="repository",
|
||||
kind="git",
|
||||
status=STATUS_DOWN,
|
||||
detail="<script>alert(1)</script>",
|
||||
required=True,
|
||||
)
|
||||
html = render_system_health_page(_snapshot(dependencies=(hostile,)))
|
||||
self.assertNotIn("<script>", 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="<p>x</p>")
|
||||
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()
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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; }}
|
||||
</style>
|
||||
{extra_head}
|
||||
</head>
|
||||
|
||||
@@ -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'<span class="badge {css}">{_esc(status)}</span>'
|
||||
|
||||
|
||||
def _reason_list(reasons: tuple[str, ...], *, empty: str) -> str:
|
||||
if not reasons:
|
||||
return f"<p class='muted'>{html.escape(empty)}</p>"
|
||||
items = "".join(f"<li>{_safe(reason)}</li>" for reason in reasons)
|
||||
return f"<ul class='reasons'>{items}</ul>"
|
||||
|
||||
|
||||
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 (
|
||||
"<section class='health-card'>"
|
||||
f"<h3>Readiness {_status_badge(snapshot.status)}</h3>"
|
||||
f"<p class='health-headline'>{html.escape(headline)}</p>"
|
||||
"<table class='detail'>"
|
||||
f"<tr><th>Service</th><td><code>{_esc(snapshot.service)}</code></td></tr>"
|
||||
f"<tr><th>Mode</th><td>{_esc(snapshot.mode)}</td></tr>"
|
||||
f"<tr><th>Ready</th><td>{_esc(snapshot.ready)}</td></tr>"
|
||||
"<tr><th>Readiness evidence complete</th>"
|
||||
f"<td>{_esc(snapshot.readiness_complete)}</td></tr>"
|
||||
"<tr><th>Deep probes requested</th>"
|
||||
f"<td>{_esc(snapshot.deep_probes_requested)}</td></tr>"
|
||||
f"<tr><th>Observed at</th><td><code>{_esc(snapshot.timestamp)}</code></td></tr>"
|
||||
"</table>"
|
||||
"<h4>Readiness reasons</h4>"
|
||||
f"{_reason_list(snapshot.readiness_reasons, empty='No readiness objections recorded.')}"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
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 (
|
||||
"<section class='health-card'>"
|
||||
"<h3>Version and uptime</h3>"
|
||||
"<table class='detail'>"
|
||||
f"<tr><th>Git SHA</th><td><code>{_esc(version.git_sha or 'unknown')}</code></td></tr>"
|
||||
"<tr><th>Git describe</th>"
|
||||
f"<td><code>{_esc(version.git_describe or 'unknown')}</code></td></tr>"
|
||||
"<tr><th>Control-plane schema</th>"
|
||||
f"<td>{_esc(schema if schema is not None else 'unknown')}</td></tr>"
|
||||
f"<tr><th>Python</th><td><code>{_esc(version.python_version)}</code></td></tr>"
|
||||
f"<tr><th>Version status</th><td>{html.escape(known)}</td></tr>"
|
||||
f"<tr><th>Started at</th><td><code>{_esc(snapshot.started_at)}</code></td></tr>"
|
||||
"<tr><th>Uptime</th>"
|
||||
f"<td>{snapshot.uptime_seconds:.3f}s ({uptime_hours:.2f}h)</td></tr>"
|
||||
"</table>"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def _dependency_rows(probes: tuple[DependencyProbe, ...]) -> str:
|
||||
if not probes:
|
||||
return "<p class='muted'>No dependency probes were reported.</p>"
|
||||
rows = []
|
||||
for probe in probes:
|
||||
latency = (
|
||||
f"{probe.latency_ms:.1f} ms" if probe.latency_ms is not None else "n/a"
|
||||
)
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><code>{_esc(probe.name)}</code></td>"
|
||||
f"<td>{_esc(probe.kind)}</td>"
|
||||
f"<td>{_status_badge(probe.status)}</td>"
|
||||
f"<td>{_esc('required' if probe.required else 'optional')}</td>"
|
||||
f"<td>{html.escape(latency)}</td>"
|
||||
f"<td>{_safe(probe.detail)}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
return (
|
||||
"<table class='registry'><thead><tr>"
|
||||
"<th>Dependency</th><th>Kind</th><th>Status</th><th>Requirement</th>"
|
||||
"<th>Latency</th><th>Detail</th>"
|
||||
"</tr></thead><tbody>"
|
||||
f"{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
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 += (
|
||||
"<div class='stub health-degraded'><p><strong>Degraded dependencies:</strong> "
|
||||
f"{_esc(names)}</p></div>"
|
||||
)
|
||||
if not_run:
|
||||
names = ", ".join(sorted(probe.name for probe in not_run))
|
||||
banner += (
|
||||
"<div class='stub'><p><strong>Not probed:</strong> "
|
||||
f"{_esc(names)} — these contribute no evidence and are not counted "
|
||||
"as healthy.</p></div>"
|
||||
)
|
||||
|
||||
return (
|
||||
"<section class='health-card'>"
|
||||
"<h3>Dependencies</h3>"
|
||||
f"{banner}"
|
||||
f"{_dependency_rows(snapshot.dependencies)}"
|
||||
"<p class='muted'>Details are redacted at the API boundary and again "
|
||||
"before rendering; credentials are never displayed.</p>"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def _namespace_card(snapshot: SystemHealthSnapshot) -> str:
|
||||
if not snapshot.mcp_namespaces:
|
||||
body = "<p class='muted'>No MCP namespaces are declared.</p>"
|
||||
else:
|
||||
rows = []
|
||||
for entry in snapshot.mcp_namespaces:
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><code>{_esc(entry.get('namespace'))}</code></td>"
|
||||
f"<td><code>{_esc(entry.get('required_tool'))}</code></td>"
|
||||
f"<td>{_status_badge(str(entry.get('status') or STATUS_UNPROVEN))}</td>"
|
||||
f"<td>{_esc(entry.get('ide_namespace_proven'))}</td>"
|
||||
f"<td>{_safe(entry.get('reason'))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
body = (
|
||||
"<table class='registry'><thead><tr>"
|
||||
"<th>Namespace</th><th>Required tool</th><th>Status</th>"
|
||||
"<th>IDE-proven</th><th>Reason</th>"
|
||||
"</tr></thead><tbody>"
|
||||
f"{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
return (
|
||||
"<section class='health-card'>"
|
||||
"<h3>MCP namespaces</h3>"
|
||||
f"{body}"
|
||||
"<p class='muted'>The web process runs outside the IDE-managed MCP "
|
||||
"client, so namespace health is reported as unproven rather than "
|
||||
"guessed (#543).</p>"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def _stale_runtime_card(snapshot: SystemHealthSnapshot) -> str:
|
||||
stale = snapshot.stale_runtime
|
||||
if stale.stale:
|
||||
warning = (
|
||||
"<div class='stub health-stale'><p><strong>Stale runtime:</strong> "
|
||||
"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.</p></div>"
|
||||
)
|
||||
elif not stale.determinable:
|
||||
warning = (
|
||||
"<div class='stub health-stale'><p><strong>Staleness "
|
||||
"indeterminate:</strong> parity could not be proven, so this "
|
||||
"runtime is not reported as mutation-safe.</p></div>"
|
||||
)
|
||||
else:
|
||||
warning = ""
|
||||
|
||||
return (
|
||||
"<section class='health-card'>"
|
||||
"<h3>Stale-runtime parity</h3>"
|
||||
f"{warning}"
|
||||
"<table class='detail'>"
|
||||
"<tr><th>Daemon head</th>"
|
||||
f"<td><code>{_esc(stale.daemon_head or 'unknown')}</code></td></tr>"
|
||||
"<tr><th>Checkout head</th>"
|
||||
f"<td><code>{_esc(stale.checkout_head or 'unknown')}</code></td></tr>"
|
||||
"<tr><th>Remote head</th>"
|
||||
f"<td><code>{_esc(stale.remote_head or 'unknown')}</code></td></tr>"
|
||||
f"<tr><th>Stale</th><td>{_esc(stale.stale)}</td></tr>"
|
||||
f"<tr><th>Determinable</th><td>{_esc(stale.determinable)}</td></tr>"
|
||||
f"<tr><th>Mutation safe</th><td>{_esc(stale.mutation_safe)}</td></tr>"
|
||||
"</table>"
|
||||
f"{_reason_list(stale.reasons, empty='Runtime, checkout, and remote agree.')}"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def _probe_error_card(snapshot: SystemHealthSnapshot) -> str:
|
||||
if not snapshot.probe_errors:
|
||||
return ""
|
||||
return (
|
||||
"<section class='health-card'>"
|
||||
"<h3>Probe errors</h3>"
|
||||
f"{_reason_list(snapshot.probe_errors, empty='')}"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def _recovery_card() -> str:
|
||||
"""Sanctioned recovery pointers only — never a manual process kill (#630)."""
|
||||
return (
|
||||
"<section class='health-card'>"
|
||||
"<h3>Recovery</h3>"
|
||||
"<p class='muted'>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.</p>"
|
||||
"<ul class='reasons'>"
|
||||
"<li><a href='/runtime'>Runtime and session view</a> — active profile, "
|
||||
"workflow hashes, and shell health.</li>"
|
||||
"<li>Reconnect the MCP client from the IDE, then re-run the blocked "
|
||||
"cycle. Never kill the daemon process manually: unmanaged kills are "
|
||||
"recorded as runtime contamination (#630).</li>"
|
||||
"<li>See <code>docs/webui-local-dev.md</code> for the documented "
|
||||
"recovery sequence.</li>"
|
||||
"</ul>"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
|
||||
def render_system_health_page(snapshot: SystemHealthSnapshot) -> str:
|
||||
"""Render the full system-health dashboard body."""
|
||||
return (
|
||||
"<h2>System health</h2>"
|
||||
"<p class='meta'>Read-only view of the Phase 1 system-health API "
|
||||
"(<code>/api/v1/system/health</code>). Reload this page to refresh; "
|
||||
"nothing here polls or mutates on your behalf.</p>"
|
||||
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()}"
|
||||
)
|
||||
Reference in New Issue
Block a user