Merge pull request 'feat: add internal web UI server skeleton (Closes #426)' (#437) from feat/issue-426-internal-web-ui-skeleton into master
This commit was merged in pull request #437.
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
Executable
+6
@@ -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 "$@"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426)."""
|
||||||
|
|
||||||
|
from webui.app import create_app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
@@ -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()
|
||||||
+117
@@ -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"<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>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>"
|
||||||
|
"</ul>"
|
||||||
|
)
|
||||||
|
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},
|
||||||
|
)
|
||||||
+102
@@ -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'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
|
||||||
|
)
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{title} · MCP Control Plane</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #0f1419;
|
||||||
|
--surface: #1a2332;
|
||||||
|
--text: #e7ecf3;
|
||||||
|
--muted: #8b9cb3;
|
||||||
|
--accent: #5b9fd4;
|
||||||
|
--border: #2a3648;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}}
|
||||||
|
header h1 {{
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
nav {{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
}}
|
||||||
|
nav a {{
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}}
|
||||||
|
nav a:hover {{ text-decoration: underline; }}
|
||||||
|
main {{
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.25rem 2.5rem;
|
||||||
|
}}
|
||||||
|
.notice {{
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}}
|
||||||
|
h2 {{ margin-top: 0; font-size: 1.35rem; }}
|
||||||
|
p {{ color: var(--muted); }}
|
||||||
|
.stub {{
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>MCP Control Plane</h1>
|
||||||
|
<nav>{nav_links}</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p class="notice">{MVP_NOTICE}</p>
|
||||||
|
{body_html}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
Reference in New Issue
Block a user