From 08061b7b8aebdd099a37d1abf5dafcf38e4fd3fb Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Wed, 22 Jul 2026 17:56:28 -0500 Subject: [PATCH] feat(webui): evolve application shell for Phase 1 console IA (Closes #638) Introduce a single nav-config module (webui/nav.py) driving grouped navigation across the epic #631 Phase 1 information architecture: Health, Traffic, Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and Insights (placeholder). The shell header now carries a read-only environment badge (local/remote from WEBUI_HOST), a mode: read-only badge, and a Docs link. Home summarizes the console purpose, lists the Phase 1 surfaces by group, and links the MVP legacy pages. Not-yet-implemented surfaces (/sessions, /inventory, /timeline, /policy, /insights) resolve to graceful read-only stub pages rather than 404s; their backing views land in later child issues of #631 (inventory surfaces backed by #636). Mutating methods on stub routes still fail closed with read-only-mvp. No privileged action controls are added. Adds tests/test_webui_shell.py covering nav groups, badge presence, environment classification, docs link, stub routes (200 + read-only), and home content. Updates docs/webui-local-dev.md with the new routes and a Phase 1 shell section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 25 +++++++ tests/test_webui_shell.py | 135 ++++++++++++++++++++++++++++++++++++++ webui/app.py | 65 ++++++++++++++---- webui/layout.py | 112 ++++++++++++++++++++++++++----- webui/nav.py | 111 +++++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 tests/test_webui_shell.py create mode 100644 webui/nav.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index a92e509..4e81498 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -67,6 +67,11 @@ that govern when a write path may open (#632, epic #631). | `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) | | `/leases` | Lease and collision visibility (#433) | | `/api/leases` | JSON lease/collision export | +| `/sessions` | Phase 1 shell stub — session inventory (backed by #636) | +| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) | +| `/timeline` | Phase 1 shell stub — workflow event timeline | +| `/policy` | Phase 1 shell stub — capability/role policy placeholder | +| `/insights` | Phase 1 shell stub — operational insights placeholder | Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with `read-only-mvp`, except `/audit` and `/api/audit` which accept POST for @@ -147,6 +152,26 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the checkout is behind merged safety-gate changes. Restart guidance links to #420; no tokens or MCP restart actions are exposed. +## Application shell — Phase 1 (#638) + +The console shell (`webui/layout.py`) renders a grouped navigation driven by a +single nav-config module, `webui/nav.py`. Nav groups follow the epic #631 +Phase 1 information architecture: **Health, Traffic, Runtime/Sessions, +Projects, Inventory, Timeline, Policy** (placeholder), and **Insights** +(placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one +place so the layout and the route table cannot drift. + +The header carries two read-only status badges — an **environment** badge +(`local` for loopback binds, `remote` otherwise, derived from `WEBUI_HOST`) and +a **mode: read-only** badge — plus a **Docs** link to this document. No +privileged action controls are present in the Phase 1 shell. + +Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`, +`/policy`, `/insights`) resolve to graceful read-only stub pages instead of +404s; their backing views land in later child issues of #631 (the inventory +surfaces are backed by #636). Mutating methods on stub routes still fail closed +with `read-only-mvp`. + ## Deployment boundary (#435) MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused** diff --git a/tests/test_webui_shell.py b/tests/test_webui_shell.py new file mode 100644 index 0000000..e3c117f --- /dev/null +++ b/tests/test_webui_shell.py @@ -0,0 +1,135 @@ +"""Tests for the Phase 1 operator console application shell (#638).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.routing import Route +from starlette.testclient import TestClient + +from webui import layout +from webui.app import create_app +from webui.nav import NAV_GROUPS, STUB_PAGES, nav_hrefs + + +class TestShellNav(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_nav_group_labels_present(self): + text = self.client.get("/").text + for group in NAV_GROUPS: + with self.subTest(group=group.label): + self.assertIn(f">{group.label}<", text) + + def test_phase1_group_labels_cover_expected_ia(self): + labels = {group.label for group in NAV_GROUPS} + for expected in ( + "Health", + "Traffic", + "Runtime/Sessions", + "Projects", + "Inventory", + "Timeline", + "Policy", + "Insights", + ): + with self.subTest(label=expected): + self.assertIn(expected, labels) + + def test_every_nav_href_resolves_to_a_get_route(self): + app = create_app() + get_paths = { + route.path + for route in app.routes + if isinstance(route, Route) and "GET" in route.methods + } + for href in nav_hrefs(): + with self.subTest(href=href): + self.assertIn(href, get_paths, f"nav href {href} has no GET route") + + def test_legacy_hrefs_still_navigable(self): + text = self.client.get("/").text + for href in ("/queue", "/projects", "/prompts", "/runtime", + "/audit", "/worktrees", "/leases", "/actions"): + with self.subTest(href=href): + self.assertIn(f'href="{href}"', text) + + +class TestShellBadges(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_mode_badge_present(self): + self.assertIn("mode: read-only", self.client.get("/").text) + + def test_environment_badge_present(self): + self.assertIn("env:", self.client.get("/").text) + + def test_default_environment_is_local(self): + self.assertEqual(layout.environment_label(), "local") + + def test_remote_bind_reports_remote_environment(self): + import os + + prior = os.environ.get("WEBUI_HOST") + os.environ["WEBUI_HOST"] = "10.0.0.5" + try: + self.assertEqual(layout.environment_label(), "remote") + finally: + if prior is None: + os.environ.pop("WEBUI_HOST", None) + else: + os.environ["WEBUI_HOST"] = prior + + def test_docs_link_present(self): + text = self.client.get("/").text + self.assertIn(layout.DOCS_URL, text) + self.assertIn(">Docs<", text) + + +class TestShellStubs(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_stub_routes_render_200(self): + for path, (title, _desc) in STUB_PAGES.items(): + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200, path) + self.assertIn(title, response.text) + self.assertIn("placeholder", response.text) + + def test_stub_routes_are_read_only(self): + for path in STUB_PAGES: + with self.subTest(path=path): + response = self.client.post(path) + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json()["error"], "read-only-mvp") + + def test_stub_pages_carry_nav_and_badges(self): + response = self.client.get("/inventory") + self.assertIn("mode: read-only", response.text) + self.assertIn('href="/queue"', response.text) + + +class TestShellHome(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_home_summarizes_console(self): + text = self.client.get("/").text + self.assertIn("Operator console", text) + self.assertIn("Phase 1", text) + + def test_home_links_legacy_pages(self): + text = self.client.get("/").text + self.assertIn("MVP legacy pages", text) + for href in ("/queue", "/audit", "/leases"): + with self.subTest(href=href): + self.assertIn(f'href="{href}"', text) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/app.py b/webui/app.py index d0f832b..eba1a8a 100644 --- a/webui/app.py +++ b/webui/app.py @@ -11,6 +11,7 @@ from starlette.routing import Route from webui.deployment_boundary import deployment_snapshot from webui.layout import render_page +from webui.nav import NAV_GROUPS, STUB_PAGES from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list from webui.prompt_library import find_prompt, library_to_dict @@ -43,24 +44,62 @@ def _stub_page(title: str, description: str) -> HTMLResponse: return HTMLResponse(render_page(title=title, body_html=body)) +_LEGACY_PAGES = ( + ("/queue", "Queue", "live PR and issue dashboard (#429)"), + ("/projects", "Projects", "registry and onboarding (#427)"), + ("/prompts", "Prompts", "canonical workflow prompt library (#428)"), + ("/runtime", "Runtime", "MCP health and stale-runtime detection (#430)"), + ("/audit", "Audit", "final-report paste and validator preview (#431)"), + ("/worktrees", "Worktrees", "branch hygiene dashboard (#432)"), + ("/leases", "Leases", "collision and lease visibility (#433)"), + ("/actions", "Actions", "gated write-action framework (#434)"), +) + + +def _render_home_nav_groups() -> str: + groups = [] + for group in NAV_GROUPS: + items = "".join( + f'
  • {item.label}' + + ("" if item.status == "live" else " (stub)") + + "
  • " + for item in group.items + ) + groups.append(f"

    {group.label}

    ") + return "".join(groups) + + async def home(_request: Request) -> HTMLResponse: + legacy = "".join( + f"
  • {label} — {desc} " + f'({href})
  • ' + for href, label, desc in _LEGACY_PAGES + ) body = ( "

    Operator console

    " - "

    Local entry point for MCP Control Plane operational views.

    " - "" + "

    Read-only home for the MCP Control Plane Phase 1 operator console. " + "Gitea, MCP capability gates, and canonical workflows remain the source " + "of truth; this console never mutates them.

    " + "

    Phase 1 surfaces

    " + + _render_home_nav_groups() + + "

    MVP legacy pages

    " + "" ) return HTMLResponse(render_page(title="Home", body_html=body)) +async def phase_stub(request: Request) -> HTMLResponse: + """Graceful read-only placeholder for a not-yet-implemented Phase 1 surface.""" + title, description = STUB_PAGES[request.url.path] + body = ( + f"

    {title}

    " + f'

    {description}

    ' + "

    Phase 1 shell placeholder — no write actions. Tracked under " + "epic #631.

    " + ) + return HTMLResponse(render_page(title=title, body_html=body)) + + async def health(_request: Request) -> JSONResponse: bind_host = _request.app.state.webui_bind_host return JSONResponse({ @@ -291,6 +330,10 @@ def create_app(*, bind_host: str | None = None) -> Starlette: methods=["POST"], ), Route("/api/leases", api_leases, methods=["GET"]), + *[ + Route(path, phase_stub, methods=["GET"]) + for path in STUB_PAGES + ], ], exception_handlers={405: method_not_allowed}, ) diff --git a/webui/layout.py b/webui/layout.py index 47bedc1..4d62eca 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -2,28 +2,66 @@ from __future__ import annotations -NAV_ITEMS = ( - ("/", "Home"), - ("/queue", "Queue"), - ("/projects", "Projects"), - ("/prompts", "Prompts"), - ("/runtime", "Runtime"), - ("/audit", "Audit"), - ("/worktrees", "Worktrees"), - ("/leases", "Leases"), - ("/actions", "Actions"), -) +import os + +from webui.nav import NAV_GROUPS MVP_NOTICE = ( "Read-only MVP — Gitea, MCP tools, and canonical workflows remain the " "source of truth. No mutation endpoints." ) +# Canonical docs entry point surfaced from the shell header (#638). +DOCS_URL = ( + "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/src/branch/" + "master/docs/webui-local-dev.md" +) + +_LOCAL_HOSTS = frozenset({"", "127.0.0.1", "localhost", "::1"}) + + +def environment_label() -> str: + """Classify the serving environment as ``local`` or ``remote`` (#638). + + Derived from the same ``WEBUI_HOST`` default the app binds to; loopback + hosts are ``local``, anything else is ``remote``. Read-only signal only. + """ + host = (os.environ.get("WEBUI_HOST", "127.0.0.1") or "").strip().lower() + return "local" if host in _LOCAL_HOSTS else "remote" + + +def _render_nav() -> str: + groups_html = [] + for group in NAV_GROUPS: + links = "".join( + f'{item.label}' + for item in group.items + ) + groups_html.append( + '" + ) + return "".join(groups_html) + + +def _render_badges() -> str: + env = environment_label() + return ( + '
    ' + f'env: {env}' + 'mode: read-only' + f'Docs' + "
    " + ) + def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: - nav_links = "".join( - f'{label}' for href, label in NAV_ITEMS - ) + nav_links = _render_nav() + header_badges = _render_badges() return f""" @@ -53,21 +91,58 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: padding: 0.75rem 1.25rem; }} header h1 {{ - margin: 0 0 0.5rem; + margin: 0; font-size: 1.1rem; font-weight: 600; }} + .header-top {{ + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.6rem; + }} + .header-badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.4rem; }} + .env-badge.env-local {{ color: #8fd19e; border-color: #3d6b4a; }} + .env-badge.env-remote {{ color: #e0c27a; border-color: #6b5730; }} + .mode-badge {{ color: #9ec8f0; border-color: #3d5f7a; }} + a.docs-link {{ + color: var(--accent); + border-color: var(--accent); + text-decoration: none; + text-transform: none; + }} + a.docs-link:hover {{ filter: brightness(1.12); }} nav {{ display: flex; flex-wrap: wrap; - gap: 0.75rem 1rem; + gap: 0.5rem 1.25rem; }} + .nav-group {{ + display: flex; + flex-direction: column; + gap: 0.15rem; + }} + .nav-group-label {{ + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + }} + .nav-group-links {{ display: inline-flex; flex-wrap: wrap; gap: 0.6rem; }} nav a {{ color: var(--accent); text-decoration: none; font-size: 0.9rem; }} nav a:hover {{ text-decoration: underline; }} + nav a.nav-stub {{ color: var(--muted); }} + nav a.nav-stub::after {{ + content: " ·stub"; + font-size: 0.7rem; + color: var(--muted); + }} main {{ max-width: 52rem; margin: 0 auto; @@ -166,7 +241,10 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
    -

    MCP Control Plane

    +
    +

    MCP Control Plane

    + {header_badges} +
    diff --git a/webui/nav.py b/webui/nav.py new file mode 100644 index 0000000..edb128c --- /dev/null +++ b/webui/nav.py @@ -0,0 +1,111 @@ +"""Navigation IA for the Phase 1 operator console shell (#638). + +Single source of truth for the console navigation so ``webui/layout.py`` and +the ``webui/app.py`` route table stay aligned with epic #631. Read-only: every +destination is a GET view or a Phase 1 placeholder. No mutation links. + +Nav groups follow the #631 Phase 1 information architecture: Health, Traffic, +Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and +Insights (placeholder). Later-phase surfaces are declared as ``stub`` items and +backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder +instead of a 404. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class NavItem: + """A single navigation destination. + + ``status`` is ``"live"`` for implemented views and ``"stub"`` for Phase 1 + placeholders whose backing view lands in a later child issue. + """ + + href: str + label: str + status: str = "live" + + +@dataclass(frozen=True) +class NavGroup: + label: str + items: tuple[NavItem, ...] + + +NAV_GROUPS: tuple[NavGroup, ...] = ( + NavGroup("Health", ( + NavItem("/health", "Liveness"), + )), + NavGroup("Traffic", ( + NavItem("/queue", "Queue"), + NavItem("/leases", "Leases"), + NavItem("/actions", "Actions"), + )), + NavGroup("Runtime/Sessions", ( + NavItem("/runtime", "Runtime health"), + NavItem("/sessions", "Sessions", "stub"), + )), + NavGroup("Projects", ( + NavItem("/projects", "Projects"), + )), + NavGroup("Inventory", ( + NavItem("/inventory", "Inventory", "stub"), + NavItem("/worktrees", "Worktrees"), + )), + NavGroup("Timeline", ( + NavItem("/timeline", "Timeline", "stub"), + )), + NavGroup("Policy", ( + NavItem("/policy", "Policy", "stub"), + NavItem("/prompts", "Prompts"), + )), + NavGroup("Insights", ( + NavItem("/insights", "Insights", "stub"), + NavItem("/audit", "Audit"), + )), +) + + +# Phase 1 placeholder destinations whose backing views land in later child +# issues of epic #631. Each maps a path to (title, description). Routes are +# registered so nav links resolve to a graceful, read-only stub page. +STUB_PAGES: dict[str, tuple[str, str]] = { + "/sessions": ( + "Sessions", + "Active session, capability, and role inventory. Backed by the unified " + "inventory API (#636) once it lands.", + ), + "/inventory": ( + "Inventory", + "Unified sessions, leases, locks, namespaces, and worktree inventory. " + "Backed by the Phase 1 inventory API (#636).", + ), + "/timeline": ( + "Timeline", + "Workflow event timeline across issues and PRs. A later Phase 1 surface.", + ), + "/policy": ( + "Policy", + "Capability and role policy surface. Placeholder until a later phase.", + ), + "/insights": ( + "Insights", + "Aggregate operational insights and trends. Placeholder until a later " + "phase.", + ), +} + + +def iter_nav_items(): + """Yield every ``NavItem`` across all groups in declared order.""" + for group in NAV_GROUPS: + for item in group.items: + yield item + + +def nav_hrefs() -> tuple[str, ...]: + """Return every navigation href in declared order.""" + return tuple(item.href for item in iter_nav_items()) -- 2.43.7