graph-dependency-analyzer 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
- graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
- graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
- graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
- graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/application/__init__.py +0 -0
- src/application/dtos/__init__.py +0 -0
- src/application/dtos/affected_component.py +59 -0
- src/application/dtos/batch_processing_result.py +17 -0
- src/application/dtos/impact_analysis_response.py +56 -0
- src/application/dtos/impact_query_request.py +38 -0
- src/application/dtos/index_batch_request.py +53 -0
- src/application/dtos/index_batch_response.py +72 -0
- src/application/dtos/index_result.py +20 -0
- src/application/dtos/repository_index_result.py +79 -0
- src/application/exceptions.py +41 -0
- src/application/services/__init__.py +0 -0
- src/application/services/file_scanner_service.py +258 -0
- src/application/services/progress_tracker.py +132 -0
- src/application/use_cases/__init__.py +0 -0
- src/application/use_cases/clear_all_data_use_case.py +180 -0
- src/application/use_cases/index_batch_use_case.py +153 -0
- src/application/use_cases/index_repository_use_case.py +683 -0
- src/application/use_cases/indexing_orchestrator.py +181 -0
- src/application/use_cases/list_repositories_use_case.py +84 -0
- src/cli.py +512 -0
- src/config/__init__.py +5 -0
- src/config/container.py +211 -0
- src/config/logging_config.py +123 -0
- src/config/settings.py +165 -0
- src/domain/__init__.py +0 -0
- src/domain/entities/__init__.py +20 -0
- src/domain/entities/ast_node.py +32 -0
- src/domain/entities/code_chunk.py +45 -0
- src/domain/entities/code_file.py +44 -0
- src/domain/entities/dependency_graph.py +110 -0
- src/domain/entities/repository.py +46 -0
- src/domain/exceptions.py +36 -0
- src/domain/repositories/__init__.py +22 -0
- src/domain/repositories/i_code_parser.py +50 -0
- src/domain/repositories/i_dependency_extractor.py +35 -0
- src/domain/repositories/i_embedding_provider.py +31 -0
- src/domain/repositories/i_graph_repository.py +78 -0
- src/domain/repositories/i_vector_repository.py +55 -0
- src/domain/services/__init__.py +8 -0
- src/domain/services/dependency_extractor.py +962 -0
- src/domain/services/semantic_chunk_builder.py +719 -0
- src/domain/value_objects/__init__.py +12 -0
- src/domain/value_objects/chunk_type.py +24 -0
- src/domain/value_objects/dependency_type.py +22 -0
- src/domain/value_objects/node_type.py +22 -0
- src/domain/value_objects/qualified_name.py +78 -0
- src/infrastructure/__init__.py +0 -0
- src/infrastructure/exceptions.py +41 -0
- src/infrastructure/external_apis/__init__.py +0 -0
- src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
- src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
- src/infrastructure/parsing/__init__.py +20 -0
- src/infrastructure/parsing/config_file_parser.py +347 -0
- src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
- src/infrastructure/parsing/groovy_parser.py +198 -0
- src/infrastructure/parsing/maven_dependency_parser.py +147 -0
- src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
- src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
- src/infrastructure/parsing/python_parser.py +211 -0
- src/infrastructure/parsing/sql_schema_parser.py +658 -0
- src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
- src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
- src/infrastructure/persistence/__init__.py +0 -0
- src/infrastructure/persistence/graph_export_repository.py +443 -0
- src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
- src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
- src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
- src/presentation/__init__.py +0 -0
- src/presentation/api/__init__.py +0 -0
- src/presentation/api/rest/__init__.py +0 -0
- src/presentation/api/rest/exception_handlers.py +174 -0
- src/presentation/api/rest/main.py +80 -0
- src/presentation/api/rest/routers/__init__.py +5 -0
- src/presentation/api/rest/routers/admin.py +103 -0
- src/presentation/api/rest/routers/analysis.py +980 -0
- src/presentation/api/rest/routers/export.py +442 -0
- src/presentation/api/rest/routers/health.py +117 -0
- src/presentation/api/rest/routers/indexing.py +148 -0
- src/presentation/api/rest/routers/relationships.py +1089 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Relationships Router
|
|
3
|
+
|
|
4
|
+
GET /api/analysis/repository-relationships
|
|
5
|
+
Discover and return all relationships between indexed repositories.
|
|
6
|
+
Uses 4-pass strategy: explicit → path-matching → semantic names → shared artifacts.
|
|
7
|
+
|
|
8
|
+
GET /api/analysis/repository-relationships/graph.html
|
|
9
|
+
Two-level interactive graph:
|
|
10
|
+
Level 1 (overview) — one node per repository, edges show cross-repo relationships
|
|
11
|
+
Level 2 (drill-down) — double-click a repo node to expand its internal graph
|
|
12
|
+
(classes, components, endpoints, APIs) with cross-repo edges highlighted
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
18
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
19
|
+
import structlog
|
|
20
|
+
|
|
21
|
+
from dependency_injector.wiring import Provide, inject
|
|
22
|
+
|
|
23
|
+
from src.config.container import Container
|
|
24
|
+
from src.infrastructure.persistence.repository_relationship_repository import (
|
|
25
|
+
RepositoryRelationshipRepository,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/api/analysis", tags=["relationships"])
|
|
31
|
+
|
|
32
|
+
_CONF_COLOURS = {
|
|
33
|
+
"HIGH": "#27AE60",
|
|
34
|
+
"MEDIUM": "#F39C12",
|
|
35
|
+
"LOW": "#E74C3C",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_RELATION_LABELS = {
|
|
39
|
+
"CALLS_BACKEND": "calls HTTP",
|
|
40
|
+
"CALLS_API": "calls API",
|
|
41
|
+
"USES_LIBRARY": "shared lib",
|
|
42
|
+
"DEPENDS_ON": "depends on",
|
|
43
|
+
"POSSIBLE_MATCH": "possible link",
|
|
44
|
+
"SHARED_DB": "shared DB",
|
|
45
|
+
"SHARED_EVENT": "shared event",
|
|
46
|
+
"USES_SERVICE": "uses service",
|
|
47
|
+
"USES_DB_SCHEMA": "uses DB schema",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# JSON endpoint
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@router.get(
|
|
56
|
+
"/repository-relationships",
|
|
57
|
+
status_code=status.HTTP_200_OK,
|
|
58
|
+
summary="Discover relationships between all indexed repositories",
|
|
59
|
+
description=(
|
|
60
|
+
"4-pass discovery:\n"
|
|
61
|
+
"1. Explicit (HIGH) — CALLS_BACKEND, USES_LIBRARY, DEPENDS_ON\n"
|
|
62
|
+
"2. Path matching (MEDIUM) — unmatched API calls fuzzy-matched to endpoints\n"
|
|
63
|
+
"3. Semantic names (LOW/MEDIUM) — shared domain concepts across class names\n"
|
|
64
|
+
"4. Shared artifacts (MEDIUM) — same DB table or event name\n\n"
|
|
65
|
+
"Every relationship includes `confidence`, `evidence`, and `details`."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
@inject
|
|
69
|
+
async def get_repository_relationships(
|
|
70
|
+
confidence: str | None = Query(
|
|
71
|
+
default=None,
|
|
72
|
+
description="Filter by minimum confidence: HIGH | MEDIUM | LOW",
|
|
73
|
+
pattern="^(HIGH|MEDIUM|LOW)$",
|
|
74
|
+
),
|
|
75
|
+
relation_type: str | None = Query(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Filter by relation type, e.g. CALLS_BACKEND",
|
|
78
|
+
),
|
|
79
|
+
repo: str | None = Query(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Only show relationships involving this repository",
|
|
82
|
+
),
|
|
83
|
+
relationship_repo: RepositoryRelationshipRepository = Depends(
|
|
84
|
+
Provide[Container.relationship_repository]
|
|
85
|
+
),
|
|
86
|
+
) -> JSONResponse:
|
|
87
|
+
logger.info("repository_relationships_requested", confidence=confidence, relation_type=relation_type, repo=repo)
|
|
88
|
+
try:
|
|
89
|
+
graph = await relationship_repo.discover_all()
|
|
90
|
+
rels = graph.relationships
|
|
91
|
+
|
|
92
|
+
if confidence:
|
|
93
|
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
94
|
+
max_order = order[confidence]
|
|
95
|
+
rels = [r for r in rels if order.get(r["confidence"], 99) <= max_order]
|
|
96
|
+
if relation_type:
|
|
97
|
+
rels = [r for r in rels if r["type"] == relation_type]
|
|
98
|
+
if repo:
|
|
99
|
+
rels = [r for r in rels if r["from"] == repo or r["to"] == repo]
|
|
100
|
+
|
|
101
|
+
return JSONResponse(content={
|
|
102
|
+
"repositories": graph.repositories,
|
|
103
|
+
"relationships": rels,
|
|
104
|
+
"meta": {
|
|
105
|
+
**graph.meta,
|
|
106
|
+
"filtered_count": len(rels),
|
|
107
|
+
"filters_applied": {"confidence": confidence, "relation_type": relation_type, "repo": repo},
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error("repository_relationships_failed", error=str(e))
|
|
112
|
+
raise HTTPException(status_code=500, detail=f"Repository relationship discovery failed: {e}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Two-level interactive HTML graph
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
@router.get(
|
|
120
|
+
"/repository-relationships/graph.html",
|
|
121
|
+
status_code=status.HTTP_200_OK,
|
|
122
|
+
response_class=HTMLResponse,
|
|
123
|
+
summary="Two-level interactive repository relationship graph",
|
|
124
|
+
description=(
|
|
125
|
+
"Level 1 — overview: one node per repository, edges = cross-repo relationships. "
|
|
126
|
+
"Level 2 — drill-down: double-click a repository node to expand its internal "
|
|
127
|
+
"dependency graph (classes, components, endpoints, APIs). "
|
|
128
|
+
"Cross-repo edges are highlighted in the drill-down view. "
|
|
129
|
+
"Press Esc or the Back button to return to Level 1."
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
@inject
|
|
133
|
+
async def get_repository_relationships_html(
|
|
134
|
+
confidence: str | None = Query(
|
|
135
|
+
default=None,
|
|
136
|
+
description="Filter by minimum confidence: HIGH | MEDIUM | LOW",
|
|
137
|
+
pattern="^(HIGH|MEDIUM|LOW)$",
|
|
138
|
+
),
|
|
139
|
+
relationship_repo: RepositoryRelationshipRepository = Depends(
|
|
140
|
+
Provide[Container.relationship_repository]
|
|
141
|
+
),
|
|
142
|
+
) -> HTMLResponse:
|
|
143
|
+
try:
|
|
144
|
+
graph = await relationship_repo.discover_all()
|
|
145
|
+
rels = graph.relationships
|
|
146
|
+
if confidence:
|
|
147
|
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
148
|
+
max_order = order[confidence]
|
|
149
|
+
rels = [r for r in rels if order.get(r["confidence"], 99) <= max_order]
|
|
150
|
+
|
|
151
|
+
html = _build_two_level_html(graph.repositories, rels, graph.meta, graph.external_services)
|
|
152
|
+
return HTMLResponse(content=html)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error("repository_relationships_html_failed", error=str(e))
|
|
155
|
+
raise HTTPException(status_code=500, detail=f"Repository graph HTML failed: {e}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# HTML builder — two-level graph
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def _build_two_level_html(
|
|
163
|
+
repos: list[str],
|
|
164
|
+
relationships: list[dict[str, Any]],
|
|
165
|
+
meta: dict[str, Any],
|
|
166
|
+
external_services: list[dict[str, Any]] | None = None,
|
|
167
|
+
) -> str:
|
|
168
|
+
# Colours per external service category
|
|
169
|
+
_EXT_CATEGORY_COLOURS = {
|
|
170
|
+
"ai": {"bg": "#7B2FBE", "border": "#9B59B6"}, # purple
|
|
171
|
+
"azure": {"bg": "#0078D4", "border": "#106EBE"}, # azure blue
|
|
172
|
+
"database": {"bg": "#C0392B", "border": "#E74C3C"}, # red
|
|
173
|
+
"messaging": {"bg": "#16A085", "border": "#1ABC9C"}, # teal
|
|
174
|
+
"analytics": {"bg": "#D35400", "border": "#E67E22"}, # orange
|
|
175
|
+
"observability":{"bg": "#2C3E50","border": "#34495E"}, # dark
|
|
176
|
+
"auth": {"bg": "#27AE60", "border": "#2ECC71"}, # green
|
|
177
|
+
"cloud": {"bg": "#2980B9", "border": "#3498DB"}, # light blue
|
|
178
|
+
"saas": {"bg": "#8E44AD", "border": "#9B59B6"}, # violet
|
|
179
|
+
"external": {"bg": "#7F8C8D", "border": "#95A5A6"}, # grey
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
out_degree: dict[str, int] = {}
|
|
183
|
+
in_degree: dict[str, int] = {}
|
|
184
|
+
for rel in relationships:
|
|
185
|
+
out_degree[rel["from"]] = out_degree.get(rel["from"], 0) + 1
|
|
186
|
+
in_degree[rel["to"]] = in_degree.get(rel["to"], 0) + 1
|
|
187
|
+
|
|
188
|
+
# Level-1 nodes — repositories
|
|
189
|
+
l1_nodes = []
|
|
190
|
+
for repo in repos:
|
|
191
|
+
out_d = out_degree.get(repo, 0)
|
|
192
|
+
in_d = in_degree.get(repo, 0)
|
|
193
|
+
size = max(18, min(55, 18 + (out_d + in_d) * 4))
|
|
194
|
+
l1_nodes.append({
|
|
195
|
+
"id": repo,
|
|
196
|
+
"label": repo,
|
|
197
|
+
"title": (
|
|
198
|
+
f"<b>{repo}</b><br/>"
|
|
199
|
+
f"outgoing: {out_d} · incoming: {in_d}<br/>"
|
|
200
|
+
f"<i>Double-click to drill down</i>"
|
|
201
|
+
),
|
|
202
|
+
"size": size,
|
|
203
|
+
"color": {
|
|
204
|
+
"background": "#4A90D9",
|
|
205
|
+
"border": "#2C3E50",
|
|
206
|
+
"highlight": {"background": "#e94560", "border": "#fff"},
|
|
207
|
+
"hover": {"background": "#74B3E8", "border": "#fff"},
|
|
208
|
+
},
|
|
209
|
+
"font": {"color": "#eee", "size": 14, "bold": True},
|
|
210
|
+
"shape": "dot",
|
|
211
|
+
"_node_type": "REPOSITORY",
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
# Level-1 external service nodes
|
|
215
|
+
ext_svcs = external_services or []
|
|
216
|
+
seen_ext_ids: set[str] = set()
|
|
217
|
+
for svc in ext_svcs:
|
|
218
|
+
svc_id = svc["id"]
|
|
219
|
+
if svc_id in seen_ext_ids:
|
|
220
|
+
continue
|
|
221
|
+
seen_ext_ids.add(svc_id)
|
|
222
|
+
cat = svc.get("category", "external")
|
|
223
|
+
colours = _EXT_CATEGORY_COLOURS.get(cat, _EXT_CATEGORY_COLOURS["external"])
|
|
224
|
+
# Count how many repos use this service
|
|
225
|
+
usage_count = sum(
|
|
226
|
+
1 for r in relationships
|
|
227
|
+
if r.get("to") == svc_id and r.get("type") == "USES_SERVICE"
|
|
228
|
+
)
|
|
229
|
+
l1_nodes.append({
|
|
230
|
+
"id": svc_id,
|
|
231
|
+
"label": svc["label"],
|
|
232
|
+
"title": (
|
|
233
|
+
f"<b>{svc['label']}</b><br/>"
|
|
234
|
+
f"category: {cat}<br/>"
|
|
235
|
+
f"used by {usage_count} repo(s)"
|
|
236
|
+
),
|
|
237
|
+
"size": max(14, min(40, 14 + usage_count * 5)),
|
|
238
|
+
"color": {
|
|
239
|
+
"background": colours["bg"],
|
|
240
|
+
"border": colours["border"],
|
|
241
|
+
"highlight": {"background": "#e94560", "border": "#fff"},
|
|
242
|
+
"hover": {"background": "#fff", "border": colours["border"]},
|
|
243
|
+
},
|
|
244
|
+
"font": {"color": "#eee", "size": 11},
|
|
245
|
+
"shape": "diamond",
|
|
246
|
+
"_node_type": "EXTERNAL_SERVICE",
|
|
247
|
+
"_category": cat,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
# Level-1 edges — cross-repo + repo→service relationships
|
|
251
|
+
l1_edges = []
|
|
252
|
+
for i, rel in enumerate(relationships):
|
|
253
|
+
is_service = rel.get("type") == "USES_SERVICE"
|
|
254
|
+
colour = _CONF_COLOURS.get(rel["confidence"], "#888")
|
|
255
|
+
if is_service:
|
|
256
|
+
# Use category colour of the service node
|
|
257
|
+
svc_id = rel.get("to", "")
|
|
258
|
+
svc_obj = next((s for s in ext_svcs if s["id"] == svc_id), None)
|
|
259
|
+
cat = svc_obj.get("category", "external") if svc_obj else "external"
|
|
260
|
+
colour = _EXT_CATEGORY_COLOURS.get(cat, _EXT_CATEGORY_COLOURS["external"])["bg"]
|
|
261
|
+
label = _RELATION_LABELS.get(rel["type"], rel["type"])
|
|
262
|
+
evidence_html = "<br/>".join(rel.get("evidence", [])[:4])
|
|
263
|
+
l1_edges.append({
|
|
264
|
+
"id": i,
|
|
265
|
+
"from": rel["from"],
|
|
266
|
+
"to": rel["to"],
|
|
267
|
+
"label": label,
|
|
268
|
+
"title": (
|
|
269
|
+
f"<b>{label}</b> <span style='color:{colour}'>[{rel['confidence']}]</span>"
|
|
270
|
+
f"<br/>{evidence_html}"
|
|
271
|
+
),
|
|
272
|
+
"color": {"color": colour, "highlight": "#e94560", "hover": "#fff"},
|
|
273
|
+
"font": {"color": colour, "size": 10, "align": "middle"},
|
|
274
|
+
"arrows": "to",
|
|
275
|
+
"width": 2 if rel["confidence"] == "HIGH" else 1,
|
|
276
|
+
"dashes": rel["confidence"] == "LOW",
|
|
277
|
+
"_confidence": rel["confidence"],
|
|
278
|
+
"_type": rel["type"],
|
|
279
|
+
"_evidence": rel.get("evidence", []),
|
|
280
|
+
"_details": rel.get("details", [])[:5],
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
total_repos = meta.get("total_repos", len(repos))
|
|
284
|
+
explicit = meta.get("explicit", 0)
|
|
285
|
+
inferred = meta.get("path_inferred", 0) + meta.get("semantic_inferred", 0)
|
|
286
|
+
shared = meta.get("shared_artifact", 0)
|
|
287
|
+
ext_count = len(seen_ext_ids)
|
|
288
|
+
|
|
289
|
+
l1_nodes_json = json.dumps(l1_nodes)
|
|
290
|
+
l1_edges_json = json.dumps(l1_edges)
|
|
291
|
+
conf_colours_json = json.dumps(_CONF_COLOURS)
|
|
292
|
+
|
|
293
|
+
return f"""<!DOCTYPE html>
|
|
294
|
+
<html lang="en">
|
|
295
|
+
<head>
|
|
296
|
+
<meta charset="UTF-8"/>
|
|
297
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
298
|
+
<title>Repository Dependency Explorer</title>
|
|
299
|
+
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
|
300
|
+
<style>
|
|
301
|
+
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
302
|
+
body {{
|
|
303
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
304
|
+
background: #0d1117; color: #c9d1d9;
|
|
305
|
+
height: 100vh; display: flex; flex-direction: column; overflow: hidden;
|
|
306
|
+
}}
|
|
307
|
+
|
|
308
|
+
/* ── header ── */
|
|
309
|
+
#header {{
|
|
310
|
+
background: #161b22; padding: 10px 20px;
|
|
311
|
+
display: flex; align-items: center; gap: 12px;
|
|
312
|
+
border-bottom: 1px solid #30363d; flex-shrink: 0; min-height: 48px;
|
|
313
|
+
}}
|
|
314
|
+
#header h1 {{ font-size: 15px; font-weight: 700; color: #58a6ff; white-space: nowrap; }}
|
|
315
|
+
#breadcrumb {{
|
|
316
|
+
display: flex; align-items: center; gap: 6px;
|
|
317
|
+
font-size: 13px; color: #8b949e; flex: 1;
|
|
318
|
+
}}
|
|
319
|
+
#breadcrumb .crumb {{ color: #58a6ff; cursor: pointer; }}
|
|
320
|
+
#breadcrumb .crumb:hover {{ text-decoration: underline; }}
|
|
321
|
+
#breadcrumb .sep {{ color: #30363d; }}
|
|
322
|
+
#back-btn {{
|
|
323
|
+
display: none; background: #21262d; color: #c9d1d9;
|
|
324
|
+
border: 1px solid #30363d; padding: 5px 14px; border-radius: 6px;
|
|
325
|
+
cursor: pointer; font-size: 13px; transition: background .15s;
|
|
326
|
+
}}
|
|
327
|
+
#back-btn:hover {{ background: #30363d; }}
|
|
328
|
+
#header .meta {{ font-size: 11px; color: #8b949e; margin-left: auto; white-space: nowrap; }}
|
|
329
|
+
|
|
330
|
+
/* ── toolbar ── */
|
|
331
|
+
#toolbar {{
|
|
332
|
+
background: #161b22; padding: 7px 20px;
|
|
333
|
+
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
|
334
|
+
border-bottom: 1px solid #30363d; flex-shrink: 0;
|
|
335
|
+
}}
|
|
336
|
+
#toolbar input {{
|
|
337
|
+
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
|
338
|
+
padding: 5px 10px; border-radius: 6px; font-size: 13px; width: 190px;
|
|
339
|
+
}}
|
|
340
|
+
#toolbar input:focus {{ outline: none; border-color: #58a6ff; }}
|
|
341
|
+
#toolbar button {{
|
|
342
|
+
background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
|
|
343
|
+
padding: 5px 13px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
|
344
|
+
}}
|
|
345
|
+
#toolbar button:hover {{ background: #30363d; }}
|
|
346
|
+
#toolbar button.primary {{
|
|
347
|
+
background: #1f6feb; border-color: #1f6feb; color: #fff;
|
|
348
|
+
}}
|
|
349
|
+
#toolbar button.primary:hover {{ background: #388bfd; }}
|
|
350
|
+
#toolbar select {{
|
|
351
|
+
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
|
352
|
+
padding: 5px 8px; border-radius: 6px; font-size: 13px;
|
|
353
|
+
}}
|
|
354
|
+
#level-badge {{
|
|
355
|
+
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
|
356
|
+
font-weight: 600; letter-spacing: .5px;
|
|
357
|
+
}}
|
|
358
|
+
.badge-l1 {{ background: #1f6feb; color: #fff; }}
|
|
359
|
+
.badge-l2 {{ background: #3d2b00; color: #e3b341; border: 1px solid #9e6a03; }}
|
|
360
|
+
#legend {{
|
|
361
|
+
display: flex; gap: 12px; align-items: center; margin-left: auto;
|
|
362
|
+
}}
|
|
363
|
+
.leg {{ display: flex; align-items: center; gap: 5px; font-size: 11px; color: #8b949e; }}
|
|
364
|
+
.leg-dot {{ width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }}
|
|
365
|
+
|
|
366
|
+
/* ── main ── */
|
|
367
|
+
#main {{ display: flex; flex: 1; overflow: hidden; }}
|
|
368
|
+
#canvas {{ flex: 1; position: relative; }}
|
|
369
|
+
#network {{ width: 100%; height: 100%; }}
|
|
370
|
+
|
|
371
|
+
/* drill-down hint overlay */
|
|
372
|
+
#hint {{
|
|
373
|
+
position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
|
|
374
|
+
background: rgba(22,27,34,.85); border: 1px solid #30363d;
|
|
375
|
+
padding: 6px 14px; border-radius: 20px; font-size: 12px; color: #8b949e;
|
|
376
|
+
pointer-events: none; transition: opacity .3s;
|
|
377
|
+
}}
|
|
378
|
+
|
|
379
|
+
/* ── sidebar ── */
|
|
380
|
+
#sidebar {{
|
|
381
|
+
width: 310px; background: #161b22; border-left: 1px solid #30363d;
|
|
382
|
+
display: flex; flex-direction: column; flex-shrink: 0;
|
|
383
|
+
}}
|
|
384
|
+
#sidebar-header {{
|
|
385
|
+
padding: 12px 16px 8px; border-bottom: 1px solid #30363d; flex-shrink: 0;
|
|
386
|
+
}}
|
|
387
|
+
#sidebar-header h2 {{ font-size: 13px; font-weight: 600; color: #58a6ff; }}
|
|
388
|
+
#sidebar-body {{ flex: 1; overflow-y: auto; padding: 12px; }}
|
|
389
|
+
.card {{
|
|
390
|
+
background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
|
|
391
|
+
padding: 10px 12px; margin-bottom: 10px; font-size: 12px;
|
|
392
|
+
}}
|
|
393
|
+
.card-title {{ font-weight: 600; color: #c9d1d9; margin-bottom: 6px; font-size: 13px; }}
|
|
394
|
+
.card-row {{ display: flex; justify-content: space-between; margin-bottom: 3px; }}
|
|
395
|
+
.card-label {{ color: #8b949e; }}
|
|
396
|
+
.card-val {{ color: #c9d1d9; text-align: right; max-width: 170px; word-break: break-all; }}
|
|
397
|
+
.badge {{
|
|
398
|
+
display: inline-block; font-size: 10px; font-weight: 700;
|
|
399
|
+
padding: 1px 6px; border-radius: 4px; letter-spacing: .4px;
|
|
400
|
+
}}
|
|
401
|
+
.badge-HIGH {{ background: #1a4731; color: #3fb950; border: 1px solid #238636; }}
|
|
402
|
+
.badge-MEDIUM {{ background: #3d2b00; color: #e3b341; border: 1px solid #9e6a03; }}
|
|
403
|
+
.badge-LOW {{ background: #3d0f0f; color: #f85149; border: 1px solid #da3633; }}
|
|
404
|
+
.badge-code {{ background: #1f3d6e; color: #58a6ff; border: 1px solid #1f6feb; }}
|
|
405
|
+
.badge-mod {{ background: #1a4731; color: #3fb950; border: 1px solid #238636; }}
|
|
406
|
+
.badge-api {{ background: #3d2b00; color: #e3b341; border: 1px solid #9e6a03; }}
|
|
407
|
+
.ev-list {{ margin-top: 6px; }}
|
|
408
|
+
.ev-item {{ color: #8b949e; padding: 2px 0; border-top: 1px solid #21262d; font-size: 11px; }}
|
|
409
|
+
.empty {{ color: #484f58; font-style: italic; font-size: 12px; }}
|
|
410
|
+
.drill-hint {{
|
|
411
|
+
background: #1f2937; border: 1px solid #1f6feb;
|
|
412
|
+
border-radius: 6px; padding: 8px 10px; margin-bottom: 10px;
|
|
413
|
+
font-size: 11px; color: #58a6ff;
|
|
414
|
+
}}
|
|
415
|
+
|
|
416
|
+
/* ── status bar ── */
|
|
417
|
+
#statusbar {{
|
|
418
|
+
padding: 4px 20px; background: #161b22; border-top: 1px solid #30363d;
|
|
419
|
+
font-size: 11px; color: #484f58; flex-shrink: 0;
|
|
420
|
+
}}
|
|
421
|
+
</style>
|
|
422
|
+
</head>
|
|
423
|
+
<body>
|
|
424
|
+
|
|
425
|
+
<div id="header">
|
|
426
|
+
<h1>☍ Dependency Explorer</h1>
|
|
427
|
+
<div id="breadcrumb">
|
|
428
|
+
<span class="crumb" onclick="goLevel1()">All Repositories</span>
|
|
429
|
+
<span class="sep" id="bc-sep" style="display:none">›</span>
|
|
430
|
+
<span id="bc-repo" style="display:none; color:#e3b341"></span>
|
|
431
|
+
</div>
|
|
432
|
+
<button id="back-btn" onclick="goLevel1()">← Back to overview</button>
|
|
433
|
+
<span class="meta" id="header-meta">
|
|
434
|
+
{total_repos} repos ·
|
|
435
|
+
{ext_count} external services ·
|
|
436
|
+
{len(relationships)} relationships ·
|
|
437
|
+
{explicit} explicit ·
|
|
438
|
+
{inferred} inferred
|
|
439
|
+
</span>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<div id="toolbar">
|
|
443
|
+
<span id="level-badge" class="level-badge badge-l1">LEVEL 1 — Overview</span>
|
|
444
|
+
<input id="search" type="text" placeholder="Search…"/>
|
|
445
|
+
<button class="primary" onclick="doSearch()">Find</button>
|
|
446
|
+
<button onclick="resetView()">Reset view</button>
|
|
447
|
+
<select id="confFilter" onchange="applyFilter()">
|
|
448
|
+
<option value="">All confidence</option>
|
|
449
|
+
<option value="HIGH">HIGH only</option>
|
|
450
|
+
<option value="MEDIUM">HIGH + MEDIUM</option>
|
|
451
|
+
</select>
|
|
452
|
+
<select id="typeFilter" onchange="applyFilter()">
|
|
453
|
+
<option value="">All types</option>
|
|
454
|
+
<option value="calls HTTP">calls HTTP</option>
|
|
455
|
+
<option value="calls API">calls API</option>
|
|
456
|
+
<option value="shared lib">shared lib</option>
|
|
457
|
+
<option value="depends on">depends on</option>
|
|
458
|
+
<option value="possible link">possible link</option>
|
|
459
|
+
<option value="shared DB">shared DB</option>
|
|
460
|
+
<option value="shared event">shared event</option>
|
|
461
|
+
<option value="uses service">uses service</option>
|
|
462
|
+
<option value="uses DB schema">uses DB schema</option>
|
|
463
|
+
</select>
|
|
464
|
+
<div id="legend">
|
|
465
|
+
<div class="leg"><div class="leg-dot" style="background:#27AE60"></div>HIGH</div>
|
|
466
|
+
<div class="leg"><div class="leg-dot" style="background:#F39C12"></div>MEDIUM</div>
|
|
467
|
+
<div class="leg"><div class="leg-dot" style="background:#E74C3C"></div>LOW</div>
|
|
468
|
+
<div class="leg"><div class="leg-dot" style="background:#4A90D9"></div>Repository</div>
|
|
469
|
+
<div class="leg"><div class="leg-dot" style="background:#74B3E8"></div>Class/Component</div>
|
|
470
|
+
<div class="leg"><div class="leg-dot" style="background:#E8A838"></div>API/Endpoint</div>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div id="main">
|
|
475
|
+
<div id="canvas">
|
|
476
|
+
<div id="network"></div>
|
|
477
|
+
<div id="hint">Double-click a repository to drill down ↓</div>
|
|
478
|
+
</div>
|
|
479
|
+
<div id="sidebar">
|
|
480
|
+
<div id="sidebar-header"><h2 id="sidebar-title">Details</h2></div>
|
|
481
|
+
<div id="sidebar-body">
|
|
482
|
+
<p class="empty">Click a node or edge to inspect it.</p>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<div id="statusbar" id="statusBar">Loading…</div>
|
|
488
|
+
|
|
489
|
+
<script>
|
|
490
|
+
// ── Data from server ──────────────────────────────────────────────────────
|
|
491
|
+
const L1_NODES = {l1_nodes_json};
|
|
492
|
+
const L1_EDGES = {l1_edges_json};
|
|
493
|
+
const CONF_COLOURS = {conf_colours_json};
|
|
494
|
+
|
|
495
|
+
const CONF_ORDER = {{HIGH:0, MEDIUM:1, LOW:2}};
|
|
496
|
+
|
|
497
|
+
// ── State ─────────────────────────────────────────────────────────────────
|
|
498
|
+
let network = null;
|
|
499
|
+
let visNodes = null;
|
|
500
|
+
let visEdges = null;
|
|
501
|
+
let currentLevel = 1;
|
|
502
|
+
let currentRepo = null;
|
|
503
|
+
|
|
504
|
+
// ── Level-1 helpers ───────────────────────────────────────────────────────
|
|
505
|
+
function l1FilteredData() {{
|
|
506
|
+
const confF = document.getElementById('confFilter').value;
|
|
507
|
+
const typeF = document.getElementById('typeFilter').value;
|
|
508
|
+
const maxOrder = confF === 'HIGH' ? 0 : confF === 'MEDIUM' ? 1 : 99;
|
|
509
|
+
|
|
510
|
+
const edges = L1_EDGES.filter(e => {{
|
|
511
|
+
if ((CONF_ORDER[e._confidence] ?? 2) > maxOrder) return false;
|
|
512
|
+
if (typeF && e.label !== typeF) return false;
|
|
513
|
+
return true;
|
|
514
|
+
}});
|
|
515
|
+
|
|
516
|
+
const nodeIds = new Set();
|
|
517
|
+
edges.forEach(e => {{ nodeIds.add(e.from); nodeIds.add(e.to); }});
|
|
518
|
+
// always show isolated repos too
|
|
519
|
+
L1_NODES.forEach(n => nodeIds.add(n.id));
|
|
520
|
+
const nodes = L1_NODES.filter(n => nodeIds.has(n.id));
|
|
521
|
+
return {{nodes, edges}};
|
|
522
|
+
}}
|
|
523
|
+
|
|
524
|
+
// ── Init Level-1 ─────────────────────────────────────────────────────────
|
|
525
|
+
function initLevel1() {{
|
|
526
|
+
currentLevel = 1;
|
|
527
|
+
currentRepo = null;
|
|
528
|
+
|
|
529
|
+
document.getElementById('level-badge').textContent = 'LEVEL 1 — Overview';
|
|
530
|
+
document.getElementById('level-badge').className = 'level-badge badge-l1';
|
|
531
|
+
document.getElementById('back-btn').style.display = 'none';
|
|
532
|
+
document.getElementById('bc-sep').style.display = 'none';
|
|
533
|
+
document.getElementById('bc-repo').style.display = 'none';
|
|
534
|
+
document.getElementById('hint').style.opacity = '1';
|
|
535
|
+
document.getElementById('confFilter').style.display = '';
|
|
536
|
+
document.getElementById('typeFilter').style.display = '';
|
|
537
|
+
|
|
538
|
+
const {{nodes, edges}} = l1FilteredData();
|
|
539
|
+
visNodes = new vis.DataSet(nodes);
|
|
540
|
+
visEdges = new vis.DataSet(edges);
|
|
541
|
+
|
|
542
|
+
if (network) network.destroy();
|
|
543
|
+
network = new vis.Network(
|
|
544
|
+
document.getElementById('network'),
|
|
545
|
+
{{nodes: visNodes, edges: visEdges}},
|
|
546
|
+
{{
|
|
547
|
+
physics: {{
|
|
548
|
+
enabled: true,
|
|
549
|
+
solver: 'forceAtlas2Based',
|
|
550
|
+
forceAtlas2Based: {{
|
|
551
|
+
gravitationalConstant: -120,
|
|
552
|
+
centralGravity: 0.005,
|
|
553
|
+
springLength: 200,
|
|
554
|
+
springConstant: 0.04,
|
|
555
|
+
damping: 0.9,
|
|
556
|
+
}},
|
|
557
|
+
stabilization: {{iterations: 400, updateInterval: 25}},
|
|
558
|
+
}},
|
|
559
|
+
interaction: {{
|
|
560
|
+
hover: true, tooltipDelay: 80,
|
|
561
|
+
navigationButtons: true, keyboard: true,
|
|
562
|
+
multiselect: false,
|
|
563
|
+
}},
|
|
564
|
+
layout: {{improvedLayout: true}},
|
|
565
|
+
}}
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
network.on('click', params => onL1Click(params));
|
|
569
|
+
network.on('doubleClick', params => onL1DoubleClick(params));
|
|
570
|
+
network.on('stabilizationIterationsDone', () => {{
|
|
571
|
+
network.setOptions({{physics: false}});
|
|
572
|
+
setStatus(nodes.length + ' repositories · ' + edges.length + ' relationships · stabilized');
|
|
573
|
+
}});
|
|
574
|
+
setStatus(nodes.length + ' repositories · ' + edges.length + ' relationships · stabilizing…');
|
|
575
|
+
setSidebar('Details', '<p class="empty">Click a node or edge to inspect it.</p>');
|
|
576
|
+
}}
|
|
577
|
+
|
|
578
|
+
function onL1Click(params) {{
|
|
579
|
+
if (params.nodes.length > 0) {{
|
|
580
|
+
const node = visNodes.get(params.nodes[0]);
|
|
581
|
+
if (node && node._node_type === 'EXTERNAL_SERVICE') showExtServiceCard(node);
|
|
582
|
+
else showRepoCard(params.nodes[0]);
|
|
583
|
+
}} else if (params.edges.length > 0) showEdgeCard(params.edges[0]);
|
|
584
|
+
else setSidebar('Details', '<p class="empty">Click a node or edge.</p>');
|
|
585
|
+
}}
|
|
586
|
+
|
|
587
|
+
function onL1DoubleClick(params) {{
|
|
588
|
+
if (params.nodes.length === 0) return;
|
|
589
|
+
const node = visNodes.get(params.nodes[0]);
|
|
590
|
+
// External service nodes don't have an internal graph — skip drill-down
|
|
591
|
+
if (node && node._node_type === 'EXTERNAL_SERVICE') return;
|
|
592
|
+
drillDown(params.nodes[0]);
|
|
593
|
+
}}
|
|
594
|
+
|
|
595
|
+
function showRepoCard(repoId) {{
|
|
596
|
+
const edges = L1_EDGES.filter(e => e.from === repoId || e.to === repoId);
|
|
597
|
+
const outEdges = edges.filter(e => e.from === repoId);
|
|
598
|
+
const inEdges = edges.filter(e => e.to === repoId);
|
|
599
|
+
|
|
600
|
+
let html = `
|
|
601
|
+
<div class="card">
|
|
602
|
+
<div class="card-title">📦 ${{repoId}}</div>
|
|
603
|
+
<div class="card-row"><span class="card-label">Outgoing calls</span><span class="card-val">${{outEdges.length}}</span></div>
|
|
604
|
+
<div class="card-row"><span class="card-label">Incoming calls</span><span class="card-val">${{inEdges.length}}</span></div>
|
|
605
|
+
</div>
|
|
606
|
+
<div class="drill-hint">← Double-click this node to explore its internal dependency graph</div>
|
|
607
|
+
`;
|
|
608
|
+
|
|
609
|
+
if (outEdges.length) {{
|
|
610
|
+
html += '<div class="card"><div class="card-title">Calls →</div>';
|
|
611
|
+
outEdges.slice(0,10).forEach(e => {{
|
|
612
|
+
html += `<div class="card-row">
|
|
613
|
+
<span class="card-label">${{e.to}}</span>
|
|
614
|
+
<span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span>
|
|
615
|
+
</div>
|
|
616
|
+
<div style="font-size:11px;color:#8b949e;margin-bottom:5px">${{e._evidence.slice(0,2).join('<br/>')}}</div>`;
|
|
617
|
+
}});
|
|
618
|
+
html += '</div>';
|
|
619
|
+
}}
|
|
620
|
+
if (inEdges.length) {{
|
|
621
|
+
html += '<div class="card"><div class="card-title">← Called by</div>';
|
|
622
|
+
inEdges.slice(0,10).forEach(e => {{
|
|
623
|
+
html += `<div class="card-row">
|
|
624
|
+
<span class="card-label">${{e.from}}</span>
|
|
625
|
+
<span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span>
|
|
626
|
+
</div>`;
|
|
627
|
+
}});
|
|
628
|
+
html += '</div>';
|
|
629
|
+
}}
|
|
630
|
+
setSidebar(repoId, html);
|
|
631
|
+
}}
|
|
632
|
+
|
|
633
|
+
function showEdgeCard(edgeId) {{
|
|
634
|
+
const edge = visEdges.get(edgeId);
|
|
635
|
+
if (!edge) return;
|
|
636
|
+
let html = `
|
|
637
|
+
<div class="card">
|
|
638
|
+
<div class="card-title">${{edge.from}} → ${{edge.to}}</div>
|
|
639
|
+
<div class="card-row"><span class="card-label">Type</span><span class="card-val">${{edge.label}}</span></div>
|
|
640
|
+
<div class="card-row"><span class="card-label">Confidence</span>
|
|
641
|
+
<span class="card-val"><span class="badge badge-${{edge._confidence}}">${{edge._confidence}}</span></span>
|
|
642
|
+
</div>
|
|
643
|
+
</div>`;
|
|
644
|
+
if (edge._evidence && edge._evidence.length) {{
|
|
645
|
+
html += '<div class="card"><div class="card-title">Evidence</div><div class="ev-list">';
|
|
646
|
+
edge._evidence.forEach(ev => {{ html += `<div class="ev-item">${{ev}}</div>`; }});
|
|
647
|
+
html += '</div></div>';
|
|
648
|
+
}}
|
|
649
|
+
if (edge._details && edge._details.length) {{
|
|
650
|
+
html += '<div class="card"><div class="card-title">Details</div>';
|
|
651
|
+
edge._details.slice(0,5).forEach(d => {{
|
|
652
|
+
const kv = Object.entries(d).slice(0,4);
|
|
653
|
+
kv.forEach(([k,v]) => {{
|
|
654
|
+
html += `<div class="card-row"><span class="card-label">${{k}}</span><span class="card-val">${{v}}</span></div>`;
|
|
655
|
+
}});
|
|
656
|
+
}});
|
|
657
|
+
html += '</div>';
|
|
658
|
+
}}
|
|
659
|
+
setSidebar(`${{edge.from}} → ${{edge.to}}`, html);
|
|
660
|
+
}}
|
|
661
|
+
|
|
662
|
+
function showExtServiceCard(node) {{
|
|
663
|
+
const svcId = node.id;
|
|
664
|
+
const cat = node._category || 'external';
|
|
665
|
+
// Find all repos that use this service
|
|
666
|
+
const callers = L1_EDGES.filter(e => e.to === svcId && e._type === 'USES_SERVICE');
|
|
667
|
+
const catIcons = {{
|
|
668
|
+
ai: '🤖', azure: '☁️', database: '🗄️', messaging: '📨',
|
|
669
|
+
analytics: '📊', observability: '📡', auth: '🔐', cloud: '☁️', saas: '🔌',
|
|
670
|
+
}};
|
|
671
|
+
const icon = catIcons[cat] || '🔌';
|
|
672
|
+
let html = `<div class="card">
|
|
673
|
+
<div class="card-title">${{icon}} ${{node.label}}</div>
|
|
674
|
+
<div class="card-row"><span class="card-label">Category</span>
|
|
675
|
+
<span class="card-val"><span class="badge badge-code">${{cat}}</span></span></div>
|
|
676
|
+
<div class="card-row"><span class="card-label">Used by</span>
|
|
677
|
+
<span class="card-val">${{callers.length}} repo(s)</span></div>
|
|
678
|
+
</div>`;
|
|
679
|
+
if (callers.length) {{
|
|
680
|
+
html += '<div class="card"><div class="card-title">Consumers</div>';
|
|
681
|
+
callers.forEach(e => {{
|
|
682
|
+
html += `<div class="card-row">
|
|
683
|
+
<span class="card-label">${{e.from}}</span>
|
|
684
|
+
<span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span>
|
|
685
|
+
</div>
|
|
686
|
+
<div style="font-size:11px;color:#8b949e;margin-bottom:5px">
|
|
687
|
+
${{(e._evidence||[]).slice(0,2).join('<br/>')}}
|
|
688
|
+
</div>`;
|
|
689
|
+
}});
|
|
690
|
+
html += '</div>';
|
|
691
|
+
}}
|
|
692
|
+
setSidebar(node.label, html);
|
|
693
|
+
}}
|
|
694
|
+
|
|
695
|
+
// ── Level-2 drill-down ────────────────────────────────────────────────────
|
|
696
|
+
async function drillDown(repoId) {{
|
|
697
|
+
currentLevel = 2;
|
|
698
|
+
currentRepo = repoId;
|
|
699
|
+
|
|
700
|
+
document.getElementById('level-badge').textContent = 'LEVEL 2 — ' + repoId;
|
|
701
|
+
document.getElementById('level-badge').className = 'level-badge badge-l2';
|
|
702
|
+
document.getElementById('back-btn').style.display = '';
|
|
703
|
+
document.getElementById('bc-sep').style.display = '';
|
|
704
|
+
document.getElementById('bc-repo').style.display = '';
|
|
705
|
+
document.getElementById('bc-repo').textContent = repoId;
|
|
706
|
+
document.getElementById('hint').style.opacity = '0';
|
|
707
|
+
document.getElementById('confFilter').style.display = 'none';
|
|
708
|
+
document.getElementById('typeFilter').style.display = 'none';
|
|
709
|
+
|
|
710
|
+
setStatus('Loading internal graph for ' + repoId + '…');
|
|
711
|
+
setSidebar(repoId, '<p class="empty">Loading…</p>');
|
|
712
|
+
|
|
713
|
+
let graphData = null;
|
|
714
|
+
try {{
|
|
715
|
+
const resp = await fetch('/api/export/graph.json?repository=' + encodeURIComponent(repoId) + '&group=true&max_nodes=200');
|
|
716
|
+
if (resp.ok) graphData = await resp.json();
|
|
717
|
+
}} catch(err) {{
|
|
718
|
+
console.warn('graph.json fetch failed', err);
|
|
719
|
+
}}
|
|
720
|
+
|
|
721
|
+
if (!graphData || !graphData.nodes || graphData.nodes.length === 0) {{
|
|
722
|
+
setSidebar(repoId, `<p class="empty">No internal graph data found for "${{repoId}}".<br/>Index this repository first.</p>`);
|
|
723
|
+
setStatus('No data for ' + repoId);
|
|
724
|
+
initLevel2Empty(repoId);
|
|
725
|
+
return;
|
|
726
|
+
}}
|
|
727
|
+
|
|
728
|
+
// Sanitise node IDs — vis.js chokes on '.', '../', './' and other
|
|
729
|
+
// relative path fragments used as TypeScript import paths.
|
|
730
|
+
// We replace them with safe equivalents and patch edges to match.
|
|
731
|
+
const idMap = {{}}; // original → safe
|
|
732
|
+
graphData.nodes.forEach(n => {{
|
|
733
|
+
const orig = n.id;
|
|
734
|
+
// Replace ./ ../ and leading dots; collapse spaces
|
|
735
|
+
const safe = String(orig)
|
|
736
|
+
.replace(/^\.\.\//g, '__parent__/')
|
|
737
|
+
.replace(/^\.\//g, '__rel__/')
|
|
738
|
+
.replace(/^\.$/, '__dot__')
|
|
739
|
+
.replace(/^\.\.$/, '__dotdot__')
|
|
740
|
+
.replace(/[^a-zA-Z0-9_\-\/.:@]/g, '_');
|
|
741
|
+
idMap[orig] = safe;
|
|
742
|
+
n.id = safe;
|
|
743
|
+
}});
|
|
744
|
+
graphData.edges.forEach(e => {{
|
|
745
|
+
e.source = idMap[e.source] || e.source;
|
|
746
|
+
e.target = idMap[e.target] || e.target;
|
|
747
|
+
}});
|
|
748
|
+
|
|
749
|
+
initLevel2(repoId, graphData);
|
|
750
|
+
}}
|
|
751
|
+
|
|
752
|
+
// Colour by neo4j label (more specific than generic type)
|
|
753
|
+
const LABEL_COLOURS = {{
|
|
754
|
+
CLASS: '#4A90D9',
|
|
755
|
+
METHOD: '#74B3E8',
|
|
756
|
+
REPOSITORY: '#2C6FA8',
|
|
757
|
+
LIBRARY: '#52BE80',
|
|
758
|
+
NPM_PACKAGE: '#27AE60',
|
|
759
|
+
API: '#E8A838',
|
|
760
|
+
ENDPOINT: '#F39C12',
|
|
761
|
+
DATABASE: '#E67E22',
|
|
762
|
+
Component: '#5DADE2',
|
|
763
|
+
Microfrontend: '#A569BD',
|
|
764
|
+
WORKSPACE: '#BDC3C7',
|
|
765
|
+
PROJECT: '#85929E',
|
|
766
|
+
// DB schema objects
|
|
767
|
+
DB_TABLE: '#C0392B',
|
|
768
|
+
DB_VIEW: '#E74C3C',
|
|
769
|
+
DB_PROCEDURE: '#D35400',
|
|
770
|
+
DB_PACKAGE: '#E67E22',
|
|
771
|
+
DB_TRIGGER: '#A93226',
|
|
772
|
+
DB_SEQUENCE: '#CB4335',
|
|
773
|
+
DB_FUNCTION: '#BA4A00',
|
|
774
|
+
DB_COLLECTION: '#8E44AD',
|
|
775
|
+
}};
|
|
776
|
+
const TYPE_COLOURS = {{
|
|
777
|
+
code: '#4A90D9',
|
|
778
|
+
module: '#52BE80',
|
|
779
|
+
concept: '#E8A838',
|
|
780
|
+
document: '#9B59B6',
|
|
781
|
+
}};
|
|
782
|
+
const NODE_SHAPES = {{
|
|
783
|
+
CLASS: 'dot', METHOD: 'dot', Component: 'dot',
|
|
784
|
+
LIBRARY: 'square', NPM_PACKAGE: 'square',
|
|
785
|
+
API: 'diamond', ENDPOINT: 'diamond', DATABASE: 'diamond',
|
|
786
|
+
REPOSITORY: 'star', Microfrontend: 'hexagon',
|
|
787
|
+
DB_TABLE: 'database', DB_VIEW: 'diamond', DB_PROCEDURE: 'triangle',
|
|
788
|
+
DB_PACKAGE: 'triangle', DB_TRIGGER: 'triangleDown',
|
|
789
|
+
DB_COLLECTION: 'database',
|
|
790
|
+
}};
|
|
791
|
+
|
|
792
|
+
function nodeColour(n) {{
|
|
793
|
+
const lbl = (n.metadata || {{}}).neo4j_label || '';
|
|
794
|
+
return LABEL_COLOURS[lbl] || TYPE_COLOURS[n.type] || '#4A90D9';
|
|
795
|
+
}}
|
|
796
|
+
function nodeShape(n) {{
|
|
797
|
+
const lbl = (n.metadata || {{}}).neo4j_label || '';
|
|
798
|
+
return NODE_SHAPES[lbl] || (n.type === 'module' ? 'square' : n.type === 'concept' ? 'diamond' : 'dot');
|
|
799
|
+
}}
|
|
800
|
+
function nodeSize(n) {{
|
|
801
|
+
const lbl = (n.metadata || {{}}).neo4j_label || '';
|
|
802
|
+
if (lbl === 'REPOSITORY') return 20;
|
|
803
|
+
if (lbl === 'CLASS' || lbl === 'Component') return 12;
|
|
804
|
+
if (lbl === 'METHOD') return 8;
|
|
805
|
+
if (lbl === 'LIBRARY' || lbl === 'NPM_PACKAGE') return 11;
|
|
806
|
+
if (lbl === 'API' || lbl === 'ENDPOINT') return 10;
|
|
807
|
+
return 10;
|
|
808
|
+
}}
|
|
809
|
+
|
|
810
|
+
function initLevel2(repoId, graphData) {{
|
|
811
|
+
const visN = graphData.nodes.map(n => {{
|
|
812
|
+
const col = nodeColour(n);
|
|
813
|
+
return {{
|
|
814
|
+
id: n.id,
|
|
815
|
+
label: n.label,
|
|
816
|
+
title: buildNodeTooltip(n),
|
|
817
|
+
color: {{
|
|
818
|
+
background: col,
|
|
819
|
+
border: '#21262d',
|
|
820
|
+
highlight: {{background: '#e94560', border: '#fff'}},
|
|
821
|
+
hover: {{background: '#fff', border: col}},
|
|
822
|
+
}},
|
|
823
|
+
size: nodeSize(n),
|
|
824
|
+
shape: nodeShape(n),
|
|
825
|
+
font: {{color: '#c9d1d9', size: 10}},
|
|
826
|
+
_raw: n,
|
|
827
|
+
}};
|
|
828
|
+
}});
|
|
829
|
+
|
|
830
|
+
// Add "external repo" proxy nodes for cross-repo edges
|
|
831
|
+
const externalRepos = new Set();
|
|
832
|
+
L1_EDGES.forEach(e => {{
|
|
833
|
+
if (e.from === repoId && e.to !== repoId) externalRepos.add(e.to);
|
|
834
|
+
if (e.to === repoId && e.from !== repoId) externalRepos.add(e.from);
|
|
835
|
+
}});
|
|
836
|
+
|
|
837
|
+
externalRepos.forEach(extRepo => {{
|
|
838
|
+
visN.push({{
|
|
839
|
+
id: '__ext__' + extRepo,
|
|
840
|
+
label: extRepo,
|
|
841
|
+
title: `<b>External repo: ${{extRepo}}</b><br/>Connected via cross-repo relationship`,
|
|
842
|
+
color: {{
|
|
843
|
+
background: '#3d2b00',
|
|
844
|
+
border: '#9e6a03',
|
|
845
|
+
highlight: {{background: '#e3b341', border: '#fff'}},
|
|
846
|
+
}},
|
|
847
|
+
size: 18,
|
|
848
|
+
shape: 'box',
|
|
849
|
+
font: {{color: '#e3b341', size: 12, bold: true}},
|
|
850
|
+
}});
|
|
851
|
+
}});
|
|
852
|
+
|
|
853
|
+
// Internal edges
|
|
854
|
+
const visE = graphData.edges.map((e, i) => ({{
|
|
855
|
+
id: 'int_' + i,
|
|
856
|
+
from: e.source,
|
|
857
|
+
to: e.target,
|
|
858
|
+
label: e.relation,
|
|
859
|
+
color: {{color: '#30363d', highlight: '#58a6ff'}},
|
|
860
|
+
font: {{color: '#484f58', size: 9}},
|
|
861
|
+
arrows: 'to',
|
|
862
|
+
width: 1,
|
|
863
|
+
}}));
|
|
864
|
+
|
|
865
|
+
// Cross-repo edges — connect from the REPOSITORY node (or first node) to the external proxy
|
|
866
|
+
const repoRootNode =
|
|
867
|
+
graphData.nodes.find(n => n.id === repoId) ||
|
|
868
|
+
graphData.nodes.find(n => (n.metadata || {{}}).neo4j_label === 'REPOSITORY') ||
|
|
869
|
+
(graphData.nodes.length > 0 ? graphData.nodes[0] : null);
|
|
870
|
+
const rootId = repoRootNode ? repoRootNode.id : null;
|
|
871
|
+
|
|
872
|
+
L1_EDGES.forEach((e, i) => {{
|
|
873
|
+
const extRepo = e.from === repoId ? e.to : (e.to === repoId ? e.from : null);
|
|
874
|
+
if (!extRepo || !rootId) return;
|
|
875
|
+
const colour = CONF_COLOURS[e._confidence] || '#888';
|
|
876
|
+
visE.push({{
|
|
877
|
+
id: 'cross_' + i,
|
|
878
|
+
from: rootId,
|
|
879
|
+
to: '__ext__' + extRepo,
|
|
880
|
+
label: e.label,
|
|
881
|
+
title: `<b>Cross-repo: ${{e.label}}</b> [${{e._confidence}}]<br/>${{(e._evidence||[]).slice(0,3).join('<br/>')}}`,
|
|
882
|
+
color: {{color: colour, highlight: '#fff'}},
|
|
883
|
+
font: {{color: colour, size: 10, bold: true}},
|
|
884
|
+
arrows: e.from === repoId ? 'to' : 'from',
|
|
885
|
+
width: 3,
|
|
886
|
+
dashes: e._confidence === 'LOW',
|
|
887
|
+
}});
|
|
888
|
+
}});
|
|
889
|
+
|
|
890
|
+
visNodes = new vis.DataSet(visN);
|
|
891
|
+
visEdges = new vis.DataSet(visE);
|
|
892
|
+
|
|
893
|
+
if (network) network.destroy();
|
|
894
|
+
network = new vis.Network(
|
|
895
|
+
document.getElementById('network'),
|
|
896
|
+
{{nodes: visNodes, edges: visEdges}},
|
|
897
|
+
{{
|
|
898
|
+
physics: {{
|
|
899
|
+
enabled: true,
|
|
900
|
+
solver: 'barnesHut',
|
|
901
|
+
barnesHut: {{
|
|
902
|
+
gravitationalConstant: -3000,
|
|
903
|
+
centralGravity: 0.3,
|
|
904
|
+
springLength: 80,
|
|
905
|
+
springConstant: 0.04,
|
|
906
|
+
damping: 0.09,
|
|
907
|
+
}},
|
|
908
|
+
stabilization: {{iterations: 300}},
|
|
909
|
+
}},
|
|
910
|
+
interaction: {{
|
|
911
|
+
hover: true, tooltipDelay: 80,
|
|
912
|
+
navigationButtons: true, keyboard: true,
|
|
913
|
+
}},
|
|
914
|
+
}}
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
network.on('click', params => onL2Click(params));
|
|
918
|
+
network.on('doubleClick', params => {{
|
|
919
|
+
// double-click on external proxy node → navigate to that repo
|
|
920
|
+
if (params.nodes.length > 0) {{
|
|
921
|
+
const id = params.nodes[0];
|
|
922
|
+
if (id.startsWith('__ext__')) drillDown(id.replace('__ext__', ''));
|
|
923
|
+
}}
|
|
924
|
+
}});
|
|
925
|
+
network.on('stabilizationIterationsDone', () => {{
|
|
926
|
+
network.setOptions({{physics: false}});
|
|
927
|
+
setStatus(visN.length + ' nodes · ' + visE.length + ' edges · stabilized');
|
|
928
|
+
}});
|
|
929
|
+
|
|
930
|
+
setStatus(visN.length + ' nodes · ' + visE.length + ' edges · stabilizing…');
|
|
931
|
+
showL2Summary(repoId, graphData, externalRepos);
|
|
932
|
+
}}
|
|
933
|
+
|
|
934
|
+
function initLevel2Empty(repoId) {{
|
|
935
|
+
if (network) network.destroy();
|
|
936
|
+
visNodes = new vis.DataSet([{{id: repoId, label: repoId, color: {{background:'#4A90D9'}}, size: 25, shape: 'dot'}}]);
|
|
937
|
+
visEdges = new vis.DataSet([]);
|
|
938
|
+
network = new vis.Network(document.getElementById('network'), {{nodes: visNodes, edges: visEdges}}, {{}});
|
|
939
|
+
}}
|
|
940
|
+
|
|
941
|
+
function onL2Click(params) {{
|
|
942
|
+
if (params.nodes.length > 0) {{
|
|
943
|
+
const id = params.nodes[0];
|
|
944
|
+
if (id.startsWith('__ext__')) {{
|
|
945
|
+
const extRepo = id.replace('__ext__','');
|
|
946
|
+
showExternalRepoCard(extRepo);
|
|
947
|
+
}} else {{
|
|
948
|
+
const node = visNodes.get(id);
|
|
949
|
+
showInternalNodeCard(node);
|
|
950
|
+
}}
|
|
951
|
+
}} else if (params.edges.length > 0) {{
|
|
952
|
+
const edge = visEdges.get(params.edges[0]);
|
|
953
|
+
showInternalEdgeCard(edge);
|
|
954
|
+
}} else {{
|
|
955
|
+
showL2Summary(currentRepo, null, null);
|
|
956
|
+
}}
|
|
957
|
+
}}
|
|
958
|
+
|
|
959
|
+
function buildNodeTooltip(n) {{
|
|
960
|
+
const m = n.metadata || {{}};
|
|
961
|
+
const lines = [`<b>${{n.label}}</b>`, `type: ${{n.type}}`];
|
|
962
|
+
if (m.neo4j_label) lines.push(`neo4j: ${{m.neo4j_label}}`);
|
|
963
|
+
if (m.repository) lines.push(`repo: ${{m.repository}}`);
|
|
964
|
+
if (m.qualified_name) lines.push(`qname: ${{m.qualified_name}}`);
|
|
965
|
+
if (m.framework) lines.push(`fw: ${{m.framework}}`);
|
|
966
|
+
if (n.file) lines.push(`file: ${{n.file}}`);
|
|
967
|
+
return lines.join('<br/>');
|
|
968
|
+
}}
|
|
969
|
+
|
|
970
|
+
function showInternalNodeCard(node) {{
|
|
971
|
+
if (!node) return;
|
|
972
|
+
const r = node._raw || {{}};
|
|
973
|
+
const m = r.metadata || {{}};
|
|
974
|
+
let html = `<div class="card">
|
|
975
|
+
<div class="card-title">${{r.label || node.label}}</div>`;
|
|
976
|
+
const typeLabel = r.type || 'unknown';
|
|
977
|
+
const badgeClass = typeLabel === 'code' ? 'badge-code' : typeLabel === 'module' ? 'badge-mod' : 'badge-api';
|
|
978
|
+
html += `<div class="card-row"><span class="card-label">Type</span>
|
|
979
|
+
<span class="card-val"><span class="badge ${{badgeClass}}">${{typeLabel}}</span></span></div>`;
|
|
980
|
+
for (const [k,v] of Object.entries(m)) {{
|
|
981
|
+
if (v) html += `<div class="card-row"><span class="card-label">${{k}}</span><span class="card-val">${{v}}</span></div>`;
|
|
982
|
+
}}
|
|
983
|
+
if (r.file) html += `<div class="card-row"><span class="card-label">file</span><span class="card-val">${{r.file}}</span></div>`;
|
|
984
|
+
html += '</div>';
|
|
985
|
+
setSidebar(r.label || node.label, html);
|
|
986
|
+
}}
|
|
987
|
+
|
|
988
|
+
function showInternalEdgeCard(edge) {{
|
|
989
|
+
if (!edge) return;
|
|
990
|
+
setSidebar('Edge', `<div class="card">
|
|
991
|
+
<div class="card-title">${{edge.from || ''}} → ${{edge.to || ''}}</div>
|
|
992
|
+
<div class="card-row"><span class="card-label">Relation</span><span class="card-val">${{edge.label || ''}}</span></div>
|
|
993
|
+
</div>`);
|
|
994
|
+
}}
|
|
995
|
+
|
|
996
|
+
function showExternalRepoCard(extRepo) {{
|
|
997
|
+
const rels = L1_EDGES.filter(e => (e.from === currentRepo && e.to === extRepo) || (e.from === extRepo && e.to === currentRepo));
|
|
998
|
+
let html = `<div class="card">
|
|
999
|
+
<div class="card-title">📦 ${{extRepo}}</div>
|
|
1000
|
+
<p style="font-size:11px;color:#8b949e;margin-top:4px">External repository connected to <b>${{currentRepo}}</b></p>
|
|
1001
|
+
</div>
|
|
1002
|
+
<div class="drill-hint">Double-click this node to explore ${{extRepo}}</div>`;
|
|
1003
|
+
if (rels.length) {{
|
|
1004
|
+
html += '<div class="card"><div class="card-title">Relationships</div>';
|
|
1005
|
+
rels.forEach(e => {{
|
|
1006
|
+
const dir = e.from === currentRepo ? `${{currentRepo}} → ${{extRepo}}` : `${{extRepo}} → ${{currentRepo}}`;
|
|
1007
|
+
html += `<div class="card-row"><span class="card-label">${{dir}}</span>
|
|
1008
|
+
<span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span></div>
|
|
1009
|
+
<div style="font-size:11px;color:#8b949e;margin-bottom:5px">${{(e._evidence||[]).slice(0,2).join('<br/>')}}</div>`;
|
|
1010
|
+
}});
|
|
1011
|
+
html += '</div>';
|
|
1012
|
+
}}
|
|
1013
|
+
setSidebar(extRepo, html);
|
|
1014
|
+
}}
|
|
1015
|
+
|
|
1016
|
+
function showL2Summary(repoId, graphData, externalRepos) {{
|
|
1017
|
+
const nodeCount = graphData ? graphData.nodes.length : 0;
|
|
1018
|
+
const edgeCount = graphData ? graphData.edges.length : 0;
|
|
1019
|
+
const extList = externalRepos ? [...externalRepos] : [];
|
|
1020
|
+
const rels = L1_EDGES.filter(e => e.from === repoId || e.to === repoId);
|
|
1021
|
+
|
|
1022
|
+
let html = `<div class="card">
|
|
1023
|
+
<div class="card-title">📦 ${{repoId}}</div>
|
|
1024
|
+
<div class="card-row"><span class="card-label">Internal nodes</span><span class="card-val">${{nodeCount}}</span></div>
|
|
1025
|
+
<div class="card-row"><span class="card-label">Internal edges</span><span class="card-val">${{edgeCount}}</span></div>
|
|
1026
|
+
<div class="card-row"><span class="card-label">Cross-repo links</span><span class="card-val">${{rels.length}}</span></div>
|
|
1027
|
+
</div>`;
|
|
1028
|
+
|
|
1029
|
+
if (extList.length) {{
|
|
1030
|
+
html += '<div class="card"><div class="card-title">Connected repositories</div>';
|
|
1031
|
+
extList.forEach(ext => {{
|
|
1032
|
+
const r = rels.find(e => e.from === ext || e.to === ext);
|
|
1033
|
+
html += `<div class="card-row">
|
|
1034
|
+
<span class="card-label" style="cursor:pointer;color:#58a6ff" onclick="drillDown('${{ext}}')">${{ext}}</span>
|
|
1035
|
+
${{r ? `<span class="card-val"><span class="badge badge-${{r._confidence}}">${{r._confidence}}</span></span>` : ''}}
|
|
1036
|
+
</div>`;
|
|
1037
|
+
}});
|
|
1038
|
+
html += '</div>';
|
|
1039
|
+
}}
|
|
1040
|
+
setSidebar(repoId, html);
|
|
1041
|
+
}}
|
|
1042
|
+
|
|
1043
|
+
// ── Navigation ────────────────────────────────────────────────────────────
|
|
1044
|
+
function goLevel1() {{
|
|
1045
|
+
initLevel1();
|
|
1046
|
+
}}
|
|
1047
|
+
|
|
1048
|
+
// ── Shared utils ──────────────────────────────────────────────────────────
|
|
1049
|
+
function setSidebar(title, bodyHtml) {{
|
|
1050
|
+
document.getElementById('sidebar-title').textContent = title;
|
|
1051
|
+
document.getElementById('sidebar-body').innerHTML = bodyHtml;
|
|
1052
|
+
}}
|
|
1053
|
+
|
|
1054
|
+
function setStatus(msg) {{
|
|
1055
|
+
document.getElementById('statusbar').textContent = msg;
|
|
1056
|
+
}}
|
|
1057
|
+
|
|
1058
|
+
function resetView() {{
|
|
1059
|
+
if (network) network.fit({{animation: true}});
|
|
1060
|
+
}}
|
|
1061
|
+
|
|
1062
|
+
function doSearch() {{
|
|
1063
|
+
const term = document.getElementById('search').value.toLowerCase().trim();
|
|
1064
|
+
if (!term || !visNodes) return;
|
|
1065
|
+
const match = visNodes.get().find(n => (n.label||'').toLowerCase().includes(term));
|
|
1066
|
+
if (match) {{
|
|
1067
|
+
network.selectNodes([match.id]);
|
|
1068
|
+
network.focus(match.id, {{scale: 1.8, animation: true}});
|
|
1069
|
+
if (currentLevel === 1) showRepoCard(match.id);
|
|
1070
|
+
else showInternalNodeCard(match);
|
|
1071
|
+
}}
|
|
1072
|
+
}}
|
|
1073
|
+
|
|
1074
|
+
function applyFilter() {{
|
|
1075
|
+
if (currentLevel === 1) initLevel1();
|
|
1076
|
+
}}
|
|
1077
|
+
|
|
1078
|
+
document.getElementById('search').addEventListener('keydown', e => {{
|
|
1079
|
+
if (e.key === 'Enter') doSearch();
|
|
1080
|
+
}});
|
|
1081
|
+
document.addEventListener('keydown', e => {{
|
|
1082
|
+
if (e.key === 'Escape' && currentLevel === 2) goLevel1();
|
|
1083
|
+
}});
|
|
1084
|
+
|
|
1085
|
+
// ── Boot ──────────────────────────────────────────────────────────────────
|
|
1086
|
+
initLevel1();
|
|
1087
|
+
</script>
|
|
1088
|
+
</body>
|
|
1089
|
+
</html>"""
|