Count badges now with navigation

This commit is contained in:
2026-04-28 03:58:34 +02:00
parent 6981cb1df4
commit 360537ef05
4 changed files with 357 additions and 13 deletions

View File

@@ -464,6 +464,11 @@ def repository_detail(
raise HTTPException(status_code=404, detail=str(exc)) from exc
runs = service.list_analysis_runs(repository_id)
ability_map = service.ability_map(repository_id)
latest_candidate = latest_completed_candidate_graph(
service,
repository_id,
runs,
)
decisions = service.list_review_decisions(repository_id)
facts = service.list_observed_facts(repository_id)
languages = sorted({fact.name for fact in facts if fact.kind == "language"})
@@ -517,7 +522,13 @@ def repository_detail(
</section>
<section class="panel">
<h2>Approved Ability Map</h2>
{render_graph_counts(asdict(ability_map), facts_count=None)}
{render_graph_counts(
asdict(ability_map),
facts_count=None,
base_href=f"/ui/repos/{repository_id}/elements?scope=approved",
)}
<h2>Latest Candidate Graph</h2>
{render_latest_candidate_counts(repository_id, latest_candidate, service)}
{render_ability_map(asdict(ability_map), repository_id)}
</section>
</div>
@@ -873,7 +884,18 @@ def analysis_run_detail(
<section class="panel">
<div class="actions">
<h2 style="margin-right:auto">Candidate Graph</h2>
{render_graph_counts(asdict(candidate_graph), facts_count=len(facts))}
{render_graph_counts(
asdict(candidate_graph),
facts_count=len(facts),
base_href=(
f"/ui/repos/{repository_id}/elements?scope=candidate"
f"&analysis_run_id={analysis_run_id}"
),
facts_href=(
f"/ui/repos/{repository_id}/elements?scope=facts"
f"&analysis_run_id={analysis_run_id}&type=facts"
),
)}
<form method="post" action="/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve">
<button type="submit">Approve</button>
</form>
@@ -883,7 +905,15 @@ def analysis_run_detail(
<section class="panel">
<div class="actions">
<h2 style="margin-right:auto">Observed Facts</h2>
{render_count_pills(facts=len(facts))}
{render_count_pills(
hrefs={
"facts": (
f"/ui/repos/{repository_id}/elements?scope=facts"
f"&analysis_run_id={analysis_run_id}&type=facts"
)
},
facts=len(facts),
)}
</div>
<table>
<thead><tr><th>Kind</th><th>Name</th><th>Path</th><th>Value</th></tr></thead>
@@ -901,6 +931,52 @@ def analysis_run_detail(
return page(f"{repository.name} Run {analysis_run_id}", body)
@router.get("/ui/repos/{repository_id}/elements")
def repository_element_listing(
repository_id: int,
scope: str = Query("approved"),
type: str = Query("abilities"),
analysis_run_id: int | None = Query(default=None),
service: RegistryService = Depends(get_service),
) -> HTMLResponse:
repository = service.get_repository(repository_id)
title = element_listing_title(repository.name, scope, type)
if scope == "approved":
graph = asdict(service.ability_map(repository_id))
rows = render_graph_element_rows(graph, type)
elif scope == "candidate":
if analysis_run_id is None:
runs = service.list_analysis_runs(repository_id)
latest = latest_completed_candidate_graph(service, repository_id, runs)
if latest is None:
rows = '<tr><td class="muted">No completed candidate graph.</td></tr>'
else:
analysis_run_id, candidate_graph = latest
rows = render_graph_element_rows(asdict(candidate_graph), type)
else:
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
rows = render_graph_element_rows(asdict(candidate_graph), type)
elif scope == "facts":
facts = service.list_observed_facts(repository_id, analysis_run_id)
rows = render_fact_element_rows(facts)
else:
rows = '<tr><td class="muted">Unknown listing scope.</td></tr>'
body = f"""
<div class="actions">
<h1 style="margin-right:auto">{escape(title)}</h1>
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
</div>
<section class="panel">
<table>
<thead><tr><th>Type</th><th>Name</th><th>Parent</th><th>Source</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</section>
"""
return page(title, body)
@router.get("/ui/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}")
def analysis_run_diff_detail(
repository_id: int,
@@ -1573,7 +1649,52 @@ def split_capability_lines(value: str) -> list[str]:
return [line.strip() for line in normalized.splitlines() if line.strip()]
def render_graph_counts(graph: dict, facts_count: int | None = None) -> str:
def latest_completed_candidate_graph(
service: RegistryService,
repository_id: int,
runs: list,
):
for run in sorted(runs, key=lambda item: item.id, reverse=True):
if run.status != "completed":
continue
try:
return run.id, service.candidate_graph(repository_id, run.id)
except NotFoundError:
continue
return None
def render_latest_candidate_counts(
repository_id: int,
latest_candidate,
service: RegistryService,
) -> str:
if latest_candidate is None:
return '<p class="muted">No completed candidate graph yet.</p>'
analysis_run_id, candidate_graph = latest_candidate
facts_count = len(service.list_observed_facts(repository_id, analysis_run_id))
return render_graph_counts(
asdict(candidate_graph),
facts_count=facts_count,
label_prefix="candidate",
base_href=(
f"/ui/repos/{repository_id}/elements?scope=candidate"
f"&analysis_run_id={analysis_run_id}"
),
facts_href=(
f"/ui/repos/{repository_id}/elements?scope=facts"
f"&analysis_run_id={analysis_run_id}&type=facts"
),
)
def render_graph_counts(
graph: dict,
facts_count: int | None = None,
label_prefix: str = "",
base_href: str | None = None,
facts_href: str | None = None,
) -> str:
abilities = graph.get("abilities", [])
capabilities = [
capability
@@ -1592,20 +1713,121 @@ def render_graph_counts(graph: dict, facts_count: int | None = None) -> str:
}
if facts_count is not None:
counts["facts"] = facts_count
return render_count_pills(**counts)
hrefs = (
{name: f"{base_href}&type={name}" for name in counts if name != "facts"}
if base_href
else {}
)
if facts_href:
hrefs["facts"] = facts_href
return render_count_pills(label_prefix=label_prefix, hrefs=hrefs, **counts)
def render_count_pills(**counts: int) -> str:
def render_count_pills(
label_prefix: str = "",
hrefs: dict[str, str] | None = None,
**counts: int,
) -> str:
labels = {
"abilities": "abilities",
"capabilities": "capabilities",
"features": "features",
"facts": "facts",
}
return "".join(
f'<span class="pill">{count} {labels[name]}</span>'
for name, count in counts.items()
)
prefix = f"{label_prefix} " if label_prefix else ""
hrefs = hrefs or {}
items = []
for name, count in counts.items():
label = f"{count} {prefix}{labels[name]}"
if name in hrefs:
items.append(f'<a class="pill" href="{escape(hrefs[name])}">{label}</a>')
else:
items.append(f'<span class="pill">{label}</span>')
return "".join(items)
def render_graph_element_rows(graph: dict, item_type: str) -> str:
rows: list[str] = []
for ability in graph.get("abilities", []):
if item_type == "abilities":
rows.append(
render_element_row("ability", ability["name"], "", ability.get("source_refs", []))
)
for capability in ability.get("capabilities", []):
if item_type == "capabilities":
rows.append(
render_element_row(
"capability",
capability["name"],
ability["name"],
capability.get("source_refs", []),
)
)
for feature in capability.get("features", []):
if item_type == "features":
rows.append(
render_element_row(
"feature",
feature["name"],
capability["name"],
feature.get("source_refs", []),
)
)
return "\n".join(rows) or '<tr><td colspan="4" class="muted">No matching elements.</td></tr>'
def element_listing_title(repository_name: str, scope: str, item_type: str) -> str:
type_labels = {
"abilities": "Abilities",
"capabilities": "Capabilities",
"features": "Features",
"facts": "Facts",
}
if scope == "facts":
return f"{repository_name} · Observed Facts"
scope_labels = {
"approved": "Approved",
"candidate": "Candidate",
}
scope_label = scope_labels.get(scope, scope.title())
type_label = type_labels.get(item_type, item_type.title())
return f"{repository_name} · {scope_label} {type_label}"
def render_fact_element_rows(facts: list) -> str:
rows = [
render_element_row(
fact.kind,
fact.name,
fact.path,
[
{
"path": fact.path,
"kind": fact.kind,
"name": fact.name,
"line": fact.metadata.get("line"),
}
],
)
for fact in facts
]
return "\n".join(rows) or '<tr><td colspan="4" class="muted">No matching facts.</td></tr>'
def render_element_row(
item_type: str,
name: str,
parent: str,
source_refs: list[dict],
) -> str:
return f"""
<tr>
<td>{escape(item_type)}</td>
<td>{escape(name)}</td>
<td>{escape(parent)}</td>
<td>{render_sources(source_refs)}</td>
</tr>
"""
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str: