{MVP_NOTICE}
+ {body_html} +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"
{description}
' + "Implementation tracked in a child issue of #425.
Local entry point for MCP Control Plane operational views.
" + "{MVP_NOTICE}
+ {body_html} +