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]>
499 lines
18 KiB
Python
499 lines
18 KiB
Python
"""Starlette application for the internal read-only web UI (#426)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from starlette.applications import Starlette
|
|
from starlette.requests import Request
|
|
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
from starlette.routing import Route
|
|
|
|
from webui.deployment_boundary import deployment_snapshot
|
|
from webui.layout import render_page
|
|
from webui.project_registry import (
|
|
ProjectRegistry,
|
|
RegistryError,
|
|
find_project,
|
|
known_project_ids,
|
|
load_registry,
|
|
project_detail_to_dict,
|
|
registry_to_dict,
|
|
)
|
|
from webui.project_views import (
|
|
render_project_detail,
|
|
render_projects_list,
|
|
render_registry_error,
|
|
)
|
|
from webui.prompt_library import find_prompt, library_to_dict
|
|
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
|
from final_report_validator import FINAL_REPORT_TASK_KINDS
|
|
|
|
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
|
from webui.gated_action_views import render_actions_page
|
|
from webui import console_audit
|
|
from webui.console_authz import authorize, rbac_matrix, resolve_principal
|
|
from webui.console_redaction import redaction_policy
|
|
from webui.audit_validator import audit_report, audit_to_dict
|
|
from webui.audit_views import render_audit_page
|
|
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
|
from webui.lease_views import render_leases_page
|
|
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.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict
|
|
from webui.worktree_views import render_worktrees_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
|
|
from webui.system_health import (
|
|
API_PATH as SYSTEM_HEALTH_API_PATH,
|
|
load_system_health,
|
|
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"})
|
|
|
|
|
|
def _stub_page(title: str, description: str) -> HTMLResponse:
|
|
body = (
|
|
f"<h2>{title}</h2>"
|
|
f'<div class="stub"><p>{description}</p>'
|
|
"<p>Implementation tracked in a child issue of #425.</p></div>"
|
|
)
|
|
return HTMLResponse(render_page(title=title, body_html=body))
|
|
|
|
|
|
async def home(_request: Request) -> HTMLResponse:
|
|
body = (
|
|
"<h2>Operator console</h2>"
|
|
"<p>Local entry point for MCP Control Plane operational views.</p>"
|
|
"<ul>"
|
|
"<li><strong>Queue</strong> — live PR and issue dashboard (#429)</li>"
|
|
"<li><strong>Projects</strong> — registry and onboarding (#427)</li>"
|
|
"<li><strong>Prompts</strong> — canonical workflow prompt library (#428)</li>"
|
|
"<li><strong>Runtime</strong> — MCP health and stale-runtime detection (#430)</li>"
|
|
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
|
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
|
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
|
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
|
|
"</ul>"
|
|
)
|
|
return HTMLResponse(render_page(title="Home", body_html=body))
|
|
|
|
|
|
async def health(_request: Request) -> JSONResponse:
|
|
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
|
|
|
|
Every MVP key is retained so existing pollers keep working; the additions
|
|
are a pointer to the structured API and the in-memory process uptime.
|
|
Readiness lives at that API because answering it costs real probes.
|
|
"""
|
|
bind_host = _request.app.state.webui_bind_host
|
|
started_at, uptime_seconds = process_uptime()
|
|
return JSONResponse({
|
|
"status": "ok",
|
|
"service": "mcp-control-plane-webui",
|
|
"mode": "read-only-mvp",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"deployment": deployment_snapshot(bind_host=bind_host),
|
|
"started_at": started_at,
|
|
"uptime_seconds": uptime_seconds,
|
|
"system_health_api": SYSTEM_HEALTH_API_PATH,
|
|
})
|
|
|
|
|
|
def _truthy_flag(value: str | None) -> bool:
|
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
async def api_system_health(request: Request) -> JSONResponse:
|
|
"""Structured read-only system health (#634).
|
|
|
|
`?deep=1` opts into the expensive network probe. The response status code
|
|
reflects readiness so automated checks can branch on it without parsing the
|
|
body: 200 when ready, 503 when a required dependency failed or never ran.
|
|
"""
|
|
deep = _truthy_flag(request.query_params.get("deep"))
|
|
snapshot = load_system_health(deep=deep)
|
|
payload = system_health_to_dict(snapshot)
|
|
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)))
|
|
|
|
|
|
async def api_queue(_request: Request) -> JSONResponse:
|
|
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
|
|
|
|
|
|
def _load_project_registry() -> tuple[ProjectRegistry | None, RegistryError | None]:
|
|
"""Load the registry, converting validation failure into a fail-closed pair."""
|
|
try:
|
|
return load_registry(), None
|
|
except RegistryError as exc:
|
|
return None, exc
|
|
|
|
|
|
async def projects(_request: Request) -> HTMLResponse:
|
|
registry, error = _load_project_registry()
|
|
if error is not None:
|
|
return HTMLResponse(render_registry_error(error), status_code=500)
|
|
return HTMLResponse(render_projects_list(registry))
|
|
|
|
|
|
async def project_detail(request: Request) -> HTMLResponse:
|
|
project_id = request.path_params["project_id"]
|
|
registry, error = _load_project_registry()
|
|
if error is not None:
|
|
return HTMLResponse(render_registry_error(error), status_code=500)
|
|
project = find_project(registry, project_id)
|
|
if project is None:
|
|
return HTMLResponse(
|
|
render_page(
|
|
title="Project not found",
|
|
body_html=(
|
|
"<h2>Project not found</h2>"
|
|
f"<p>No registry entry for <code>{project_id}</code>.</p>"
|
|
'<p><a href="/projects">← All projects</a></p>'
|
|
),
|
|
),
|
|
status_code=404,
|
|
)
|
|
return HTMLResponse(render_project_detail(project))
|
|
|
|
|
|
async def api_projects(_request: Request) -> JSONResponse:
|
|
"""Unversioned MVP alias, retained through Phase 1 (#632 section 6)."""
|
|
registry, error = _load_project_registry()
|
|
if error is not None:
|
|
return JSONResponse(error.to_dict(), status_code=500)
|
|
return JSONResponse(registry_to_dict(registry))
|
|
|
|
|
|
async def api_v1_projects(_request: Request) -> JSONResponse:
|
|
registry, error = _load_project_registry()
|
|
if error is not None:
|
|
return JSONResponse(error.to_dict(), status_code=500)
|
|
return JSONResponse(registry_to_dict(registry))
|
|
|
|
|
|
async def api_v1_project_detail(request: Request) -> JSONResponse:
|
|
project_id = request.path_params["project_id"]
|
|
registry, error = _load_project_registry()
|
|
if error is not None:
|
|
return JSONResponse(error.to_dict(), status_code=500)
|
|
project = find_project(registry, project_id)
|
|
if project is None:
|
|
return JSONResponse(
|
|
{
|
|
"error": "project_not_found",
|
|
"project_id": project_id,
|
|
"known_project_ids": known_project_ids(registry),
|
|
"remediation": (
|
|
"Request one of the known project ids, or add the project to the "
|
|
"registry file named in 'source'."
|
|
),
|
|
"source": {
|
|
"kind": "file",
|
|
"path": str(registry.source_path),
|
|
"inventory_complete": True,
|
|
},
|
|
},
|
|
status_code=404,
|
|
)
|
|
return JSONResponse(project_detail_to_dict(registry, project))
|
|
|
|
|
|
async def prompts(_request: Request) -> HTMLResponse:
|
|
return HTMLResponse(render_prompts_page())
|
|
|
|
|
|
async def prompt_detail(request: Request) -> HTMLResponse:
|
|
prompt_id = request.path_params["prompt_id"]
|
|
prompt = find_prompt(prompt_id)
|
|
if prompt is None:
|
|
return HTMLResponse(
|
|
render_page(
|
|
title="Prompt not found",
|
|
body_html=(
|
|
"<h2>Prompt not found</h2>"
|
|
f"<p>No library entry for <code>{prompt_id}</code>.</p>"
|
|
'<p><a href="/prompts">← All prompts</a></p>'
|
|
),
|
|
),
|
|
status_code=404,
|
|
)
|
|
return HTMLResponse(render_prompt_detail(prompt))
|
|
|
|
|
|
async def api_prompts(_request: Request) -> JSONResponse:
|
|
return JSONResponse(library_to_dict())
|
|
|
|
|
|
async def runtime(_request: Request) -> HTMLResponse:
|
|
snapshot = load_runtime_snapshot()
|
|
return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot)))
|
|
|
|
|
|
async def api_runtime(_request: Request) -> JSONResponse:
|
|
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
|
|
|
|
|
|
async def _parse_audit_form(request: Request) -> tuple[str, str | None]:
|
|
if request.method == "GET":
|
|
return "", None
|
|
content_type = (request.headers.get("content-type") or "").lower()
|
|
if "application/json" in content_type:
|
|
payload = await request.json()
|
|
if not isinstance(payload, dict):
|
|
return "", None
|
|
report_text = str(payload.get("report_text") or "")
|
|
task_kind = payload.get("task_kind")
|
|
return report_text, str(task_kind) if task_kind else None
|
|
form = await request.form()
|
|
report_text = str(form.get("report_text") or "")
|
|
task_kind = form.get("task_kind")
|
|
return report_text, str(task_kind) if task_kind else None
|
|
|
|
|
|
async def audit(request: Request) -> HTMLResponse:
|
|
report_text, task_kind = await _parse_audit_form(request)
|
|
result = None
|
|
if request.method == "POST" and report_text.strip():
|
|
result = audit_report(report_text, task_kind=task_kind)
|
|
return HTMLResponse(
|
|
render_audit_page(
|
|
report_text=report_text,
|
|
task_kind=task_kind,
|
|
result=result,
|
|
)
|
|
)
|
|
|
|
|
|
async def api_audit(request: Request) -> JSONResponse:
|
|
if request.method == "GET":
|
|
return JSONResponse({
|
|
"usage": "POST JSON with report_text and optional task_kind",
|
|
"task_kinds": sorted(FINAL_REPORT_TASK_KINDS),
|
|
})
|
|
report_text, task_kind = await _parse_audit_form(request)
|
|
if not report_text.strip():
|
|
return JSONResponse({"error": "report_text required"}, status_code=400)
|
|
return JSONResponse(audit_to_dict(audit_report(report_text, task_kind=task_kind)))
|
|
|
|
|
|
async def worktrees(_request: Request) -> HTMLResponse:
|
|
snapshot = load_hygiene_snapshot()
|
|
return HTMLResponse(render_worktrees_page(snapshot))
|
|
|
|
|
|
async def api_worktrees(_request: Request) -> JSONResponse:
|
|
return JSONResponse(worktree_snapshot_to_dict(load_hygiene_snapshot()))
|
|
|
|
|
|
async def leases(_request: Request) -> HTMLResponse:
|
|
snapshot = load_lease_snapshot()
|
|
return HTMLResponse(render_leases_page(snapshot))
|
|
|
|
|
|
async def api_leases(_request: Request) -> JSONResponse:
|
|
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
|
|
|
|
|
|
async def actions(_request: Request) -> HTMLResponse:
|
|
registry = load_action_registry()
|
|
return HTMLResponse(render_actions_page(registry))
|
|
|
|
|
|
async def api_actions(_request: Request) -> JSONResponse:
|
|
return JSONResponse(load_action_registry().to_dict())
|
|
|
|
|
|
def _request_id() -> str:
|
|
return f"req-{uuid.uuid4().hex}"
|
|
|
|
|
|
def _audit_target(action_id: str, params: dict[str, object]) -> dict[str, object]:
|
|
"""Describe the action target for the audit record (never secrets)."""
|
|
if "pr_number" in params:
|
|
return {"kind": "pr", "ref": f"#{params['pr_number']}"}
|
|
if "issue_number" in params:
|
|
return {"kind": "issue", "ref": f"#{params['issue_number']}"}
|
|
if "branch_name" in params:
|
|
return {"kind": "branch", "ref": str(params["branch_name"])}
|
|
return {"kind": "unspecified", "ref": action_id}
|
|
|
|
|
|
def _authorize_request(
|
|
request: Request,
|
|
action_id: str,
|
|
params: dict[str, object],
|
|
*,
|
|
for_execution: bool,
|
|
result: str,
|
|
) -> dict[str, object]:
|
|
"""Resolve principal, decide, and audit. Returns the decision payload.
|
|
|
|
Phase 1 records the decision rather than enforcing it as the terminal
|
|
outcome: ``webui.gated_actions`` already fails closed for every action, so
|
|
this layer cannot loosen anything. Phase 2 enforces on this same decision.
|
|
"""
|
|
principal = resolve_principal(headers=dict(request.headers))
|
|
decision = authorize(action_id, principal, for_execution=for_execution)
|
|
console_audit.record_event(
|
|
action_id=action_id,
|
|
result=result,
|
|
decision=decision,
|
|
principal=principal,
|
|
target=_audit_target(action_id, params),
|
|
request_id=_request_id(),
|
|
detail=decision.detail,
|
|
)
|
|
return decision.to_dict()
|
|
|
|
|
|
async def api_action_preview(request: Request) -> JSONResponse:
|
|
action_id = request.path_params["action_id"]
|
|
params = dict(request.query_params)
|
|
for key in ("issue_number", "pr_number"):
|
|
if key in params:
|
|
params[key] = int(params[key])
|
|
result = preview_action(action_id, **params)
|
|
if "error" in result:
|
|
return JSONResponse(result, status_code=404)
|
|
result["authorization"] = _authorize_request(
|
|
request,
|
|
action_id,
|
|
params,
|
|
for_execution=False,
|
|
result=console_audit.RESULT_PREVIEWED,
|
|
)
|
|
return JSONResponse(result)
|
|
|
|
|
|
async def api_action_attempt(request: Request) -> JSONResponse:
|
|
"""POST is rejected by read-only guard; kept for explicit fail-closed tests."""
|
|
action_id = request.path_params["action_id"]
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
if not isinstance(body, dict):
|
|
body = {}
|
|
result = attempt_action(action_id, **body)
|
|
authorization = _authorize_request(
|
|
request,
|
|
action_id,
|
|
body,
|
|
for_execution=True,
|
|
result=(
|
|
console_audit.RESULT_DENIED
|
|
if not result.get("success")
|
|
else console_audit.RESULT_ALLOWED
|
|
),
|
|
)
|
|
result["authorization"] = authorization
|
|
status = 403 if not result.get("success") else 200
|
|
return JSONResponse(result, status_code=status)
|
|
|
|
|
|
async def api_console_security_model(_request: Request) -> JSONResponse:
|
|
"""Read-only publication of the #633 authorization/redaction/audit model."""
|
|
return JSONResponse({
|
|
"rbac": rbac_matrix(),
|
|
"redaction": redaction_policy(),
|
|
"audit": console_audit.audit_policy(),
|
|
})
|
|
|
|
|
|
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
|
path = request.url.path
|
|
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
if request.method not in _READ_ONLY_METHODS:
|
|
return JSONResponse(
|
|
{"error": "read-only-mvp", "detail": f"{request.method} not permitted"},
|
|
status_code=405,
|
|
)
|
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
|
|
|
|
def create_app(*, bind_host: str | None = None) -> Starlette:
|
|
"""Build the read-only MVP Starlette app."""
|
|
import os
|
|
|
|
resolved_bind = bind_host or os.environ.get("WEBUI_HOST", "127.0.0.1")
|
|
app = Starlette(
|
|
debug=False,
|
|
routes=[
|
|
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"]),
|
|
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
|
Route("/api/projects", api_projects, methods=["GET"]),
|
|
Route("/api/v1/projects", api_v1_projects, methods=["GET"]),
|
|
Route(
|
|
"/api/v1/projects/{project_id}",
|
|
api_v1_project_detail,
|
|
methods=["GET"],
|
|
),
|
|
Route("/prompts", prompts, methods=["GET"]),
|
|
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
|
Route("/api/prompts", api_prompts, methods=["GET"]),
|
|
Route("/runtime", runtime, methods=["GET"]),
|
|
Route("/api/runtime", api_runtime, methods=["GET"]),
|
|
Route("/audit", audit, methods=["GET", "POST"]),
|
|
Route("/api/audit", api_audit, methods=["GET", "POST"]),
|
|
Route("/worktrees", worktrees, methods=["GET"]),
|
|
Route("/api/worktrees", api_worktrees, methods=["GET"]),
|
|
Route("/leases", leases, methods=["GET"]),
|
|
Route("/actions", actions, methods=["GET"]),
|
|
Route("/api/actions", api_actions, methods=["GET"]),
|
|
Route(
|
|
"/api/actions/{action_id}/preview",
|
|
api_action_preview,
|
|
methods=["GET"],
|
|
),
|
|
Route(
|
|
"/api/actions/{action_id}/attempt",
|
|
api_action_attempt,
|
|
methods=["POST"],
|
|
),
|
|
Route("/api/leases", api_leases, methods=["GET"]),
|
|
Route(
|
|
"/api/console/security-model",
|
|
api_console_security_model,
|
|
methods=["GET"],
|
|
),
|
|
],
|
|
exception_handlers={405: method_not_allowed},
|
|
)
|
|
app.state.webui_bind_host = resolved_bind
|
|
return app |