From a8fcf0e01cede2b1f0d3013797f2f05a05b6b002 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 13:23:09 -0400 Subject: [PATCH] feat: add internal web UI server skeleton (Closes #426) Starlette read-only MVP with shared layout, /health JSON liveness, and route stubs for projects, prompts, runtime, audit, worktrees, and leases. Includes scripts/run-webui, docs/webui-local-dev.md, and tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 53 ++++++++++++++++ scripts/run-webui | 6 ++ tests/test_webui_skeleton.py | 58 +++++++++++++++++ webui/__init__.py | 5 ++ webui/__main__.py | 19 ++++++ webui/app.py | 117 +++++++++++++++++++++++++++++++++++ webui/layout.py | 102 ++++++++++++++++++++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 docs/webui-local-dev.md create mode 100755 scripts/run-webui create mode 100644 tests/test_webui_skeleton.py create mode 100644 webui/__init__.py create mode 100644 webui/__main__.py create mode 100644 webui/app.py create mode 100644 webui/layout.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md new file mode 100644 index 0000000..48a540c --- /dev/null +++ b/docs/webui-local-dev.md @@ -0,0 +1,53 @@ +# Internal web UI — local development (#426) + +Read-only MVP skeleton for the MCP Control Plane operator console. Gitea, +MCP capability gates, and `skills/llm-project-workflow/` remain the source of +truth; this UI only provides route stubs and layout. + +## Prerequisites + +- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`) +- No secrets in repo, config, or client bundle + +## Start the server + +From the repository root (or an issue worktree): + +```bash +./scripts/run-webui +``` + +Or directly: + +```bash +python3 -m webui +``` + +Optional environment variables: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) | +| `WEBUI_PORT` | `8765` | Listen port | + +## Routes (MVP) + +| Path | Description | +|------|-------------| +| `/` | Home / operator overview | +| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) | +| `/projects` | Stub — registry (#427) | +| `/prompts` | Stub — prompt library (#428) | +| `/runtime` | Stub — MCP runtime health (#430) | +| `/audit` | Stub — report audit paste (#431) | +| `/worktrees` | Stub — hygiene dashboard (#432) | +| `/leases` | Stub — lease visibility (#433) | + +All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with +`read-only-mvp`. + +## Tests + +```bash +pytest tests/test_webui_skeleton.py -q +``` \ No newline at end of file diff --git a/scripts/run-webui b/scripts/run-webui new file mode 100755 index 0000000..fe9f58c --- /dev/null +++ b/scripts/run-webui @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +cd "$repo_root" +exec python3 -m webui "$@" \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py new file mode 100644 index 0000000..89471af --- /dev/null +++ b/tests/test_webui_skeleton.py @@ -0,0 +1,58 @@ +"""Tests for internal web UI skeleton (#426).""" +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 + + +class TestWebuiSkeleton(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_health_returns_json(self): + response = self.client.get("/health") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["status"], "ok") + self.assertEqual(data["service"], "mcp-control-plane-webui") + self.assertEqual(data["mode"], "read-only-mvp") + self.assertIn("timestamp", data) + + def test_home_renders(self): + response = self.client.get("/") + self.assertEqual(response.status_code, 200) + self.assertIn("Operator console", response.text) + self.assertIn("Read-only MVP", response.text) + + def test_route_stubs_render(self): + for path in ("/projects", "/prompts", "/runtime", "/audit"): + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertIn("child issue", response.text.lower()) + + def test_extra_stub_routes(self): + for path in ("/worktrees", "/leases"): + with self.subTest(path=path): + self.assertEqual(self.client.get(path).status_code, 200) + + def test_post_is_rejected(self): + response = self.client.post("/health") + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json()["error"], "read-only-mvp") + + def test_nav_links_on_all_pages(self): + for path in ("/", "/projects", "/prompts", "/runtime", "/audit"): + with self.subTest(path=path): + text = self.client.get(path).text + for href in ("/projects", "/prompts", "/runtime", "/audit"): + self.assertIn(f'href="{href}"', text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/webui/__init__.py b/webui/__init__.py new file mode 100644 index 0000000..56829f0 --- /dev/null +++ b/webui/__init__.py @@ -0,0 +1,5 @@ +"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426).""" + +from webui.app import create_app + +__all__ = ["create_app"] \ No newline at end of file diff --git a/webui/__main__.py b/webui/__main__.py new file mode 100644 index 0000000..c55b05e --- /dev/null +++ b/webui/__main__.py @@ -0,0 +1,19 @@ +"""Run the internal web UI: ``python -m webui``.""" + +from __future__ import annotations + +import os + +import uvicorn + +from webui.app import create_app + + +def main() -> None: + host = os.environ.get("WEBUI_HOST", "127.0.0.1") + port = int(os.environ.get("WEBUI_PORT", "8765")) + uvicorn.run(create_app(), host=host, port=port, log_level="info") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/webui/app.py b/webui/app.py new file mode 100644 index 0000000..aa0c10f --- /dev/null +++ b/webui/app.py @@ -0,0 +1,117 @@ +"""Starlette application for the internal read-only web UI (#426).""" + +from __future__ import annotations + +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.layout import render_page + +_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + + +def _stub_page(title: str, description: str) -> HTMLResponse: + body = ( + f"

{title}

" + f'

{description}

' + "

Implementation tracked in a child issue of #425.

" + ) + return HTMLResponse(render_page(title=title, body_html=body)) + + +async def home(_request: Request) -> HTMLResponse: + body = ( + "

Operator console

" + "

Local entry point for MCP Control Plane operational views.

" + "" + ) + return HTMLResponse(render_page(title="Home", body_html=body)) + + +async def health(_request: Request) -> JSONResponse: + return JSONResponse({ + "status": "ok", + "service": "mcp-control-plane-webui", + "mode": "read-only-mvp", + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + + +async def projects(_request: Request) -> HTMLResponse: + return _stub_page( + "Projects", + "Project registry and onboarding model will list configured repos and profiles.", + ) + + +async def prompts(_request: Request) -> HTMLResponse: + return _stub_page( + "Prompts", + "Prompt library will surface canonical workflows from skills/llm-project-workflow/.", + ) + + +async def runtime(_request: Request) -> HTMLResponse: + return _stub_page( + "Runtime", + "Runtime health will report MCP profile, preflight, and stale-server signals.", + ) + + +async def audit(_request: Request) -> HTMLResponse: + return _stub_page( + "Audit", + "Report audit will accept pasted final reports and run validator previews.", + ) + + +async def worktrees(_request: Request) -> HTMLResponse: + return _stub_page( + "Worktrees", + "Worktree hygiene will summarize branches/ session folders and cleanup risk.", + ) + + +async def leases(_request: Request) -> HTMLResponse: + return _stub_page( + "Leases", + "Lease visibility will show active issue and reviewer PR leases.", + ) + + +async def method_not_allowed(request: Request, _exc: Exception) -> Response: + 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() -> Starlette: + """Build the read-only MVP Starlette app.""" + return Starlette( + debug=False, + routes=[ + Route("/", home, methods=["GET"]), + Route("/health", health, methods=["GET"]), + Route("/projects", projects, methods=["GET"]), + Route("/prompts", prompts, methods=["GET"]), + Route("/runtime", runtime, methods=["GET"]), + Route("/audit", audit, methods=["GET"]), + Route("/worktrees", worktrees, methods=["GET"]), + Route("/leases", leases, methods=["GET"]), + ], + exception_handlers={405: method_not_allowed}, + ) \ No newline at end of file diff --git a/webui/layout.py b/webui/layout.py new file mode 100644 index 0000000..0aa2a87 --- /dev/null +++ b/webui/layout.py @@ -0,0 +1,102 @@ +"""Shared HTML layout for the internal web UI.""" + +from __future__ import annotations + +NAV_ITEMS = ( + ("/", "Home"), + ("/projects", "Projects"), + ("/prompts", "Prompts"), + ("/runtime", "Runtime"), + ("/audit", "Audit"), + ("/worktrees", "Worktrees"), + ("/leases", "Leases"), +) + +MVP_NOTICE = ( + "Read-only MVP — Gitea, MCP tools, and canonical workflows remain the " + "source of truth. No mutation endpoints." +) + + +def render_page(*, title: str, body_html: str) -> str: + nav_links = "".join( + f'{label}' for href, label in NAV_ITEMS + ) + return f""" + + + + + {title} · MCP Control Plane + + + +
+

MCP Control Plane

+ +
+
+

{MVP_NOTICE}

+ {body_html} +
+ +""" \ No newline at end of file