Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a63476787 |
@@ -73,11 +73,6 @@ 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
|
||||
@@ -158,26 +153,6 @@ 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**
|
||||
|
||||
+51
-2
@@ -228,25 +228,74 @@ def find_active_reviewer_lease(
|
||||
return None
|
||||
|
||||
|
||||
def _conflict_fix_chain_key(lease: dict) -> tuple | None:
|
||||
"""Identity of the lease chain a conflict-fix marker belongs to (#842).
|
||||
|
||||
Keyed by PR number, profile, head_before, and branch. Returns None when any
|
||||
required component (pr_number, profile, head_before) is missing or malformed.
|
||||
"""
|
||||
raw = lease.get("raw_fields") or {}
|
||||
pr_number = lease.get("pr_number")
|
||||
profile = (lease.get("profile") or "").strip().lower()
|
||||
head_before = lease.get("head_before")
|
||||
branch = (lease.get("branch") or raw.get("branch") or "").strip()
|
||||
if not (pr_number and profile and head_before):
|
||||
return None
|
||||
return (pr_number, profile, head_before, branch)
|
||||
|
||||
|
||||
def _conflict_fix_chain_matches(key1: tuple, key2: tuple) -> bool:
|
||||
"""True when two conflict-fix chain keys refer to the same lease chain."""
|
||||
pr1, profile1, head1, branch1 = key1
|
||||
pr2, profile2, head2, branch2 = key2
|
||||
if pr1 != pr2 or profile1 != profile2 or head1 != head2:
|
||||
return False
|
||||
if branch1 and branch2 and branch1 != branch2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _conflict_fix_chain_terminated_after(entries: list[dict], index: int) -> bool:
|
||||
"""True when a later marker terminates the conflict-fix chain of ``entries[index]``.
|
||||
|
||||
Append-only newest-wins: a terminal marker (phase=released/blocked/done)
|
||||
ends only its matching claim chain (#842).
|
||||
"""
|
||||
key = _conflict_fix_chain_key(entries[index])
|
||||
if key is None:
|
||||
return False
|
||||
for later in entries[index + 1:]:
|
||||
phase = (later.get("phase") or "").strip().lower()
|
||||
if phase not in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
continue
|
||||
later_key = _conflict_fix_chain_key(later)
|
||||
if later_key and _conflict_fix_chain_matches(key, later_key):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_active_conflict_fix_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
|
||||
"""Return the newest unexpired, non-terminated conflict-fix lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "conflict_fix"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
for index in range(len(candidates) - 1, -1, -1):
|
||||
lease = candidates[index]
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
|
||||
if _conflict_fix_chain_terminated_after(candidates, index):
|
||||
continue
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from pr_work_lease import ( # noqa: E402
|
||||
assess_reviewer_mutation_blocked,
|
||||
assess_reviewer_stale_head_final_report,
|
||||
format_conflict_fix_lease_body,
|
||||
find_active_conflict_fix_lease,
|
||||
parse_conflict_fix_lease_comment,
|
||||
parse_reviewer_lease_comment,
|
||||
)
|
||||
@@ -203,5 +204,157 @@ class TestFormatLease(unittest.TestCase):
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
|
||||
|
||||
class TestConflictFixLeaseLifecycle(unittest.TestCase):
|
||||
def test_claim_followed_by_matching_release(self):
|
||||
claim_body = _conflict_fix_body(phase="claimed", worktree="branches/fix-376")
|
||||
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
|
||||
release_body = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"branch: feat/fix-376",
|
||||
"worktree: branches/fix-376",
|
||||
"profile: prgs-author",
|
||||
"phase: released",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"head_after: {HEAD_B}",
|
||||
f"expires_at: {expires}",
|
||||
])
|
||||
comments = [{"body": claim_body}, {"body": release_body}]
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW)
|
||||
self.assertIsNone(lease)
|
||||
|
||||
def test_expired_claim_without_release(self):
|
||||
past_expires = (NOW - timedelta(minutes=10)).isoformat().replace("+00:00", "Z")
|
||||
claim_body = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"phase: claimed",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"expires_at: {past_expires}",
|
||||
"profile: prgs-author",
|
||||
])
|
||||
comments = [{"body": claim_body}]
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW)
|
||||
self.assertIsNone(lease)
|
||||
|
||||
def test_mismatched_release_different_head(self):
|
||||
claim_body = _conflict_fix_body(phase="claimed", worktree="branches/fix-376")
|
||||
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
|
||||
release_body = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"profile: prgs-author",
|
||||
"phase: released",
|
||||
f"head_before: {HEAD_B}",
|
||||
f"expires_at: {expires}",
|
||||
])
|
||||
comments = [{"body": claim_body}, {"body": release_body}]
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW)
|
||||
self.assertIsNotNone(lease)
|
||||
self.assertEqual(lease["phase"], "claimed")
|
||||
|
||||
def test_mismatched_release_different_branch(self):
|
||||
claim_body = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"branch: feat/branch-A",
|
||||
"phase: claimed",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"expires_at: {(NOW + timedelta(minutes=60)).isoformat().replace('+00:00', 'Z')}",
|
||||
"profile: prgs-author",
|
||||
])
|
||||
release_body = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"branch: feat/branch-B",
|
||||
"phase: released",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"expires_at: {(NOW + timedelta(minutes=60)).isoformat().replace('+00:00', 'Z')}",
|
||||
"profile: prgs-author",
|
||||
])
|
||||
comments = [{"body": claim_body}, {"body": release_body}]
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW)
|
||||
self.assertIsNotNone(lease)
|
||||
self.assertEqual(lease["phase"], "claimed")
|
||||
|
||||
def test_release_followed_by_newer_claim(self):
|
||||
claim_1 = _conflict_fix_body(phase="claimed", worktree="branches/fix-376")
|
||||
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
|
||||
release_1 = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"profile: prgs-author",
|
||||
"phase: released",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"head_after: {HEAD_B}",
|
||||
f"expires_at: {expires}",
|
||||
])
|
||||
claim_2 = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"profile: prgs-author",
|
||||
"phase: claimed",
|
||||
f"head_before: {HEAD_B}",
|
||||
f"expires_at: {expires}",
|
||||
])
|
||||
comments = [{"body": claim_1}, {"body": release_1}, {"body": claim_2}]
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW)
|
||||
self.assertIsNotNone(lease)
|
||||
self.assertEqual(lease["head_before"], HEAD_B)
|
||||
|
||||
def test_malformed_or_ambiguous_markers(self):
|
||||
malformed_release = "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"phase: released",
|
||||
# missing head_before and profile
|
||||
])
|
||||
claim_body = _conflict_fix_body(phase="claimed")
|
||||
comments = [{"body": claim_body}, {"body": malformed_release}]
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=376, now=NOW)
|
||||
self.assertIsNotNone(lease)
|
||||
|
||||
def test_pr818_historical_sequence(self):
|
||||
comment_14696 = "\n".join([
|
||||
"<!-- mcp-conflict-fix-lease:v1 -->",
|
||||
"pr: #818",
|
||||
"branch: feat/issue-638-webui-app-shell-phase1",
|
||||
"worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/issue-638-webui-app-shell-phase1",
|
||||
"profile: prgs-author",
|
||||
"session_id: unknown",
|
||||
"phase: claimed",
|
||||
"head_before: 08061b7b8aebdd099a37d1abf5dafcf38e4fd3fb",
|
||||
"expires_at: 2026-07-23T07:12:13Z",
|
||||
"reviewer_active: no",
|
||||
])
|
||||
comment_14730 = "\n".join([
|
||||
"<!-- mcp-conflict-fix-lease:v1 -->",
|
||||
"pr: #818",
|
||||
"branch: feat/issue-638-webui-app-shell-phase1",
|
||||
"worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/issue-638-webui-app-shell-phase1",
|
||||
"profile: prgs-author",
|
||||
"session_id: prgs-author-61241-e5129c60",
|
||||
"phase: released",
|
||||
"head_before: 08061b7b8aebdd099a37d1abf5dafcf38e4fd3fb",
|
||||
"head_after: 64b6eb5d5402663098de5ded3b0617cc3b3df98f",
|
||||
"expires_at: 2026-07-23T06:05:00Z",
|
||||
"reviewer_active: no",
|
||||
])
|
||||
comments = [{"body": comment_14696}, {"body": comment_14730}]
|
||||
check_now = datetime(2026, 7, 23, 6, 30, tzinfo=timezone.utc)
|
||||
lease = find_active_conflict_fix_lease(comments, pr_number=818, now=check_now)
|
||||
self.assertIsNone(lease)
|
||||
|
||||
reviewer_gate = assess_reviewer_mutation_blocked(
|
||||
pr_number=818,
|
||||
comments=comments,
|
||||
reviewed_head_sha="64b6eb5d5402663098de5ded3b0617cc3b3df98f",
|
||||
live_head_sha="64b6eb5d5402663098de5ded3b0617cc3b3df98f",
|
||||
mutation="approve",
|
||||
now=check_now,
|
||||
)
|
||||
self.assertTrue(reviewer_gate["mutation_allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,135 +0,0 @@
|
||||
"""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()
|
||||
+11
-54
@@ -12,7 +12,6 @@ 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,
|
||||
@@ -60,62 +59,24 @@ 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'<li><a href="{item.href}">{item.label}</a>'
|
||||
+ ("" if item.status == "live" else " <span class=\"muted\">(stub)</span>")
|
||||
+ "</li>"
|
||||
for item in group.items
|
||||
)
|
||||
groups.append(f"<h3>{group.label}</h3><ul>{items}</ul>")
|
||||
return "".join(groups)
|
||||
|
||||
|
||||
async def home(_request: Request) -> HTMLResponse:
|
||||
legacy = "".join(
|
||||
f"<li><strong>{label}</strong> — {desc} "
|
||||
f'(<a href="{href}">{href}</a>)</li>'
|
||||
for href, label, desc in _LEGACY_PAGES
|
||||
)
|
||||
body = (
|
||||
"<h2>Operator console</h2>"
|
||||
"<p>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.</p>"
|
||||
"<h2>Phase 1 surfaces</h2>"
|
||||
+ _render_home_nav_groups()
|
||||
+ "<h2>MVP legacy pages</h2>"
|
||||
"<ul>" + legacy + "</ul>"
|
||||
"<p>Local entry point for MCP Control Plane operational views.</p>"
|
||||
"<ul>"
|
||||
"<li><strong>Queue</strong> — live PR and issue dashboard (#429)</li>"
|
||||
"<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>"
|
||||
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
|
||||
"</ul>"
|
||||
)
|
||||
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"<h2>{title}</h2>"
|
||||
f'<div class="stub"><p>{description}</p>'
|
||||
"<p>Phase 1 shell placeholder — no write actions. Tracked under "
|
||||
"epic #631.</p></div>"
|
||||
)
|
||||
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({
|
||||
@@ -477,10 +438,6 @@ 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},
|
||||
)
|
||||
|
||||
+16
-94
@@ -2,66 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from webui.nav import NAV_GROUPS
|
||||
NAV_ITEMS = (
|
||||
("/", "Home"),
|
||||
("/queue", "Queue"),
|
||||
("/projects", "Projects"),
|
||||
("/prompts", "Prompts"),
|
||||
("/runtime", "Runtime"),
|
||||
("/audit", "Audit"),
|
||||
("/worktrees", "Worktrees"),
|
||||
("/leases", "Leases"),
|
||||
("/actions", "Actions"),
|
||||
)
|
||||
|
||||
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'<a href="{item.href}"'
|
||||
+ (' class="nav-stub"' if item.status == "stub" else "")
|
||||
+ f'>{item.label}</a>'
|
||||
for item in group.items
|
||||
)
|
||||
groups_html.append(
|
||||
'<div class="nav-group">'
|
||||
f'<span class="nav-group-label">{group.label}</span>'
|
||||
f'<span class="nav-group-links">{links}</span>'
|
||||
"</div>"
|
||||
)
|
||||
return "".join(groups_html)
|
||||
|
||||
|
||||
def _render_badges() -> str:
|
||||
env = environment_label()
|
||||
return (
|
||||
'<div class="header-badges">'
|
||||
f'<span class="badge env-badge env-{env}">env: {env}</span>'
|
||||
'<span class="badge mode-badge">mode: read-only</span>'
|
||||
f'<a class="badge docs-link" href="{DOCS_URL}">Docs</a>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||
nav_links = _render_nav()
|
||||
header_badges = _render_badges()
|
||||
nav_links = "".join(
|
||||
f'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -91,58 +53,21 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||
padding: 0.75rem 1.25rem;
|
||||
}}
|
||||
header h1 {{
|
||||
margin: 0;
|
||||
margin: 0 0 0.5rem;
|
||||
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.5rem 1.25rem;
|
||||
gap: 0.75rem 1rem;
|
||||
}}
|
||||
.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;
|
||||
@@ -241,10 +166,7 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-top">
|
||||
<h1>MCP Control Plane</h1>
|
||||
{header_badges}
|
||||
</div>
|
||||
<nav>{nav_links}</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user