feat(webui): versioned project registry API (Closes #635)
Evolve the MVP project registry (#427) into a versioned, fail-closed project registry API for the console (Phase 1, read-only). - Add schema version 2 with project `status`, per-step onboarding `state`/`required`, optional redacted `last_seen_health`, and `remote_name`. Version 1 files stay loadable and are normalized with explicit defaults. - Serve `/api/v1/projects` and `/api/v1/projects/{project_id}` with API provenance (`api_version`, `schema_version`, `source`). `/api/projects` is retained as an unversioned Phase 1 alias. - Replace bare `ValueError` with `RegistryError`, carrying an operator `remediation` and `field_path`; invalid registries fail closed as a 500 JSON payload or a dedicated HTML error page instead of a traceback. - Reject credential-shaped keys before any DTO is built, reusing `registry_safety.is_forbidden_key` as the single source of truth shared with the worker registry (#798). - Render HTML views from `project_to_dict`, so the console and the JSON API cannot disagree about status or onboarding progress. - Document the contract in docs/webui-project-registry-api.md. Tests: registry load/validate (valid, missing project, schema validation, credential rejection) and API route coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+107
-25
@@ -1,34 +1,65 @@
|
||||
"""HTML views for project registry pages (#427)."""
|
||||
"""HTML views for project registry pages (#427, evolved for #635).
|
||||
|
||||
Every view renders from :func:`webui.project_registry.project_to_dict`, the
|
||||
same DTO the ``/api/v1/projects`` JSON responses use, so the HTML console and
|
||||
the API can never disagree about status or onboarding progress.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from webui.layout import render_page
|
||||
from webui.project_registry import ProjectRecord, ProjectRegistry
|
||||
from webui.project_registry import (
|
||||
ProjectRecord,
|
||||
ProjectRegistry,
|
||||
RegistryError,
|
||||
project_to_dict,
|
||||
)
|
||||
|
||||
_STATE_LABELS = {
|
||||
"complete": "Complete",
|
||||
"pending": "Pending",
|
||||
"blocked": "Blocked",
|
||||
"not_applicable": "Not applicable",
|
||||
}
|
||||
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
return html.escape(text, quote=True)
|
||||
|
||||
|
||||
def _progress_label(summary: dict[str, Any]) -> str:
|
||||
total = summary["total"]
|
||||
if not total:
|
||||
return "no steps"
|
||||
label = f"{summary['complete']}/{total} complete"
|
||||
if summary["blocked"]:
|
||||
label += f", {summary['blocked']} blocked"
|
||||
return label
|
||||
|
||||
|
||||
def render_projects_list(registry: ProjectRegistry) -> str:
|
||||
rows = []
|
||||
for project in registry.projects:
|
||||
dto = project_to_dict(project)
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><a href=\"/projects/{_escape(project.id)}\">{_escape(project.repo_name)}</a></td>"
|
||||
f"<td>{_escape(project.gitea_owner)}</td>"
|
||||
f"<td>{_escape(project.remote_host)}</td>"
|
||||
f"<td>{_escape(project.default_branch)}</td>"
|
||||
f"<td><code>{_escape(project.profiles['author'])}</code></td>"
|
||||
f"<td><a href=\"/projects/{_escape(dto['id'])}\">{_escape(dto['repo_name'])}</a></td>"
|
||||
f"<td>{_escape(dto['gitea_owner'])}</td>"
|
||||
f"<td>{_escape(dto['remote_host'])}</td>"
|
||||
f"<td>{_escape(dto['default_branch'])}</td>"
|
||||
f"<td><code>{_escape(dto['status'])}</code></td>"
|
||||
f"<td>{_escape(_progress_label(dto['onboarding_summary']))}</td>"
|
||||
f"<td><code>{_escape(dto['profiles']['author'])}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
table = (
|
||||
"<table class=\"registry\">"
|
||||
"<thead><tr>"
|
||||
"<th>Repository</th><th>Owner</th><th>Remote</th>"
|
||||
"<th>Branch</th><th>Author profile</th>"
|
||||
"<th>Branch</th><th>Status</th><th>Onboarding</th><th>Author profile</th>"
|
||||
"</tr></thead>"
|
||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
@@ -36,32 +67,39 @@ def render_projects_list(registry: ProjectRegistry) -> str:
|
||||
"<h2>Projects</h2>"
|
||||
"<p>Configured repositories managed by the MCP Control Plane.</p>"
|
||||
f"<p class=\"meta\">Registry: <code>{_escape(str(registry.source_path))}</code> "
|
||||
f"(version {registry.version})</p>"
|
||||
f"(schema version {registry.schema_version}, "
|
||||
f"API {_escape(registry.api_version)})</p>"
|
||||
f"{table}"
|
||||
"<p><a href=\"/api/projects\">JSON API</a></p>"
|
||||
"<p><a href=\"/api/v1/projects\">JSON API</a> "
|
||||
"(<a href=\"/api/projects\">unversioned alias</a>)</p>"
|
||||
)
|
||||
return render_page(title="Projects", body_html=body)
|
||||
|
||||
|
||||
def render_project_detail(project: ProjectRecord) -> str:
|
||||
dto = project_to_dict(project)
|
||||
profile_rows = "".join(
|
||||
f"<tr><th>{_escape(role)}</th><td><code>{_escape(name)}</code></td></tr>"
|
||||
for role, name in project.profiles.items()
|
||||
for role, name in dto["profiles"].items()
|
||||
)
|
||||
workflow_rows = "".join(
|
||||
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
||||
for key, path in project.workflow_paths.items()
|
||||
for key, path in dto["workflow_paths"].items()
|
||||
)
|
||||
schema_rows = "".join(
|
||||
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
||||
for key, path in project.schema_paths.items()
|
||||
for key, path in dto["schema_paths"].items()
|
||||
)
|
||||
checklist_items = []
|
||||
for index, step in enumerate(project.onboarding_checklist, start=1):
|
||||
for index, step in enumerate(dto["onboarding_checklist"], start=1):
|
||||
state_label = _STATE_LABELS.get(step["state"], step["state"])
|
||||
requirement = "required" if step["required"] else "optional"
|
||||
checklist_items.append(
|
||||
"<li>"
|
||||
f"<strong>{index}. {_escape(step.title)}</strong>"
|
||||
f"<p>{_escape(step.description)}</p>"
|
||||
f"<li class=\"step-{_escape(step['state'])}\">"
|
||||
f"<strong>{index}. {_escape(step['title'])}</strong>"
|
||||
f" <span class=\"badge\">{_escape(state_label)}</span>"
|
||||
f" <span class=\"meta\">({_escape(requirement)})</span>"
|
||||
f"<p>{_escape(step['description'])}</p>"
|
||||
"</li>"
|
||||
)
|
||||
checklist_html = (
|
||||
@@ -69,16 +107,38 @@ def render_project_detail(project: ProjectRecord) -> str:
|
||||
if checklist_items
|
||||
else "<p>No onboarding steps defined.</p>"
|
||||
)
|
||||
summary = dto["onboarding_summary"]
|
||||
summary_html = (
|
||||
"<p class=\"meta\">Onboarding: "
|
||||
f"{_escape(_progress_label(summary))}; required outstanding "
|
||||
f"{summary['required_outstanding']}.</p>"
|
||||
)
|
||||
health = dto["last_seen_health"]
|
||||
health_html = (
|
||||
"<p class=\"meta\">No health probe recorded (Phase 1 is read-only).</p>"
|
||||
if health is None
|
||||
else (
|
||||
"<table class=\"detail\">"
|
||||
f"<tr><th>Status</th><td><code>{_escape(health['status'])}</code></td></tr>"
|
||||
f"<tr><th>Checked at</th><td>{_escape(str(health['checked_at'] or 'unknown'))}</td></tr>"
|
||||
f"<tr><th>Detail</th><td>{_escape(str(health['detail'] or ''))}</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
)
|
||||
remote_name = dto["remote_name"] or "unset"
|
||||
body = (
|
||||
f"<h2>{_escape(project.repo_name)}</h2>"
|
||||
f"<h2>{_escape(dto['repo_name'])}</h2>"
|
||||
"<p><a href=\"/projects\">← All projects</a></p>"
|
||||
"<h3>Identity</h3>"
|
||||
"<table class=\"detail\">"
|
||||
f"<tr><th>Registry id</th><td><code>{_escape(project.id)}</code></td></tr>"
|
||||
f"<tr><th>Gitea owner</th><td>{_escape(project.gitea_owner)}</td></tr>"
|
||||
f"<tr><th>Remote host</th><td>{_escape(project.remote_host)}</td></tr>"
|
||||
f"<tr><th>Default branch</th><td><code>{_escape(project.default_branch)}</code></td></tr>"
|
||||
f"<tr><th>Local checkout</th><td><code>{_escape(project.local_checkout_path)}</code></td></tr>"
|
||||
f"<tr><th>Registry id</th><td><code>{_escape(dto['id'])}</code></td></tr>"
|
||||
f"<tr><th>Status</th><td><code>{_escape(dto['status'])}</code></td></tr>"
|
||||
f"<tr><th>Gitea owner</th><td>{_escape(dto['gitea_owner'])}</td></tr>"
|
||||
f"<tr><th>Repository</th><td><code>{_escape(dto['repo_full_name'])}</code></td></tr>"
|
||||
f"<tr><th>Remote name</th><td><code>{_escape(remote_name)}</code></td></tr>"
|
||||
f"<tr><th>Remote host</th><td>{_escape(dto['remote_host'])}</td></tr>"
|
||||
f"<tr><th>Default branch</th><td><code>{_escape(dto['default_branch'])}</code></td></tr>"
|
||||
f"<tr><th>Local checkout</th><td><code>{_escape(dto['local_checkout_path'])}</code></td></tr>"
|
||||
"</table>"
|
||||
"<h3>Profiles</h3>"
|
||||
f"<table class=\"detail\">{profile_rows}</table>"
|
||||
@@ -86,8 +146,30 @@ def render_project_detail(project: ProjectRecord) -> str:
|
||||
f"<table class=\"detail\">{workflow_rows}</table>"
|
||||
"<h3>Schema paths</h3>"
|
||||
f"<table class=\"detail\">{schema_rows}</table>"
|
||||
"<h3>Last seen health</h3>"
|
||||
f"{health_html}"
|
||||
"<h3>Onboarding checklist</h3>"
|
||||
"<p class=\"meta\">Read-only MVP — complete these steps outside the UI.</p>"
|
||||
"<p class=\"meta\">Read-only — complete these steps outside the UI.</p>"
|
||||
f"{summary_html}"
|
||||
f"{checklist_html}"
|
||||
f"<p><a href=\"/api/v1/projects/{_escape(dto['id'])}\">JSON detail</a></p>"
|
||||
)
|
||||
return render_page(title=project.repo_name, body_html=body)
|
||||
return render_page(title=dto["repo_name"], body_html=body)
|
||||
|
||||
|
||||
def render_registry_error(error: RegistryError) -> str:
|
||||
"""Render a fail-closed page for an invalid registry."""
|
||||
source = str(error.source_path) if error.source_path else "unknown"
|
||||
field = error.field_path or "n/a"
|
||||
body = (
|
||||
"<h2>Project registry unavailable</h2>"
|
||||
"<p>The registry failed validation, so the console refuses to render a "
|
||||
"partial inventory.</p>"
|
||||
"<table class=\"detail\">"
|
||||
f"<tr><th>Detail</th><td>{_escape(error.message)}</td></tr>"
|
||||
f"<tr><th>Field</th><td><code>{_escape(field)}</code></td></tr>"
|
||||
f"<tr><th>Source</th><td><code>{_escape(source)}</code></td></tr>"
|
||||
f"<tr><th>Remediation</th><td>{_escape(error.remediation)}</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
return render_page(title="Project registry unavailable", body_html=body)
|
||||
|
||||
Reference in New Issue
Block a user