diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index f5799b2..71078f8 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -73,6 +73,11 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
| `/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
@@ -153,6 +158,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 cd8ab8b..8143790 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -12,6 +12,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 (
ProjectRegistry,
RegistryError,
@@ -59,24 +60,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.
"
- ""
- "Queue — live PR and issue dashboard (#429) "
- "Projects — registry and onboarding (#427) "
- "Prompts — canonical workflow prompt library (#428) "
- "Runtime — MCP health and stale-runtime detection (#430) "
- "Audit — final-report paste and validator preview (#431) "
- "Worktrees — branch hygiene dashboard (#432) "
- "Leases — collision and lease visibility (#433) "
- "Actions — gated write-action framework (#434) "
- " "
+ "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({
@@ -438,6 +477,10 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
api_console_security_model,
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(
+ ''
+ f'{group.label} '
+ f'{links} '
+ "
"
+ )
+ return "".join(groups_html)
+
+
+def _render_badges() -> str:
+ env = environment_label()
+ return (
+ '"
+ )
+
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
+
{nav_links}
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())