"""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, 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( "" f"{_escape(dto['repo_name'])}" f"{_escape(dto['gitea_owner'])}" f"{_escape(dto['remote_host'])}" f"{_escape(dto['default_branch'])}" f"{_escape(dto['status'])}" f"{_escape(_progress_label(dto['onboarding_summary']))}" f"{_escape(dto['profiles']['author'])}" "" ) table = ( "" "" "" "" "" f"{''.join(rows)}
RepositoryOwnerRemoteBranchStatusOnboardingAuthor profile
" ) body = ( "

Projects

" "

Configured repositories managed by the MCP Control Plane.

" f"

Registry: {_escape(str(registry.source_path))} " f"(schema version {registry.schema_version}, " f"API {_escape(registry.api_version)})

" f"{table}" "

JSON API " "(unversioned alias)

" ) return render_page(title="Projects", body_html=body) def render_project_detail(project: ProjectRecord) -> str: dto = project_to_dict(project) profile_rows = "".join( f"{_escape(role)}{_escape(name)}" for role, name in dto["profiles"].items() ) workflow_rows = "".join( f"{_escape(key)}{_escape(path)}" for key, path in dto["workflow_paths"].items() ) schema_rows = "".join( f"{_escape(key)}{_escape(path)}" for key, path in dto["schema_paths"].items() ) checklist_items = [] 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( f"
  • " f"{index}. {_escape(step['title'])}" f" {_escape(state_label)}" f" ({_escape(requirement)})" f"

    {_escape(step['description'])}

    " "
  • " ) checklist_html = ( "
      " + "".join(checklist_items) + "
    " if checklist_items else "

    No onboarding steps defined.

    " ) summary = dto["onboarding_summary"] summary_html = ( "

    Onboarding: " f"{_escape(_progress_label(summary))}; required outstanding " f"{summary['required_outstanding']}.

    " ) health = dto["last_seen_health"] health_html = ( "

    No health probe recorded (Phase 1 is read-only).

    " if health is None else ( "" f"" f"" f"" "
    Status{_escape(health['status'])}
    Checked at{_escape(str(health['checked_at'] or 'unknown'))}
    Detail{_escape(str(health['detail'] or ''))}
    " ) ) remote_name = dto["remote_name"] or "unset" body = ( f"

    {_escape(dto['repo_name'])}

    " "

    ← All projects

    " "

    Identity

    " "" f"" f"" f"" f"" f"" f"" f"" f"" "
    Registry id{_escape(dto['id'])}
    Status{_escape(dto['status'])}
    Gitea owner{_escape(dto['gitea_owner'])}
    Repository{_escape(dto['repo_full_name'])}
    Remote name{_escape(remote_name)}
    Remote host{_escape(dto['remote_host'])}
    Default branch{_escape(dto['default_branch'])}
    Local checkout{_escape(dto['local_checkout_path'])}
    " "

    Profiles

    " f"{profile_rows}
    " "

    Workflow paths

    " f"{workflow_rows}
    " "

    Schema paths

    " f"{schema_rows}
    " "

    Last seen health

    " f"{health_html}" "

    Onboarding checklist

    " "

    Read-only — complete these steps outside the UI.

    " f"{summary_html}" f"{checklist_html}" f"

    JSON detail

    " ) 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 = ( "

    Project registry unavailable

    " "

    The registry failed validation, so the console refuses to render a " "partial inventory.

    " "" f"" f"" f"" f"" "
    Detail{_escape(error.message)}
    Field{_escape(field)}
    Source{_escape(source)}
    Remediation{_escape(error.remediation)}
    " ) return render_page(title="Project registry unavailable", body_html=body)