Added Discovery UI

This commit is contained in:
2026-04-26 16:13:53 +02:00
parent 924efe67dc
commit e1139a89f1
3 changed files with 475 additions and 4 deletions

View File

@@ -3,8 +3,8 @@ from __future__ import annotations
from dataclasses import asdict
from html import escape
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Depends, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from repo_registry.core.service import RegistryService
from repo_registry.storage.sqlite import NotFoundError
@@ -127,6 +127,7 @@ def page(title: str, body: str) -> HTMLResponse:
<a href="/ui"><strong>Repository Ability Registry</strong></a>
<nav class="actions">
<a href="/ui/search">Search</a>
<a href="/ui/discovery">Discovery</a>
<a href="/docs">API Docs</a>
</nav>
</header>
@@ -163,7 +164,10 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes
</form>
</section>
<section class="panel">
<h2>Registry</h2>
<div class="actions">
<h2 style="margin-right:auto">Registry</h2>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<table>
<thead><tr><th>Name</th><th>Status</th><th>Branch</th><th>Source</th></tr></thead>
<tbody>{rows or '<tr><td colspan="4" class="muted">No repositories yet.</td></tr>'}</tbody>
@@ -174,6 +178,110 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes
return page("Repositories", body)
@router.get("/ui/discovery")
def discovery_page(service: RegistryService = Depends(get_service)) -> HTMLResponse:
repositories = service.list_repositories()
return page(
"Discovery",
f"""
<div class="actions">
<h1 style="margin-right:auto">Discovery</h1>
<a class="button secondary" href="/ui">Repositories</a>
</div>
<div class="grid">
<section class="panel">
<h2>Compare Repositories</h2>
<form class="stack" method="get" action="/ui/discovery/compare">
{render_repository_checkbox_list(service, repositories)}
<button type="submit">Compare</button>
</form>
</section>
<section class="panel">
<h2>Capability Gap Report</h2>
<form class="stack" method="post" action="/ui/discovery/gaps">
<label>Desired ability <input name="desired_ability" required></label>
<label>Desired capabilities <textarea name="desired_capabilities" rows="7" placeholder="One capability per line or comma-separated" required></textarea></label>
{render_repository_checkbox_list(service, repositories)}
<button type="submit">Run Gap Report</button>
</form>
</section>
</div>
""",
)
@router.get("/ui/discovery/compare")
def discovery_compare_page(
repository_ids: list[int] = Query(default=[]),
service: RegistryService = Depends(get_service),
) -> HTMLResponse:
if len(repository_ids) < 2:
return page(
"Repository Comparison",
"""
<div class="actions">
<h1 style="margin-right:auto">Repository Comparison</h1>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<section class="panel">
<p class="muted">Select at least two repositories with approved profiles.</p>
</section>
""",
)
try:
comparison = service.compare_repositories(repository_ids)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
body = f"""
<div class="actions">
<h1 style="margin-right:auto">Repository Comparison</h1>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<section class="panel">
<h2>Compared Repositories</h2>
{render_compared_repositories(comparison["repositories"])}
</section>
<section class="panel" style="margin-top:18px">
<h2>Shared Abilities</h2>
{render_compared_abilities(comparison["abilities"])}
</section>
<section class="panel" style="margin-top:18px">
<h2>Unique Capabilities</h2>
{render_unique_capabilities(comparison["unique_capabilities"])}
</section>
"""
return page("Repository Comparison", body)
@router.post("/ui/discovery/gaps")
def discovery_gap_report_page(
desired_ability: str = Form(...),
desired_capabilities: str = Form(...),
repository_ids: list[int] = Form(default=[]),
service: RegistryService = Depends(get_service),
) -> HTMLResponse:
capabilities = split_capability_lines(desired_capabilities)
try:
report = service.detect_capability_gaps(
desired_ability=desired_ability,
desired_capabilities=capabilities,
repository_ids=repository_ids or None,
)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
body = f"""
<div class="actions">
<h1 style="margin-right:auto">Capability Gap Report</h1>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<section class="panel">
<h2>{escape(report["desired_ability"])}</h2>
{render_gap_report(report)}
</section>
"""
return page("Capability Gap Report", body)
@router.get("/ui/search")
def search_page(
q: str = "",
@@ -290,6 +398,7 @@ def repository_detail(
body = f"""
<div class="actions">
<h1 style="margin-right:auto">{escape(repository.name)}</h1>
<a class="button secondary" href="/ui/repos/{repository_id}/export">Export</a>
<a class="button secondary" href="/ui">Back</a>
</div>
<p class="muted">{escape(repository.description or '')}</p>
@@ -374,6 +483,18 @@ def repository_detail(
return page(repository.name, body)
@router.get("/ui/repos/{repository_id}/export")
def export_repository_from_ui(
repository_id: int,
service: RegistryService = Depends(get_service),
) -> PlainTextResponse:
try:
content = service.export_registry_entry(repository_id)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return PlainTextResponse(content, media_type="application/x-yaml")
@router.post("/ui/repos/{repository_id}/edit")
def edit_repository_from_form(
repository_id: int,
@@ -1156,6 +1277,200 @@ def render_diff_payload(payload: dict | None) -> str:
return "".join(parts) or '<span class="muted">No display fields.</span>'
def render_repository_checkbox_list(
service: RegistryService,
repositories: list,
) -> str:
if not repositories:
return '<p class="muted">No repositories registered.</p>'
rows = []
for repository in repositories:
ability_map = service.ability_map(repository.id)
approved_count = len(ability_map.abilities)
disabled = " disabled" if approved_count == 0 else ""
status = (
f"{approved_count} approved "
f"{'ability' if approved_count == 1 else 'abilities'}"
if approved_count
else "No approved profile"
)
rows.append(
f"""
<label>
<span>
<input style="width:auto" type="checkbox" name="repository_ids" value="{repository.id}"{disabled}>
{escape(repository.name)}
</span>
<span class="muted">{escape(status)}</span>
</label>
"""
)
return f'<div class="stack">{"".join(rows)}</div>'
def render_compared_repositories(repositories: list[dict]) -> str:
if not repositories:
return '<p class="muted">No repositories selected.</p>'
rows = "\n".join(
f"""
<tr>
<td><a href="/ui/repos/{repository['id']}">{escape(repository['name'])}</a></td>
<td><span class="pill">{escape(repository['status'])}</span></td>
<td class="source">{escape(repository['branch'])}</td>
</tr>
"""
for repository in repositories
)
return f"""
<table>
<thead><tr><th>Name</th><th>Status</th><th>Branch</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_compared_abilities(abilities: list[dict]) -> str:
if not abilities:
return '<p class="muted">No shared approved abilities.</p>'
rows = []
for ability in abilities:
repositories = "".join(
f"""
<li>
<strong>{escape(repository['repository_name'])}</strong>
<span class="pill">{repository['confidence']:.2f} {escape(repository['confidence_label'])}</span>
{render_compared_capability_pills(repository['capabilities'])}
</li>
"""
for repository in ability["repositories"]
)
rows.append(
f"""
<li>
<strong>{escape(ability['name'])}</strong>
<ul>{repositories}</ul>
</li>
"""
)
return f'<div class="tree"><ul>{"".join(rows)}</ul></div>'
def render_compared_capability_pills(capabilities: list[dict]) -> str:
if not capabilities:
return ""
return " ".join(
f'<span class="pill">{escape(capability["name"])} · {capability["evidence_count"]} evidence</span>'
for capability in capabilities
)
def render_unique_capabilities(capabilities: list[dict]) -> str:
if not capabilities:
return '<p class="muted">No unique approved capabilities.</p>'
rows = "\n".join(
f"""
<tr>
<td><a href="/ui/repos/{capability['repository_id']}">{escape(capability['repository_name'])}</a></td>
<td>{escape(capability['ability_name'])}</td>
<td><strong>{escape(capability['capability_name'])}</strong></td>
</tr>
"""
for capability in capabilities
)
return f"""
<table>
<thead><tr><th>Repository</th><th>Ability</th><th>Capability</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_gap_report(report: dict) -> str:
return f"""
<div class="grid">
<section>
<h3>Matched</h3>
{render_gap_matches(report["matched_capabilities"])}
</section>
<section>
<h3>Missing</h3>
{render_gap_list(report["missing_capabilities"], "No missing capabilities.")}
</section>
<section>
<h3>Weak Evidence</h3>
{render_weak_evidence(report["weakly_evidenced_capabilities"])}
</section>
<section>
<h3>Duplicates</h3>
{render_duplicate_capabilities(report["duplicate_capabilities"])}
</section>
</div>
"""
def render_gap_matches(matches: list[dict]) -> str:
if not matches:
return '<p class="muted">No desired capabilities matched.</p>'
rows = "\n".join(
f"""
<tr>
<td><strong>{escape(match['capability'])}</strong></td>
<td>{escape(', '.join(match['repositories']))}</td>
</tr>
"""
for match in matches
)
return f"<table><tbody>{rows}</tbody></table>"
def render_gap_list(values: list[str], empty: str) -> str:
if not values:
return f'<p class="muted">{escape(empty)}</p>'
return "<ul>" + "".join(f"<li>{escape(value)}</li>" for value in values) + "</ul>"
def render_weak_evidence(items: list[dict]) -> str:
if not items:
return '<p class="muted">No weak evidence flags.</p>'
rows = "\n".join(
f"""
<tr>
<td>{escape(item['capability'])}</td>
<td>{escape(item['repository_name'])}</td>
<td>{escape(str(item['strongest_evidence'] or 'none'))}</td>
<td>{item['confidence']:.2f} <span class="pill">{escape(item['confidence_label'])}</span></td>
</tr>
"""
for item in items
)
return f"""
<table>
<thead><tr><th>Capability</th><th>Repository</th><th>Evidence</th><th>Confidence</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_duplicate_capabilities(items: list[dict]) -> str:
if not items:
return '<p class="muted">No duplicate capabilities.</p>'
rows = "\n".join(
f"""
<tr>
<td>{escape(item['capability'])}</td>
<td>{escape(', '.join(item['repositories']))}</td>
</tr>
"""
for item in items
)
return f"<table><tbody>{rows}</tbody></table>"
def split_capability_lines(value: str) -> list[str]:
normalized = value.replace(",", "\n")
return [line.strip() for line in normalized.splitlines() if line.strip()]
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
abilities = graph.get("abilities", [])
if not abilities: