generated from coulomb/repo-seed
Dependency graph vizualization
This commit is contained in:
40
docs/adr-dependency-graph-visualization-framework.md
Normal file
40
docs/adr-dependency-graph-visualization-framework.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# ADR: Dependency Graph Visualization Framework
|
||||
|
||||
Status: accepted
|
||||
|
||||
Context: dependency-aware scope propagation needs an interactive graph view where
|
||||
curators can inspect fact-to-scope paths, stale nodes, evidence bridges,
|
||||
same-layer normalization signals, and review recommendations. The visualization
|
||||
must support practical graph exploration rather than only a static diagram.
|
||||
|
||||
Decision: use Cytoscape.js for the interactive dependency graph visualization.
|
||||
|
||||
Cytoscape.js is selected because it is graph-native: it supports pan, zoom,
|
||||
selection, layouts, node and edge styling, filtering, and path highlighting in a
|
||||
way that maps directly to the dependency model. It can be embedded inside the
|
||||
existing FastAPI-served UI without requiring a full frontend rewrite.
|
||||
|
||||
Variants considered:
|
||||
|
||||
- React Flow: strong for polished node-card workflows and editable diagrams, but
|
||||
it is more flow-builder-oriented than graph-analysis-oriented. Dense
|
||||
dependency maps and impact-path exploration would require more custom layout
|
||||
discipline.
|
||||
- D3: maximum visual control and excellent for bespoke animated propagation, but
|
||||
it carries the highest implementation and maintenance cost. It is too easy to
|
||||
build a compelling custom view that becomes hard to evolve.
|
||||
- Mermaid: lightweight and useful for static docs or fallback exports, but not
|
||||
interactive enough for curator workflows that need selection, filtering,
|
||||
pan/zoom, and path drilldown.
|
||||
|
||||
Consequences:
|
||||
|
||||
- The first visualization implementation should expose graph data as nodes and
|
||||
edges that Cytoscape can consume directly.
|
||||
- UI work can remain incremental: a single graph route and JSON endpoint can be
|
||||
added before broader review-workflow screens.
|
||||
- Cytoscape styling should encode repo-scoping semantics, not generic graph
|
||||
decoration: node kind, edge strength, dependency type, staleness, and
|
||||
same-layer flags should be visible.
|
||||
- Mermaid may still be useful as an export or documentation format, but it is
|
||||
not the primary interactive implementation.
|
||||
@@ -47,6 +47,18 @@ The current implementation exposes this through `RegistryService`:
|
||||
The impact result includes changed fact keys, impacted items, reason chains,
|
||||
maximum propagation depth, breadth, and whether the root scope was affected.
|
||||
|
||||
## Interactive Visualization
|
||||
|
||||
The accepted implementation framework for the interactive graph view is
|
||||
Cytoscape.js. See
|
||||
`docs/adr-dependency-graph-visualization-framework.md` for the decision and the
|
||||
tradeoffs against React Flow, D3, and Mermaid.
|
||||
|
||||
The first UI implementation exposes `/repos/{repository_id}/dependency-graph`
|
||||
as a Cytoscape-ready JSON payload and `/ui/repos/{repository_id}/dependency-graph`
|
||||
as the graph page. The page supports full graph, impact-only, and selected-path
|
||||
views with a detail panel for selected nodes and edges.
|
||||
|
||||
## Metrics
|
||||
|
||||
Propagation depth says how far a source change bubbled up. Propagation breadth
|
||||
|
||||
@@ -1090,6 +1090,115 @@ class RegistryService:
|
||||
graph=graph,
|
||||
)
|
||||
|
||||
def dependency_graph_elements(
|
||||
self,
|
||||
repository_id: int,
|
||||
*,
|
||||
base_analysis_run_id: int | None = None,
|
||||
target_analysis_run_id: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
impact = None
|
||||
if base_analysis_run_id is not None or target_analysis_run_id is not None:
|
||||
if base_analysis_run_id is None or target_analysis_run_id is None:
|
||||
raise ValueError(
|
||||
"base_analysis_run_id and target_analysis_run_id must be provided together"
|
||||
)
|
||||
impact = self.analyze_dependency_impact(
|
||||
repository_id,
|
||||
base_analysis_run_id,
|
||||
target_analysis_run_id,
|
||||
)
|
||||
graph = impact.graph
|
||||
else:
|
||||
graph = self.build_dependency_graph(repository_id)
|
||||
|
||||
impact_by_key = (
|
||||
{item.item_key: item for item in impact.impacts} if impact is not None else {}
|
||||
)
|
||||
changed_fact_keys = set(impact.changed_fact_keys) if impact is not None else set()
|
||||
nodes: dict[str, dict[str, object]] = {}
|
||||
|
||||
def ensure_node(kind: str, key: str, item_id: int | None) -> None:
|
||||
if key in nodes:
|
||||
return
|
||||
impact_item = impact_by_key.get(key)
|
||||
is_changed_fact = key in changed_fact_keys
|
||||
nodes[key] = {
|
||||
"data": {
|
||||
"id": key,
|
||||
"kind": kind,
|
||||
"label": self._dependency_node_label(repository_id, kind, key, item_id),
|
||||
"ownership": self._ownership_for_kind(kind),
|
||||
"freshnessState": (
|
||||
impact_item.freshness_state
|
||||
if impact_item is not None
|
||||
else "changed"
|
||||
if is_changed_fact
|
||||
else "current"
|
||||
),
|
||||
"recommendedAction": (
|
||||
impact_item.recommended_action if impact_item is not None else ""
|
||||
),
|
||||
"impactDepth": (
|
||||
impact_item.impact_depth if impact_item is not None else None
|
||||
),
|
||||
"reasons": impact_item.reasons if impact_item is not None else [],
|
||||
},
|
||||
"classes": " ".join(
|
||||
class_name
|
||||
for class_name in (
|
||||
kind,
|
||||
"stale" if impact_item is not None else "current",
|
||||
"changed" if is_changed_fact else "",
|
||||
)
|
||||
if class_name
|
||||
),
|
||||
}
|
||||
|
||||
for edge in graph.edges:
|
||||
ensure_node(edge.source_kind, edge.source_key, edge.source_id)
|
||||
ensure_node(edge.target_kind, edge.target_key, edge.target_id)
|
||||
|
||||
edges = [
|
||||
{
|
||||
"data": {
|
||||
"id": f"{edge.source_key}->{edge.target_key}:{index}",
|
||||
"source": edge.source_key,
|
||||
"target": edge.target_key,
|
||||
"dependencyType": edge.dependency_type,
|
||||
"strength": edge.strength,
|
||||
"edgeSource": edge.source,
|
||||
"sameLayer": edge.same_layer,
|
||||
"label": edge.dependency_type,
|
||||
},
|
||||
"classes": " ".join(
|
||||
class_name
|
||||
for class_name in (
|
||||
edge.dependency_type,
|
||||
edge.strength,
|
||||
"same-layer" if edge.same_layer else "",
|
||||
)
|
||||
if class_name
|
||||
),
|
||||
}
|
||||
for index, edge in enumerate(graph.edges)
|
||||
]
|
||||
return {
|
||||
"repository": asdict(graph.repository),
|
||||
"scope": asdict(graph.scope),
|
||||
"mode": "impact" if impact is not None else "full",
|
||||
"metrics": {
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
"propagation_breadth": impact.propagation_breadth if impact else 0,
|
||||
"max_depth": impact.max_depth if impact else 0,
|
||||
"scope_impacted": impact.scope_impacted if impact else False,
|
||||
},
|
||||
"changed_fact_keys": impact.changed_fact_keys if impact else [],
|
||||
"elements": [*nodes.values(), *edges],
|
||||
"impacts": [asdict(item) for item in impact.impacts] if impact else [],
|
||||
}
|
||||
|
||||
def approve_analysis_run_changes(
|
||||
self,
|
||||
repository_id: int,
|
||||
@@ -2300,6 +2409,24 @@ class RegistryService:
|
||||
return evidence.reference
|
||||
return f"{kind}:{item_id}"
|
||||
|
||||
def _dependency_node_label(
|
||||
self,
|
||||
repository_id: int,
|
||||
kind: str,
|
||||
key: str,
|
||||
item_id: int | None,
|
||||
) -> str:
|
||||
if item_id is not None and kind != "fact":
|
||||
return self._dependency_display_name(repository_id, kind, item_id)
|
||||
if kind == "fact":
|
||||
parts = key.split(":", 3)
|
||||
if len(parts) == 4:
|
||||
_, fact_kind, path, name = parts
|
||||
return f"{name} ({fact_kind}, {path})"
|
||||
if item_id is not None:
|
||||
return f"{kind}:{item_id}"
|
||||
return key
|
||||
|
||||
def _chunk_index(
|
||||
self,
|
||||
chunks: Sequence[ContentChunk],
|
||||
|
||||
@@ -1139,6 +1139,28 @@ def get_ability_map(
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repos/{repository_id}/dependency-graph",
|
||||
tags=["registry"],
|
||||
)
|
||||
def get_dependency_graph(
|
||||
repository_id: int,
|
||||
base_analysis_run_id: int | None = Query(default=None),
|
||||
target_analysis_run_id: int | None = Query(default=None),
|
||||
service: RegistryService = Depends(get_service),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return service.dependency_graph_elements(
|
||||
repository_id,
|
||||
base_analysis_run_id=base_analysis_run_id,
|
||||
target_analysis_run_id=target_analysis_run_id,
|
||||
)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repos/{repository_id}/export",
|
||||
tags=["discovery"],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
@@ -200,6 +201,49 @@ def page(
|
||||
color: #1f2933;
|
||||
font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}}
|
||||
.graph-shell {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
min-height: 680px;
|
||||
}}
|
||||
.graph-canvas {{
|
||||
min-height: 680px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}}
|
||||
.graph-sidebar {{
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}}
|
||||
.graph-controls {{
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.graph-controls button[aria-pressed="true"] {{
|
||||
color: white;
|
||||
background: var(--accent-dark);
|
||||
border-color: var(--accent-dark);
|
||||
}}
|
||||
.legend-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 10px;
|
||||
align-items: center;
|
||||
}}
|
||||
.legend-swatch {{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
}}
|
||||
.detail-list {{
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}}
|
||||
.actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
|
||||
.header-brand {{ display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }}
|
||||
.header-context {{ color: #b6c8d6; font-weight: 650; }}
|
||||
@@ -211,6 +255,8 @@ def page(
|
||||
header {{ padding: 12px 16px; }}
|
||||
main {{ padding: 16px; }}
|
||||
.grid {{ grid-template-columns: 1fr; }}
|
||||
.graph-shell {{ grid-template-columns: 1fr; }}
|
||||
.graph-canvas {{ min-height: 560px; }}
|
||||
table, tbody, tr, td {{ display: block; width: 100%; }}
|
||||
thead {{ display: none; }}
|
||||
td {{ border-bottom: 0; padding: 6px 0; }}
|
||||
@@ -780,6 +826,7 @@ def repository_detail(
|
||||
body = f"""
|
||||
<div class="actions">
|
||||
<h1 style="margin-right:auto">{escape(display_name)}</h1>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}/dependency-graph">Dependency Graph</a>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}/export">Export</a>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}/scope">SCOPE</a>
|
||||
<a class="button secondary" href="/ui">Back</a>
|
||||
@@ -1578,6 +1625,7 @@ def analysis_run_diff_detail(
|
||||
body = f"""
|
||||
<div class="actions">
|
||||
<h1 style="margin-right:auto">{escape(display_name)} · Change Review</h1>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}/dependency-graph?base_analysis_run_id={base_analysis_run_id}&target_analysis_run_id={target_analysis_run_id}">Impact Graph</a>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}">Target Run</a>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
||||
</div>
|
||||
@@ -1618,6 +1666,250 @@ def analysis_run_diff_detail(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui/repos/{repository_id}/dependency-graph")
|
||||
def dependency_graph_view(
|
||||
repository_id: int,
|
||||
base_analysis_run_id: int | None = Query(default=None),
|
||||
target_analysis_run_id: int | None = Query(default=None),
|
||||
service: RegistryService = Depends(get_service),
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
repository = service.get_repository(repository_id)
|
||||
graph = service.dependency_graph_elements(
|
||||
repository_id,
|
||||
base_analysis_run_id=base_analysis_run_id,
|
||||
target_analysis_run_id=target_analysis_run_id,
|
||||
)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
display_name = repository_display_name(repository)
|
||||
query = ""
|
||||
if base_analysis_run_id is not None and target_analysis_run_id is not None:
|
||||
query = (
|
||||
f"?base_analysis_run_id={base_analysis_run_id}"
|
||||
f"&target_analysis_run_id={target_analysis_run_id}"
|
||||
)
|
||||
endpoint = json.dumps(f"/repos/{repository_id}/dependency-graph{query}")
|
||||
metrics = graph["metrics"]
|
||||
changed_note = (
|
||||
f"Impact view for run #{base_analysis_run_id} to run #{target_analysis_run_id}."
|
||||
if graph["mode"] == "impact"
|
||||
else "Full approved dependency graph."
|
||||
)
|
||||
body = f"""
|
||||
<div class="actions">
|
||||
<h1 style="margin-right:auto">{escape(display_name)} · Dependency Graph</h1>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
||||
</div>
|
||||
<p class="muted">{escape(changed_note)}</p>
|
||||
<section class="panel">
|
||||
<div class="actions">
|
||||
<span class="pill">{metrics['node_count']} nodes</span>
|
||||
<span class="pill">{metrics['edge_count']} edges</span>
|
||||
<span class="pill">{metrics['propagation_breadth']} impacted</span>
|
||||
<span class="pill">depth {metrics['max_depth']}</span>
|
||||
<span class="pill">scope {'impacted' if metrics['scope_impacted'] else 'current'}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="graph-shell" style="margin-top:18px">
|
||||
<div id="dependency-graph" class="graph-canvas" role="img" aria-label="Interactive dependency graph"></div>
|
||||
<aside class="graph-sidebar">
|
||||
<section class="panel">
|
||||
<h2>View</h2>
|
||||
<div class="graph-controls" role="toolbar" aria-label="Graph view controls">
|
||||
<button class="secondary" type="button" data-graph-mode="full" aria-pressed="true">Full</button>
|
||||
<button class="secondary" type="button" data-graph-mode="impact" aria-pressed="false">Impact</button>
|
||||
<button class="secondary" type="button" data-graph-mode="path" aria-pressed="false">Path</button>
|
||||
<button class="secondary" type="button" data-graph-fit>Fit</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Legend</h2>
|
||||
<div class="legend-grid">
|
||||
<span class="legend-swatch" style="background:#64748b"></span><span>Fact</span>
|
||||
<span class="legend-swatch" style="background:#0891b2"></span><span>Evidence</span>
|
||||
<span class="legend-swatch" style="background:#7c3aed"></span><span>Feature</span>
|
||||
<span class="legend-swatch" style="background:#0f766e"></span><span>Capability</span>
|
||||
<span class="legend-swatch" style="background:#b45309"></span><span>Ability</span>
|
||||
<span class="legend-swatch" style="background:#be123c"></span><span>Scope</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Selection</h2>
|
||||
<div id="graph-detail" class="muted">Select a node or edge.</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
|
||||
<script>
|
||||
(() => {{
|
||||
const endpoint = {endpoint};
|
||||
const container = document.getElementById("dependency-graph");
|
||||
const detail = document.getElementById("graph-detail");
|
||||
const modeButtons = Array.from(document.querySelectorAll("[data-graph-mode]"));
|
||||
const fitButton = document.querySelector("[data-graph-fit]");
|
||||
let cy = null;
|
||||
let mode = "full";
|
||||
let selected = null;
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
const renderDetailList = (items) => {{
|
||||
if (!items || items.length === 0) return "";
|
||||
return `<ul class="detail-list">${{items.map((item) => `<li>${{escapeHtml(item)}}</li>`).join("")}}</ul>`;
|
||||
}};
|
||||
|
||||
const showDetails = (element) => {{
|
||||
if (!element) {{
|
||||
detail.className = "muted";
|
||||
detail.textContent = "Select a node or edge.";
|
||||
return;
|
||||
}}
|
||||
const data = element.data();
|
||||
detail.className = "";
|
||||
if (element.isNode()) {{
|
||||
detail.innerHTML = `
|
||||
<p><strong>${{escapeHtml(data.label)}}</strong></p>
|
||||
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span></p>
|
||||
<p class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p>
|
||||
${{data.recommendedAction ? `<p>Recommended action: <strong>${{escapeHtml(data.recommendedAction)}}</strong></p>` : ""}}
|
||||
${{renderDetailList(data.reasons)}}
|
||||
`;
|
||||
}} else {{
|
||||
detail.innerHTML = `
|
||||
<p><strong>${{escapeHtml(data.dependencyType)}}</strong></p>
|
||||
<p><span class="pill">${{escapeHtml(data.strength)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p>
|
||||
<p class="muted">${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}</p>
|
||||
<p class="muted">Source: ${{escapeHtml(data.edgeSource)}}</p>
|
||||
`;
|
||||
}}
|
||||
}};
|
||||
|
||||
const visibleForMode = () => {{
|
||||
if (!cy) return cy.collection();
|
||||
if (mode === "full") return cy.elements();
|
||||
if (mode === "impact") {{
|
||||
const touched = cy.nodes(".stale, .changed");
|
||||
return touched.union(touched.connectedEdges().filter((edge) => edge.connectedNodes(".stale, .changed").length > 0));
|
||||
}}
|
||||
if (mode === "path" && selected) {{
|
||||
return selected.union(selected.predecessors()).union(selected.successors());
|
||||
}}
|
||||
return cy.elements();
|
||||
}};
|
||||
|
||||
const applyMode = (nextMode) => {{
|
||||
mode = nextMode;
|
||||
modeButtons.forEach((button) => {{
|
||||
button.setAttribute("aria-pressed", String(button.dataset.graphMode === mode));
|
||||
}});
|
||||
if (!cy) return;
|
||||
cy.elements().addClass("hidden");
|
||||
visibleForMode().removeClass("hidden");
|
||||
cy.layout({{ name: "breadthfirst", directed: true, spacingFactor: 1.3, animate: true }}).run();
|
||||
cy.fit(cy.elements(":visible"), 48);
|
||||
}};
|
||||
|
||||
const style = [
|
||||
{{
|
||||
selector: "node",
|
||||
style: {{
|
||||
"background-color": "#64748b",
|
||||
"border-color": "#334155",
|
||||
"border-width": 1,
|
||||
"color": "#1f2933",
|
||||
"font-size": 11,
|
||||
"height": 36,
|
||||
"label": "data(label)",
|
||||
"text-background-color": "#ffffff",
|
||||
"text-background-opacity": .85,
|
||||
"text-background-padding": 2,
|
||||
"text-margin-y": -7,
|
||||
"text-max-width": 130,
|
||||
"text-valign": "top",
|
||||
"text-wrap": "wrap",
|
||||
"width": 36
|
||||
}}
|
||||
}},
|
||||
{{ selector: "node[kind = 'evidence']", style: {{ "background-color": "#0891b2" }} }},
|
||||
{{ selector: "node[kind = 'feature']", style: {{ "background-color": "#7c3aed" }} }},
|
||||
{{ selector: "node[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle", "width": 56 }} }},
|
||||
{{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon", "height": 46, "width": 46 }} }},
|
||||
{{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star", "height": 58, "width": 58 }} }},
|
||||
{{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }},
|
||||
{{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }},
|
||||
{{
|
||||
selector: "edge",
|
||||
style: {{
|
||||
"curve-style": "bezier",
|
||||
"line-color": "#94a3b8",
|
||||
"target-arrow-color": "#94a3b8",
|
||||
"target-arrow-shape": "triangle",
|
||||
"width": 2
|
||||
}}
|
||||
}},
|
||||
{{ selector: "edge[strength = 'strong']", style: {{ "width": 4, "line-color": "#475569", "target-arrow-color": "#475569" }} }},
|
||||
{{ selector: "edge[strength = 'weak']", style: {{ "width": 1, "line-style": "dotted" }} }},
|
||||
{{ selector: "edge.same-layer", style: {{ "line-color": "#f97316", "line-style": "dashed", "target-arrow-color": "#f97316" }} }},
|
||||
{{ selector: ":selected", style: {{ "border-color": "#111827", "border-width": 5, "line-color": "#111827", "target-arrow-color": "#111827" }} }},
|
||||
{{ selector: ".hidden", style: {{ "display": "none" }} }}
|
||||
];
|
||||
|
||||
if (!window.cytoscape) {{
|
||||
container.innerHTML = '<p class="notice warn">Cytoscape.js could not be loaded.</p>';
|
||||
return;
|
||||
}}
|
||||
|
||||
fetch(endpoint)
|
||||
.then((response) => {{
|
||||
if (!response.ok) throw new Error(`Graph request failed: ${{response.status}}`);
|
||||
return response.json();
|
||||
}})
|
||||
.then((payload) => {{
|
||||
cy = cytoscape({{
|
||||
container,
|
||||
elements: payload.elements,
|
||||
style,
|
||||
layout: {{ name: "breadthfirst", directed: true, spacingFactor: 1.25 }}
|
||||
}});
|
||||
cy.on("tap", "node, edge", (event) => {{
|
||||
selected = event.target;
|
||||
showDetails(selected);
|
||||
if (mode === "path") applyMode("path");
|
||||
}});
|
||||
cy.on("tap", (event) => {{
|
||||
if (event.target === cy) {{
|
||||
selected = null;
|
||||
showDetails(null);
|
||||
if (mode === "path") applyMode("path");
|
||||
}}
|
||||
}});
|
||||
fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48));
|
||||
modeButtons.forEach((button) => {{
|
||||
button.addEventListener("click", () => applyMode(button.dataset.graphMode));
|
||||
}});
|
||||
applyMode(payload.mode === "impact" ? "impact" : "full");
|
||||
}})
|
||||
.catch((error) => {{
|
||||
container.innerHTML = `<p class="notice error">${{escapeHtml(error.message)}}</p>`;
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
return page(
|
||||
f"{display_name} Dependency Graph",
|
||||
body,
|
||||
selected_repository=display_name,
|
||||
selected_repository_id=repository.id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve")
|
||||
def approve_candidate_graph_from_form(
|
||||
repository_id: int,
|
||||
|
||||
@@ -189,6 +189,9 @@ def test_openapi_contract_snapshot_for_stable_agent_paths():
|
||||
"/repos/{repository_id}/ability-map": {
|
||||
"get": {"tags": ["registry"], "success_schema": "RepositoryAbilityMapResponse"}
|
||||
},
|
||||
"/repos/{repository_id}/dependency-graph": {
|
||||
"get": {"tags": ["registry"], "success_schema": "object"}
|
||||
},
|
||||
"/repos/{repository_id}/analysis-runs": {
|
||||
"get": {"tags": ["analysis"], "success_schema": "list[AnalysisRunResponse]"},
|
||||
"post": {"tags": ["analysis"], "success_schema": "ScanSummaryResponse"},
|
||||
@@ -1365,6 +1368,10 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
||||
f'<a class="button secondary" href="/ui/repos/{repository_id}/scope">SCOPE</a>'
|
||||
in detail_response.text
|
||||
)
|
||||
assert (
|
||||
f'<a class="button secondary" href="/ui/repos/{repository_id}/dependency-graph">Dependency Graph</a>'
|
||||
in detail_response.text
|
||||
)
|
||||
|
||||
repo_scope_response = client.get(f"/ui/repos/{repository_id}/scope")
|
||||
assert repo_scope_response.status_code == 200
|
||||
@@ -1491,6 +1498,24 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
||||
assert "Elements" in approved_detail.text
|
||||
assert "q=Report+Service+Status" in approved_detail.text
|
||||
|
||||
graph_response = client.get(f"/repos/{repository_id}/dependency-graph")
|
||||
assert graph_response.status_code == 200
|
||||
graph_payload = graph_response.json()
|
||||
assert graph_payload["mode"] == "full"
|
||||
assert graph_payload["metrics"]["node_count"] >= 4
|
||||
assert graph_payload["metrics"]["edge_count"] >= 3
|
||||
assert any(
|
||||
element["data"].get("kind") == "scope"
|
||||
for element in graph_payload["elements"]
|
||||
if "source" not in element["data"]
|
||||
)
|
||||
|
||||
graph_page = client.get(f"/ui/repos/{repository_id}/dependency-graph")
|
||||
assert graph_page.status_code == 200
|
||||
assert "Dependency Graph" in graph_page.text
|
||||
assert "cytoscape.min.js" in graph_page.text
|
||||
assert 'data-graph-mode="impact"' in graph_page.text
|
||||
|
||||
scope_listing = client.get(
|
||||
f"/ui/repos/{repository_id}/elements",
|
||||
params={"scope": "all", "type": "scopes", "entry_filter": "approved"},
|
||||
|
||||
@@ -115,7 +115,7 @@ Acceptance criteria:
|
||||
|
||||
```task
|
||||
id: RREG-WP-0008-T05
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: medium
|
||||
state_hub_task_id: "02147d64-d339-40e2-b88d-406567bfa366"
|
||||
```
|
||||
@@ -129,6 +129,30 @@ Acceptance criteria:
|
||||
abilities, and scope.
|
||||
- Evidence remains the bridge between observed facts and interpreted claims.
|
||||
|
||||
## Interactive Dependency Graph Visualization
|
||||
|
||||
```task
|
||||
id: RREG-WP-0008-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "a1f57c22-017b-43ef-99b4-99c10a6bccd5"
|
||||
```
|
||||
|
||||
Provide an interactive visualization of the dependency graph so users can inspect
|
||||
fact-to-scope propagation paths, same-layer normalization signals, stale nodes,
|
||||
and review recommendations.
|
||||
|
||||
Acceptance criteria:
|
||||
- Users can pan, zoom, and select dependency graph nodes.
|
||||
- Node styling distinguishes facts, evidence, features, capabilities, abilities,
|
||||
and the root scope.
|
||||
- Edge styling distinguishes dependency type, strength, and same-layer
|
||||
normalization signals.
|
||||
- Users can switch between full graph, impact-only, and selected-path views.
|
||||
- The implementation framework choice is documented with tradeoffs before UI
|
||||
implementation starts in
|
||||
`docs/adr-dependency-graph-visualization-framework.md`.
|
||||
|
||||
## Documentation And Terminology
|
||||
|
||||
```task
|
||||
|
||||
Reference in New Issue
Block a user