feat: add MCP runtime health dashboard to web UI (Closes #430)

Adds read-only /runtime and /api/runtime routes showing active profile,
identity, config model, git sync vs remote master, shell health,
workflow/schema hashes, and stale-runtime warnings with #420 guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 15:18:45 -04:00
co-authored by Claude Opus 4.8
parent ee8e9a0247
commit 4429113dae
6 changed files with 529 additions and 9 deletions
+12 -2
View File
@@ -43,7 +43,8 @@ Optional environment variables:
| `/api/projects` | JSON registry export | | `/api/projects` | JSON registry export |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) | | `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes | | `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | Stub — MCP runtime health (#430) | | `/runtime` | MCP runtime health and stale detection (#430) |
| `/api/runtime` | JSON runtime health export |
| `/audit` | Stub — report audit paste (#431) | | `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) | | `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Stub — lease visibility (#433) | | `/leases` | Stub — lease visibility (#433) |
@@ -82,8 +83,17 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
If credentials are missing or the fetch fails, the page shows an explicit error If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed). instead of an empty queue (fail closed).
## Runtime health (#430)
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
project: active profile and role kind, authenticated identity (when credentials
are available), config model/mode, local vs remote `master` SHA sync, shell
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.
## Tests ## Tests
```bash ```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_runtime_health.py -q
``` ```
+88
View File
@@ -0,0 +1,88 @@
"""Tests for web UI runtime health dashboard (#430)."""
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict
def _mock_identity(_host: str):
return "jcwalker3", None
def _mock_git_sync(_repo: Path, _remote: str, _branch: str):
return "localsha123456", "remoteshaabcdef", 2
class TestRuntimeHealthLoader(unittest.TestCase):
def test_role_kind_author(self):
allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"]
forbidden = ["gitea.pr.merge", "gitea.pr.approve"]
self.assertEqual(_role_kind(allowed, forbidden), "author")
def test_snapshot_with_mocks(self):
snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
self.assertEqual(snapshot.project_id, "gitea-tools")
self.assertEqual(snapshot.authenticated_username, "jcwalker3")
self.assertEqual(snapshot.commits_behind_master, 2)
self.assertIsNotNone(snapshot.stale_runtime_warning)
self.assertGreaterEqual(len(snapshot.workflow_hashes), 3)
self.assertGreaterEqual(len(snapshot.schema_hashes), 2)
self.assertIn("shell_use_allowed", snapshot.shell_health)
def test_snapshot_dict_export(self):
snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
data = snapshot_to_dict(snapshot)
self.assertEqual(data["authenticated_username"], "jcwalker3")
self.assertEqual(data["commits_behind_master"], 2)
self.assertTrue(data["stale_runtime_warning"])
class TestRuntimeRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
self.snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
self._patch = mock.patch(
"webui.app.load_runtime_snapshot",
return_value=self.snapshot,
)
self._patch.start()
def tearDown(self):
self._patch.stop()
def test_runtime_page_renders_profile_and_warning(self):
response = self.client.get("/runtime")
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
self.assertIn("Active profile", response.text)
self.assertIn("Stale runtime warning", response.text)
self.assertIn("Workflow hashes", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_runtime_json(self):
response = self.client.get("/api/runtime")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["project_id"], "gitea-tools")
self.assertEqual(data["role_kind"], self.snapshot.role_kind)
self.assertEqual(data["commits_behind_master"], 2)
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Operator console", response.text) self.assertIn("Operator console", response.text)
self.assertIn("Read-only MVP", response.text) self.assertIn("Read-only MVP", response.text)
def test_runtime_is_implemented(self):
response = self.client.get("/runtime")
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
def test_route_stubs_render(self): def test_route_stubs_render(self):
for path in ("/runtime", "/audit"): for path in ("/audit",):
with self.subTest(path=path): with self.subTest(path=path):
response = self.client.get(path) response = self.client.get(path)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
+11 -6
View File
@@ -14,8 +14,10 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.prompt_views import render_prompt_detail, render_prompts_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict
from webui.queue_views import render_queue_page from webui.queue_views import render_queue_page
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
from webui.runtime_views import render_runtime_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -61,7 +63,7 @@ async def queue(_request: Request) -> HTMLResponse:
async def api_queue(_request: Request) -> JSONResponse: async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(snapshot_to_dict(load_queue_snapshot())) return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
async def projects(_request: Request) -> HTMLResponse: async def projects(_request: Request) -> HTMLResponse:
@@ -120,10 +122,12 @@ async def api_prompts(_request: Request) -> JSONResponse:
async def runtime(_request: Request) -> HTMLResponse: async def runtime(_request: Request) -> HTMLResponse:
return _stub_page( snapshot = load_runtime_snapshot()
"Runtime", return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot)))
"Runtime health will report MCP profile, preflight, and stale-server signals.",
)
async def api_runtime(_request: Request) -> JSONResponse:
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
async def audit(_request: Request) -> HTMLResponse: async def audit(_request: Request) -> HTMLResponse:
@@ -172,6 +176,7 @@ def create_app() -> Starlette:
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]), Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
Route("/api/prompts", api_prompts, methods=["GET"]), Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]),
Route("/api/runtime", api_runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]), Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]), Route("/leases", leases, methods=["GET"]),
+320
View File
@@ -0,0 +1,320 @@
"""Read-only MCP/runtime health snapshot for the web UI (#430)."""
from __future__ import annotations
import hashlib
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse
import gitea_config
import reconciler_profile
from gitea_auth import REMOTES, api_request, get_auth_header, get_profile, gitea_url
from native_mcp_preference import shell_health_status
from webui.project_registry import ProjectRecord, load_registry
_RESTART_DOCS = "docs/llm-workflow-runbooks.md#issue-420-mcp-hot-reload"
_HOT_RELOAD_ISSUE = "#420"
@dataclass(frozen=True)
class FileHash:
label: str
path: str
sha256: str | None
error: str | None = None
@dataclass(frozen=True)
class RuntimeSnapshot:
project_id: str
repo_root: str
remote: str
host: str
profile_name: str
role_kind: str
config_model: str
profile_mode: str
profile_source: str
authenticated_username: str | None
identity_error: str | None
repo_sha: str | None
remote_master_sha: str | None
commits_behind_master: int | None
stale_runtime_warning: str | None
shell_health: dict[str, Any]
workflow_hashes: tuple[FileHash, ...]
schema_hashes: tuple[FileHash, ...]
restart_guidance: str
fetch_error: str | None = None
def _repo_root() -> Path:
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
if override:
return Path(override).resolve()
return Path(__file__).resolve().parent.parent
def _host_from_url(remote_host: str) -> str:
parsed = urlparse(remote_host.strip())
return parsed.netloc or remote_host.strip().rstrip("/")
def _remote_name_for_host(host: str) -> str:
for name, profile in REMOTES.items():
if profile.get("host") == host:
return name
return "custom"
def _config_model(config: dict | None) -> str:
if config is None:
return "env-only"
version = config.get("version")
if version == 1:
return "v1"
if version == 2:
if "contexts" in config or config.get("shape") == "contexts":
return "v2-contexts"
return "v2-environments"
return f"unknown-version-{version}"
def _profile_source() -> str:
if os.environ.get("GITEA_PROFILE_NAME"):
return "env var"
if gitea_config.selected_profile_name():
return "config file profile"
return "default"
def _role_kind(allowed: list[str], forbidden: list[str]) -> str:
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
return "reconciler"
def can(op: str) -> bool:
return gitea_config.check_operation(op, allowed, forbidden)[0]
review = can("gitea.pr.approve") or can("gitea.pr.merge")
author = can("gitea.pr.create") or can("gitea.branch.push")
reconciler = can("gitea.pr.close") and not review and not author
if review and author:
return "mixed"
if review:
return "reviewer"
if reconciler:
return "reconciler"
if author:
return "author"
return "limited"
def _sha256_file(path: Path) -> FileHash:
label = path.name
try:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
return FileHash(label=label, path=str(path), sha256=digest)
except OSError as exc:
return FileHash(label=label, path=str(path), sha256=None, error=str(exc))
def _run_git(repo: Path, *args: str) -> str | None:
try:
completed = subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip() or None
except (OSError, subprocess.CalledProcessError):
return None
def _git_sync_status(repo: Path, remote: str, branch: str) -> tuple[str | None, str | None, int | None]:
local_sha = _run_git(repo, "rev-parse", "HEAD")
remote_sha = _run_git(repo, "rev-parse", f"{remote}/{branch}")
behind = None
if local_sha and remote_sha and local_sha != remote_sha:
count = _run_git(repo, "rev-list", "--count", f"HEAD..{remote}/{branch}")
if count is not None and count.isdigit():
behind = int(count)
return local_sha, remote_sha, behind
def _resolve_username(host: str) -> tuple[str | None, str | None]:
auth = get_auth_header(host)
if not auth:
return None, f"Gitea credentials unavailable for {host}"
try:
data = api_request("GET", gitea_url(host, "/api/v1/user"), auth)
except Exception as exc: # noqa: BLE001 — operator-visible identity errors
return None, f"Identity lookup failed: {exc}"
username = (data or {}).get("login")
if not username:
return None, "Authenticated identity could not be resolved"
return str(username), None
def _stale_warning(commits_behind: int | None) -> str | None:
if commits_behind is None or commits_behind <= 0:
return None
return (
f"Local checkout is {commits_behind} commit(s) behind remote master. "
"Merged safety-gate changes may require an MCP server restart or profile "
f"reload — see {_HOT_RELOAD_ISSUE} / {_RESTART_DOCS}."
)
def load_runtime_snapshot(
project_id: str | None = None,
*,
resolve_username: Callable[[str], tuple[str | None, str | None]] | None = None,
git_sync: Callable[[Path, str, str], tuple[str | None, str | None, int | None]] | None = None,
) -> RuntimeSnapshot:
"""Build a read-only runtime health snapshot for the default registry project."""
registry = load_registry()
project: ProjectRecord | None = None
if project_id:
for entry in registry.projects:
if entry.id == project_id:
project = entry
break
else:
project = registry.projects[0] if registry.projects else None
if project is None:
return RuntimeSnapshot(
project_id=project_id or "",
repo_root=str(_repo_root()),
remote="",
host="",
profile_name="",
role_kind="",
config_model="",
profile_mode="",
profile_source="",
authenticated_username=None,
identity_error="project not found in registry",
repo_sha=None,
remote_master_sha=None,
commits_behind_master=None,
stale_runtime_warning=None,
shell_health={},
workflow_hashes=(),
schema_hashes=(),
restart_guidance=_RESTART_DOCS,
fetch_error="project not found in registry",
)
repo = _repo_root()
host = _host_from_url(project.remote_host)
remote = _remote_name_for_host(host)
profile = get_profile()
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
config = None
try:
config = gitea_config.load_config()
except gitea_config.ConfigError as exc:
return RuntimeSnapshot(
project_id=project.id,
repo_root=str(repo),
remote=remote,
host=host,
profile_name=profile.get("profile_name") or "",
role_kind=_role_kind(allowed, forbidden),
config_model="config-error",
profile_mode="unknown",
profile_source=_profile_source(),
authenticated_username=None,
identity_error=str(exc),
repo_sha=None,
remote_master_sha=None,
commits_behind_master=None,
stale_runtime_warning=None,
shell_health=shell_health_status(),
workflow_hashes=(),
schema_hashes=(),
restart_guidance=_RESTART_DOCS,
fetch_error=str(exc),
)
identity_fn = resolve_username or _resolve_username
username, identity_error = identity_fn(host)
git_fn = git_sync or _git_sync_status
remote_ref = "prgs" if remote == "prgs" else "origin"
local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch)
workflow_hashes = tuple(
_sha256_file(repo / rel_path)
for rel_path in project.workflow_paths.values()
)
schema_hashes = tuple(
_sha256_file(repo / rel_path)
for rel_path in project.schema_paths.values()
)
switching = gitea_config.is_runtime_switching_enabled()
return RuntimeSnapshot(
project_id=project.id,
repo_root=str(repo),
remote=remote,
host=host,
profile_name=str(profile.get("profile_name") or ""),
role_kind=_role_kind(allowed, forbidden),
config_model=_config_model(config),
profile_mode="dynamic-profile" if switching else "static-profile",
profile_source=_profile_source(),
authenticated_username=username,
identity_error=identity_error,
repo_sha=local_sha,
remote_master_sha=remote_sha,
commits_behind_master=behind,
stale_runtime_warning=_stale_warning(behind),
shell_health=shell_health_status(),
workflow_hashes=workflow_hashes,
schema_hashes=schema_hashes,
restart_guidance=_RESTART_DOCS,
)
def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]:
def _hash_row(item: FileHash) -> dict[str, Any]:
return {
"label": item.label,
"path": item.path,
"sha256": item.sha256,
"error": item.error,
}
return {
"project_id": snapshot.project_id,
"repo_root": snapshot.repo_root,
"remote": snapshot.remote,
"host": snapshot.host,
"profile_name": snapshot.profile_name,
"role_kind": snapshot.role_kind,
"config_model": snapshot.config_model,
"profile_mode": snapshot.profile_mode,
"profile_source": snapshot.profile_source,
"authenticated_username": snapshot.authenticated_username,
"identity_error": snapshot.identity_error,
"repo_sha": snapshot.repo_sha,
"remote_master_sha": snapshot.remote_master_sha,
"commits_behind_master": snapshot.commits_behind_master,
"stale_runtime_warning": snapshot.stale_runtime_warning,
"shell_health": snapshot.shell_health,
"workflow_hashes": [_hash_row(item) for item in snapshot.workflow_hashes],
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
"restart_guidance": snapshot.restart_guidance,
"fetch_error": snapshot.fetch_error,
}
+92
View File
@@ -0,0 +1,92 @@
"""HTML views for MCP runtime health (#430)."""
from __future__ import annotations
import html
from webui.runtime_health import FileHash, RuntimeSnapshot
def _hash_rows(items: tuple[FileHash, ...]) -> str:
if not items:
return "<p class='muted'>No hashes available.</p>"
rows = []
for item in items:
digest = item.sha256 or item.error or "unavailable"
rows.append(
"<tr>"
f"<td>{html.escape(item.label)}</td>"
f"<td><code>{html.escape(item.path)}</code></td>"
f"<td><code>{html.escape(digest[:16] if item.sha256 else digest)}</code></td>"
"</tr>"
)
return (
"<table class='registry'><thead><tr>"
"<th>Artifact</th><th>Path</th><th>SHA-256</th>"
"</tr></thead><tbody>"
f"{''.join(rows)}</tbody></table>"
)
def render_runtime_page(snapshot: RuntimeSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
'<div class="stub"><p><strong>Runtime snapshot incomplete:</strong> '
f"{html.escape(snapshot.fetch_error)}</p></div>"
)
identity = snapshot.authenticated_username or "unresolved"
if snapshot.identity_error:
identity = f"unresolved ({snapshot.identity_error})"
stale_block = ""
if snapshot.stale_runtime_warning:
stale_block = (
'<div class="stub"><p><strong>Stale runtime warning:</strong> '
f"{html.escape(snapshot.stale_runtime_warning)}</p></div>"
)
shell = snapshot.shell_health or {}
shell_summary = (
f"shell_use_allowed={shell.get('shell_use_allowed')} · "
f"failures={shell.get('consecutive_spawn_failures')} · "
f"hard_stopped={shell.get('hard_stopped')}"
)
return (
"<h2>Runtime health</h2>"
f"<p class='meta'>Project <code>{html.escape(snapshot.project_id)}</code> · "
f"host <code>{html.escape(snapshot.host)}</code> · "
f"repo root <code>{html.escape(snapshot.repo_root)}</code></p>"
f"{error_block}"
f"{stale_block}"
"<h3>Active profile</h3>"
"<table class='detail'>"
f"<tr><th>Profile</th><td><code>{html.escape(snapshot.profile_name)}</code></td></tr>"
f"<tr><th>Role kind</th><td>{html.escape(snapshot.role_kind)}</td></tr>"
f"<tr><th>Identity</th><td>{html.escape(identity)}</td></tr>"
f"<tr><th>Config model</th><td>{html.escape(snapshot.config_model)}</td></tr>"
f"<tr><th>Profile mode</th><td>{html.escape(snapshot.profile_mode)} "
f"({html.escape(snapshot.profile_source)})</td></tr>"
"</table>"
"<h3>Server code sync</h3>"
"<table class='detail'>"
f"<tr><th>Local HEAD</th><td><code>{html.escape(snapshot.repo_sha or 'unknown')}</code></td></tr>"
f"<tr><th>Remote {html.escape(snapshot.remote)}/{html.escape('master')}</th>"
f"<td><code>{html.escape(snapshot.remote_master_sha or 'unknown')}</code></td></tr>"
f"<tr><th>Commits behind</th><td>{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}</td></tr>"
"</table>"
"<h3>Shell health</h3>"
f"<p class='meta'>{html.escape(shell_summary)}</p>"
f"<p class='muted'>{html.escape(str(shell.get('safe_next_action') or ''))}</p>"
"<h3>Workflow hashes</h3>"
f"{_hash_rows(snapshot.workflow_hashes)}"
"<h3>Schema hashes</h3>"
f"{_hash_rows(snapshot.schema_hashes)}"
"<h3>Restart / reload</h3>"
"<p class='muted'>MVP is read-only — restart MCP servers from your IDE/operator "
"workflow. Related issue: <code>#420</code>. Guidance: "
f"<code>{html.escape(snapshot.restart_guidance)}</code></p>"
"<p class='muted'>This page does not expose tokens or perform MCP restarts.</p>"
)