Files
Gitea-Tools/webui/app.py
T
sysadminandClaude Opus 4.8 ecda200180 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]>
2026-07-23 16:08:36 -04:00

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