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,442 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export Router — generates Graphify-compatible graph.json and graph.html from Neo4j data.
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
GET /api/export/graph.json — raw Graphify-compatible JSON
|
|
6
|
+
GET /api/export/graph.html — interactive vis.js visualization (like Graphify)
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
12
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from dependency_injector.wiring import Provide, inject
|
|
16
|
+
|
|
17
|
+
from src.config.container import Container
|
|
18
|
+
from src.infrastructure.persistence.graph_export_repository import GraphExportRepository
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/api/export", tags=["export"])
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Colour / shape palette matching Graphify's style
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
_TYPE_COLOURS: dict[str, str] = {
|
|
28
|
+
"code": "#4A90D9", # blue — classes, components, methods
|
|
29
|
+
"module": "#7CB87A", # green — libraries, npm packages
|
|
30
|
+
"concept": "#E8A838", # amber — APIs, databases, concepts
|
|
31
|
+
"document": "#9B59B6", # purple — docs
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_LABEL_COLOURS: dict[str, str] = {
|
|
35
|
+
"CLASS": "#4A90D9",
|
|
36
|
+
"METHOD": "#74B3E8",
|
|
37
|
+
"REPOSITORY": "#2C3E50",
|
|
38
|
+
"LIBRARY": "#7CB87A",
|
|
39
|
+
"NPM_PACKAGE": "#52BE80",
|
|
40
|
+
"API": "#E8A838",
|
|
41
|
+
"DATABASE": "#E67E22",
|
|
42
|
+
"Component": "#5DADE2",
|
|
43
|
+
"Microfrontend": "#A569BD",
|
|
44
|
+
"WORKSPACE": "#BDC3C7",
|
|
45
|
+
"PROJECT": "#85929E",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_NODE_SHAPES: dict[str, str] = {
|
|
49
|
+
"code": "dot",
|
|
50
|
+
"module": "square",
|
|
51
|
+
"concept": "diamond",
|
|
52
|
+
"document": "triangle",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_vis_html(graph_data: dict[str, Any], title: str) -> str:
|
|
57
|
+
"""Build a fast, filterable vis.js Network visualization."""
|
|
58
|
+
nodes_json = json.dumps(graph_data["nodes"])
|
|
59
|
+
edges_json = json.dumps(graph_data["edges"])
|
|
60
|
+
meta = graph_data.get("meta", {})
|
|
61
|
+
repository = meta.get("repository_filter", "all")
|
|
62
|
+
node_count = meta.get("node_count", 0)
|
|
63
|
+
edge_count = meta.get("edge_count", 0)
|
|
64
|
+
grouped = meta.get("grouped", True)
|
|
65
|
+
|
|
66
|
+
return f"""<!DOCTYPE html>
|
|
67
|
+
<html lang="en">
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="UTF-8"/>
|
|
70
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
71
|
+
<title>{title}</title>
|
|
72
|
+
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
|
73
|
+
<style>
|
|
74
|
+
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
|
75
|
+
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
76
|
+
background:#0d1117;color:#c9d1d9;height:100vh;display:flex;flex-direction:column;overflow:hidden}}
|
|
77
|
+
#hdr{{background:#161b22;padding:9px 16px;display:flex;align-items:center;gap:10px;
|
|
78
|
+
border-bottom:1px solid #30363d;flex-shrink:0}}
|
|
79
|
+
#hdr h1{{font-size:14px;font-weight:700;color:#58a6ff;white-space:nowrap}}
|
|
80
|
+
#hdr .meta{{font-size:11px;color:#8b949e;margin-left:auto}}
|
|
81
|
+
#bar{{background:#161b22;padding:6px 14px;display:flex;gap:8px;align-items:center;
|
|
82
|
+
flex-wrap:wrap;border-bottom:1px solid #30363d;flex-shrink:0}}
|
|
83
|
+
#bar input{{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;
|
|
84
|
+
padding:4px 9px;border-radius:5px;font-size:12px;width:170px}}
|
|
85
|
+
#bar input:focus{{outline:none;border-color:#58a6ff}}
|
|
86
|
+
#bar button{{background:#21262d;color:#c9d1d9;border:1px solid #30363d;
|
|
87
|
+
padding:4px 11px;border-radius:5px;cursor:pointer;font-size:12px}}
|
|
88
|
+
#bar button:hover{{background:#30363d}}
|
|
89
|
+
#bar button.on{{background:#1f6feb;border-color:#1f6feb;color:#fff}}
|
|
90
|
+
.sep{{color:#30363d;font-size:16px}}
|
|
91
|
+
.tog{{display:flex;align-items:center;gap:4px;font-size:11px;cursor:pointer;
|
|
92
|
+
padding:3px 8px;border-radius:4px;border:1px solid #30363d;background:#0d1117;color:#8b949e;user-select:none}}
|
|
93
|
+
.tog.on{{color:#fff;border-color:var(--c);background:color-mix(in srgb,var(--c) 20%,#0d1117)}}
|
|
94
|
+
.tog .dot{{width:8px;height:8px;border-radius:50%;background:var(--c)}}
|
|
95
|
+
#main{{display:flex;flex:1;overflow:hidden}}
|
|
96
|
+
#net{{flex:1}}
|
|
97
|
+
#side{{width:270px;background:#161b22;border-left:1px solid #30363d;
|
|
98
|
+
display:flex;flex-direction:column;flex-shrink:0}}
|
|
99
|
+
#side h2{{font-size:12px;font-weight:600;color:#58a6ff;padding:10px 12px 6px;
|
|
100
|
+
border-bottom:1px solid #30363d}}
|
|
101
|
+
#side-body{{flex:1;overflow-y:auto;padding:10px 12px;font-size:12px}}
|
|
102
|
+
.card{{background:#0d1117;border:1px solid #30363d;border-radius:6px;
|
|
103
|
+
padding:8px 10px;margin-bottom:8px}}
|
|
104
|
+
.row{{display:flex;justify-content:space-between;margin-bottom:3px;gap:6px}}
|
|
105
|
+
.k{{color:#8b949e;font-size:11px;flex-shrink:0}}
|
|
106
|
+
.v{{color:#c9d1d9;word-break:break-all;text-align:right;font-size:11px}}
|
|
107
|
+
.conn-item{{padding:3px 0;border-top:1px solid #21262d;font-size:11px;color:#8b949e}}
|
|
108
|
+
#status{{padding:4px 14px;background:#161b22;border-top:1px solid #30363d;
|
|
109
|
+
font-size:11px;color:#484f58;flex-shrink:0}}
|
|
110
|
+
#url-hint{{font-size:11px;color:#8b949e;display:flex;align-items:center;gap:6px}}
|
|
111
|
+
#url-hint a{{color:#58a6ff;text-decoration:none}}
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div id="hdr">
|
|
116
|
+
<h1>☍ {title}</h1>
|
|
117
|
+
<span class="meta" id="meta-txt">{node_count} nodes · {edge_count} edges{' · grouped by package' if grouped else ''}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div id="bar">
|
|
120
|
+
<input id="search" placeholder="Search…" />
|
|
121
|
+
<button onclick="doSearch()">Find</button>
|
|
122
|
+
<button onclick="resetView()">Reset</button>
|
|
123
|
+
<button id="physBtn" onclick="togglePhysics()">Physics OFF</button>
|
|
124
|
+
<span class="sep">|</span>
|
|
125
|
+
<span style="font-size:11px;color:#8b949e">Show:</span>
|
|
126
|
+
<span class="tog on" style="--c:#4A90D9" data-type="code" onclick="toggleType(this)"><span class="dot"></span>Classes/Pkg</span>
|
|
127
|
+
<span class="tog on" style="--c:#C0392B" data-type="database" onclick="toggleType(this)"><span class="dot"></span>DB/Tables</span>
|
|
128
|
+
<span class="tog on" style="--c:#E8A838" data-type="concept" onclick="toggleType(this)"><span class="dot"></span>API/Endpoint</span>
|
|
129
|
+
<span class="tog on" style="--c:#52BE80" data-type="module" onclick="toggleType(this)"><span class="dot"></span>Libraries</span>
|
|
130
|
+
<span class="sep">|</span>
|
|
131
|
+
<span id="url-hint">
|
|
132
|
+
More nodes:
|
|
133
|
+
<a href="?{'repository='+repository+'&' if repository != 'all' else ''}group=false&max_nodes=1000" target="_blank">ungrouped</a>
|
|
134
|
+
<a href="?{'repository='+repository+'&' if repository != 'all' else ''}types=DB_TABLE,DB_PROCEDURE,DB_PACKAGE,DB_VIEW&max_nodes=800" target="_blank">DB only</a>
|
|
135
|
+
<a href="?{'repository='+repository+'&' if repository != 'all' else ''}types=CLASS,PACKAGE&max_nodes=800" target="_blank">Code only</a>
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
<div id="main">
|
|
139
|
+
<div id="net"></div>
|
|
140
|
+
<div id="side">
|
|
141
|
+
<h2>Details</h2>
|
|
142
|
+
<div id="side-body"><p style="color:#484f58;font-style:italic">Click a node.</p></div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div id="status">Loading…</div>
|
|
146
|
+
<script>
|
|
147
|
+
const RAW = {nodes_json};
|
|
148
|
+
const EDGES = {edges_json};
|
|
149
|
+
const COLS = {json.dumps(_LABEL_COLOURS)};
|
|
150
|
+
const TCOLS = {json.dumps(_TYPE_COLOURS)};
|
|
151
|
+
const SHAPES = {json.dumps(_NODE_SHAPES)};
|
|
152
|
+
|
|
153
|
+
// Which types are currently visible
|
|
154
|
+
const VISIBLE = new Set(['code','database','concept','module']);
|
|
155
|
+
|
|
156
|
+
function nodeCol(n) {{
|
|
157
|
+
const lbl=(n.metadata||{{}}).neo4j_label||'';
|
|
158
|
+
return COLS[lbl]||TCOLS[n.type]||'#888';
|
|
159
|
+
}}
|
|
160
|
+
function nodeShape(n) {{
|
|
161
|
+
const lbl=(n.metadata||{{}}).neo4j_label||'';
|
|
162
|
+
return SHAPES[lbl]||(n.type==='module'?'square':n.type==='concept'?'diamond':'dot');
|
|
163
|
+
}}
|
|
164
|
+
function nodeSize(n) {{
|
|
165
|
+
const lbl=(n.metadata||{{}}).neo4j_label||'';
|
|
166
|
+
const cnt=(n.metadata||{{}})._class_count||0;
|
|
167
|
+
if(lbl==='PACKAGE') return Math.min(30, 14+cnt*0.4);
|
|
168
|
+
if(lbl.startsWith('DB_')) return 12;
|
|
169
|
+
return 10;
|
|
170
|
+
}}
|
|
171
|
+
|
|
172
|
+
function buildVis() {{
|
|
173
|
+
const nodes = RAW
|
|
174
|
+
.filter(n => VISIBLE.has(n.type))
|
|
175
|
+
.map(n => ({{
|
|
176
|
+
id: n.id, label: n.label,
|
|
177
|
+
title: tooltip(n),
|
|
178
|
+
color: {{background:nodeCol(n), border:'#21262d',
|
|
179
|
+
highlight:{{background:'#e94560',border:'#fff'}},
|
|
180
|
+
hover:{{background:'#fff',border:nodeCol(n)}}}},
|
|
181
|
+
size: nodeSize(n),
|
|
182
|
+
shape: nodeShape(n),
|
|
183
|
+
font: {{color:'#c9d1d9', size:10}},
|
|
184
|
+
_raw: n,
|
|
185
|
+
}}));
|
|
186
|
+
const ids = new Set(nodes.map(n=>n.id));
|
|
187
|
+
const edges = EDGES
|
|
188
|
+
.filter(e=>ids.has(e.source)&&ids.has(e.target))
|
|
189
|
+
.map((e,i)=>({{
|
|
190
|
+
id:i, from:e.source, to:e.target,
|
|
191
|
+
label: e.relation==='imports'?'':e.relation,
|
|
192
|
+
color:{{color:'#30363d',highlight:'#58a6ff'}},
|
|
193
|
+
font:{{color:'#484f58',size:9,align:'middle'}},
|
|
194
|
+
arrows:'to', width:1,
|
|
195
|
+
}}));
|
|
196
|
+
return {{nodes, edges}};
|
|
197
|
+
}}
|
|
198
|
+
|
|
199
|
+
function tooltip(n) {{
|
|
200
|
+
const m=n.metadata||{{}};
|
|
201
|
+
const lines=[`<b>${{n.label}}</b>`];
|
|
202
|
+
if(m.neo4j_label) lines.push(`type: ${{m.neo4j_label}}`);
|
|
203
|
+
if(m.repository) lines.push(`repo: ${{m.repository}}`);
|
|
204
|
+
if(m.qualified_name&&m.qualified_name!==n.label) lines.push(`${{m.qualified_name}}`);
|
|
205
|
+
if(m._class_count) lines.push(`${{m._class_count}} classes`);
|
|
206
|
+
if(n.file) lines.push(`${{n.file.split('/').slice(-2).join('/')}}`);
|
|
207
|
+
return lines.join('<br/>');
|
|
208
|
+
}}
|
|
209
|
+
|
|
210
|
+
let net, visN, visE, physOn=false;
|
|
211
|
+
|
|
212
|
+
function init() {{
|
|
213
|
+
const {{nodes,edges}}=buildVis();
|
|
214
|
+
visN=new vis.DataSet(nodes);
|
|
215
|
+
visE=new vis.DataSet(edges);
|
|
216
|
+
if(net) net.destroy();
|
|
217
|
+
net=new vis.Network(document.getElementById('net'),{{nodes:visN,edges:visE}},{{
|
|
218
|
+
physics:{{
|
|
219
|
+
enabled:physOn,
|
|
220
|
+
solver:'forceAtlas2Based',
|
|
221
|
+
forceAtlas2Based:{{gravitationalConstant:-80,centralGravity:0.01,
|
|
222
|
+
springLength:100,springConstant:0.05,damping:0.9}},
|
|
223
|
+
stabilization:{{iterations:physOn?150:0,updateInterval:20}},
|
|
224
|
+
}},
|
|
225
|
+
interaction:{{hover:true,tooltipDelay:60,navigationButtons:true,keyboard:true,multiselect:false,zoomSpeed:0.5}},
|
|
226
|
+
layout:{{improvedLayout:nodes.length<150,randomSeed:42}},
|
|
227
|
+
}});
|
|
228
|
+
// Always fit after render so nodes are visible
|
|
229
|
+
net.once('afterDrawing',()=>{{ net.fit({{animation:false}}); }});
|
|
230
|
+
net.on('click', p=>{{
|
|
231
|
+
if(p.nodes.length) showNode(p.nodes[0]);
|
|
232
|
+
else document.getElementById('side-body').innerHTML='<p style="color:#484f58;font-style:italic">Click a node.</p>';
|
|
233
|
+
}});
|
|
234
|
+
net.on('stabilizationIterationsDone',()=>{{
|
|
235
|
+
net.setOptions({{physics:false}});
|
|
236
|
+
net.fit({{animation:{{duration:300}}}});
|
|
237
|
+
setStatus(nodes.length+' nodes · '+edges.length+' edges · done');
|
|
238
|
+
}});
|
|
239
|
+
setStatus(nodes.length+' nodes · '+edges.length+' edges · '+(physOn?'stabilizing…':'ready'));
|
|
240
|
+
document.getElementById('meta-txt').textContent =
|
|
241
|
+
nodes.length+' nodes shown · '+edges.length+' edges';
|
|
242
|
+
}}
|
|
243
|
+
|
|
244
|
+
function showNode(id) {{
|
|
245
|
+
const v=visN.get(id); if(!v) return;
|
|
246
|
+
const n=v._raw, m=n.metadata||{{}};
|
|
247
|
+
let h=`<div class="card">`;
|
|
248
|
+
h+=`<div class="row"><span class="k">Name</span><span class="v"><b>${{n.label}}</b></span></div>`;
|
|
249
|
+
if(m.neo4j_label) h+=`<div class="row"><span class="k">Type</span><span class="v">${{m.neo4j_label}}</span></div>`;
|
|
250
|
+
if(m.repository) h+=`<div class="row"><span class="k">Repo</span><span class="v">${{m.repository}}</span></div>`;
|
|
251
|
+
if(m.qualified_name) h+=`<div class="row"><span class="k">FQN</span><span class="v" style="font-size:10px">${{m.qualified_name}}</span></div>`;
|
|
252
|
+
if(m._class_count) h+=`<div class="row"><span class="k">Classes</span><span class="v">${{m._class_count}}</span></div>`;
|
|
253
|
+
if(n.file) h+=`<div class="row"><span class="k">File</span><span class="v" style="font-size:10px">${{n.file.split('/').slice(-2).join('/')}}</span></div>`;
|
|
254
|
+
for(const [k,v] of Object.entries(m)) {{
|
|
255
|
+
if(['neo4j_label','repository','qualified_name','_class_count','type'].includes(k)||!v) continue;
|
|
256
|
+
h+=`<div class="row"><span class="k">${{k}}</span><span class="v">${{String(v).slice(0,60)}}</span></div>`;
|
|
257
|
+
}}
|
|
258
|
+
h+='</div>';
|
|
259
|
+
// Connected nodes
|
|
260
|
+
const connEdges=visE.get().filter(e=>e.from===id||e.to===id).slice(0,12);
|
|
261
|
+
if(connEdges.length) {{
|
|
262
|
+
h+='<div class="card"><b style="font-size:11px;color:#58a6ff">Connections ('+connEdges.length+')</b>';
|
|
263
|
+
connEdges.forEach(e=>{{
|
|
264
|
+
const other=e.from===id?e.to:e.from;
|
|
265
|
+
const dir=e.from===id?'→':'←';
|
|
266
|
+
const oNode=visN.get(other);
|
|
267
|
+
const oLabel=oNode?oNode.label:other.split('.').pop();
|
|
268
|
+
h+=`<div class="conn-item">${{dir}} ${{e.label||'link'}} <b>${{oLabel}}</b></div>`;
|
|
269
|
+
}});
|
|
270
|
+
h+='</div>';
|
|
271
|
+
}}
|
|
272
|
+
document.getElementById('side-body').innerHTML=h;
|
|
273
|
+
}}
|
|
274
|
+
|
|
275
|
+
function doSearch() {{
|
|
276
|
+
const t=document.getElementById('search').value.toLowerCase().trim();
|
|
277
|
+
if(!t){{net.unselectAll();return;}}
|
|
278
|
+
const m=visN.get().find(n=>n.label.toLowerCase().includes(t));
|
|
279
|
+
if(m){{net.selectNodes([m.id]);net.focus(m.id,{{scale:1.8,animation:true}});showNode(m.id);}}
|
|
280
|
+
}}
|
|
281
|
+
|
|
282
|
+
function togglePhysics() {{
|
|
283
|
+
physOn=!physOn;
|
|
284
|
+
document.getElementById('physBtn').textContent=physOn?'Physics ON':'Physics OFF';
|
|
285
|
+
document.getElementById('physBtn').className=physOn?'on':'';
|
|
286
|
+
net.setOptions({{physics:{{enabled:physOn}}}});
|
|
287
|
+
if(physOn) net.stabilize(150);
|
|
288
|
+
}}
|
|
289
|
+
|
|
290
|
+
function toggleType(el) {{
|
|
291
|
+
const t=el.dataset.type;
|
|
292
|
+
if(VISIBLE.has(t)){{VISIBLE.delete(t);el.classList.remove('on');}}
|
|
293
|
+
else{{VISIBLE.add(t);el.classList.add('on');}}
|
|
294
|
+
init();
|
|
295
|
+
}}
|
|
296
|
+
|
|
297
|
+
function resetView(){{net.fit({{animation:true}});}}
|
|
298
|
+
function setStatus(msg){{document.getElementById('status').textContent=msg;}}
|
|
299
|
+
|
|
300
|
+
document.getElementById('search').addEventListener('keydown',e=>{{if(e.key==='Enter')doSearch();}});
|
|
301
|
+
init();
|
|
302
|
+
</script>
|
|
303
|
+
</body>
|
|
304
|
+
</html>"""
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@router.get(
|
|
308
|
+
"/graph.json",
|
|
309
|
+
status_code=status.HTTP_200_OK,
|
|
310
|
+
responses={
|
|
311
|
+
200: {"description": "Graphify-compatible graph JSON"},
|
|
312
|
+
404: {"description": "No data found"},
|
|
313
|
+
500: {"description": "Internal server error"},
|
|
314
|
+
},
|
|
315
|
+
summary="Export graph as Graphify-compatible JSON",
|
|
316
|
+
description=(
|
|
317
|
+
"Exports the full dependency graph from Neo4j in the Graphify schema "
|
|
318
|
+
"(nodes + edges with relation/confidence/weight). "
|
|
319
|
+
"Optionally filter by repository name."
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
@inject
|
|
323
|
+
async def export_graph_json(
|
|
324
|
+
repository: str | None = Query(default=None, description="Filter by repository name"),
|
|
325
|
+
group: bool = Query(default=True, description="Group Java classes by package"),
|
|
326
|
+
types: str | None = Query(default=None, description="Comma-separated node types to include, e.g. CLASS,DB_TABLE,API"),
|
|
327
|
+
max_nodes: int = Query(default=500, ge=50, le=5000, description="Max nodes to return"),
|
|
328
|
+
graph_export_repo: GraphExportRepository = Depends(Provide[Container.graph_export_repository]),
|
|
329
|
+
) -> JSONResponse:
|
|
330
|
+
logger.info("graph_json_export_requested", repository=repository)
|
|
331
|
+
node_types = [t.strip() for t in types.split(",")] if types else None
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
graph = await graph_export_repo.export_graph(
|
|
335
|
+
repository=repository,
|
|
336
|
+
group_packages=group,
|
|
337
|
+
node_types=node_types,
|
|
338
|
+
max_nodes=max_nodes,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if not graph.nodes:
|
|
342
|
+
raise HTTPException(
|
|
343
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
344
|
+
detail=(
|
|
345
|
+
f"No nodes found for repository '{repository}'."
|
|
346
|
+
if repository
|
|
347
|
+
else "No nodes found. Index a repository first."
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
payload = {
|
|
352
|
+
"nodes": graph.nodes,
|
|
353
|
+
"edges": graph.edges,
|
|
354
|
+
"meta": graph.meta,
|
|
355
|
+
}
|
|
356
|
+
logger.info(
|
|
357
|
+
"graph_json_export_completed",
|
|
358
|
+
repository=repository,
|
|
359
|
+
nodes=len(graph.nodes),
|
|
360
|
+
edges=len(graph.edges),
|
|
361
|
+
)
|
|
362
|
+
return JSONResponse(content=payload)
|
|
363
|
+
|
|
364
|
+
except HTTPException:
|
|
365
|
+
raise
|
|
366
|
+
except Exception as e:
|
|
367
|
+
logger.error("graph_json_export_failed", error=str(e))
|
|
368
|
+
raise HTTPException(
|
|
369
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
370
|
+
detail=f"Graph export failed: {e}",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@router.get(
|
|
375
|
+
"/graph.html",
|
|
376
|
+
status_code=status.HTTP_200_OK,
|
|
377
|
+
response_class=HTMLResponse,
|
|
378
|
+
responses={
|
|
379
|
+
200: {"description": "Interactive vis.js graph HTML"},
|
|
380
|
+
404: {"description": "No data found"},
|
|
381
|
+
500: {"description": "Internal server error"},
|
|
382
|
+
},
|
|
383
|
+
summary="Export graph as interactive HTML (Graphify-style)",
|
|
384
|
+
description=(
|
|
385
|
+
"Generates a self-contained interactive HTML visualization of the dependency graph "
|
|
386
|
+
"using vis.js Network — the same style as Graphify's graph.html output. "
|
|
387
|
+
"Optionally filter by repository name."
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
@inject
|
|
391
|
+
async def export_graph_html(
|
|
392
|
+
repository: str | None = Query(default=None, description="Filter by repository name"),
|
|
393
|
+
group: bool = Query(default=True, description="Group Java classes by package"),
|
|
394
|
+
types: str | None = Query(default=None, description="Comma-separated node types, e.g. CLASS,DB_TABLE,API"),
|
|
395
|
+
max_nodes: int = Query(default=200, ge=50, le=2000, description="Max nodes"),
|
|
396
|
+
graph_export_repo: GraphExportRepository = Depends(Provide[Container.graph_export_repository]),
|
|
397
|
+
) -> HTMLResponse:
|
|
398
|
+
logger.info("graph_html_export_requested", repository=repository)
|
|
399
|
+
|
|
400
|
+
node_types = [t.strip() for t in types.split(",")] if types else None
|
|
401
|
+
try:
|
|
402
|
+
graph = await graph_export_repo.export_graph(
|
|
403
|
+
repository=repository,
|
|
404
|
+
group_packages=group,
|
|
405
|
+
node_types=node_types,
|
|
406
|
+
max_nodes=max_nodes,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if not graph.nodes:
|
|
410
|
+
raise HTTPException(
|
|
411
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
412
|
+
detail=(
|
|
413
|
+
f"No nodes found for repository '{repository}'."
|
|
414
|
+
if repository
|
|
415
|
+
else "No nodes found. Index a repository first."
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
title = repository or "All Repositories"
|
|
420
|
+
payload = {
|
|
421
|
+
"nodes": graph.nodes,
|
|
422
|
+
"edges": graph.edges,
|
|
423
|
+
"meta": graph.meta,
|
|
424
|
+
}
|
|
425
|
+
html = _build_vis_html(payload, title)
|
|
426
|
+
|
|
427
|
+
logger.info(
|
|
428
|
+
"graph_html_export_completed",
|
|
429
|
+
repository=repository,
|
|
430
|
+
nodes=len(graph.nodes),
|
|
431
|
+
edges=len(graph.edges),
|
|
432
|
+
)
|
|
433
|
+
return HTMLResponse(content=html)
|
|
434
|
+
|
|
435
|
+
except HTTPException:
|
|
436
|
+
raise
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.error("graph_html_export_failed", error=str(e))
|
|
439
|
+
raise HTTPException(
|
|
440
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
441
|
+
detail=f"Graph HTML export failed: {e}",
|
|
442
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health Check Router (Task 9.4)
|
|
3
|
+
|
|
4
|
+
Health check endpoint for monitoring service status.
|
|
5
|
+
"""
|
|
6
|
+
from fastapi import APIRouter, status
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from typing import Literal
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="", tags=["health"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HealthCheckDetail(BaseModel):
|
|
18
|
+
"""Health check detail for a single service."""
|
|
19
|
+
|
|
20
|
+
service: str = Field(..., description="Service name")
|
|
21
|
+
status: Literal["healthy", "degraded", "unhealthy"] = Field(
|
|
22
|
+
...,
|
|
23
|
+
description="Service health status"
|
|
24
|
+
)
|
|
25
|
+
message: str | None = Field(
|
|
26
|
+
default=None,
|
|
27
|
+
description="Optional status message"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HealthCheckResponse(BaseModel):
|
|
32
|
+
"""Overall health check response."""
|
|
33
|
+
|
|
34
|
+
status: Literal["healthy", "degraded"] = Field(
|
|
35
|
+
...,
|
|
36
|
+
description="Overall system status"
|
|
37
|
+
)
|
|
38
|
+
checks: list[HealthCheckDetail] = Field(
|
|
39
|
+
default_factory=list,
|
|
40
|
+
description="Individual service health checks"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get(
|
|
45
|
+
"/health",
|
|
46
|
+
response_model=HealthCheckResponse,
|
|
47
|
+
status_code=status.HTTP_200_OK,
|
|
48
|
+
responses={
|
|
49
|
+
200: {"description": "All services healthy"},
|
|
50
|
+
503: {"description": "One or more services degraded"}
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
async def health_check() -> HealthCheckResponse:
|
|
54
|
+
"""
|
|
55
|
+
Check health status of all dependent services.
|
|
56
|
+
|
|
57
|
+
Checks:
|
|
58
|
+
- Qdrant vector database connectivity
|
|
59
|
+
- Neo4j graph database connectivity
|
|
60
|
+
- OpenAI API accessibility
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
HealthCheckResponse with overall status and individual checks
|
|
64
|
+
|
|
65
|
+
Status Codes:
|
|
66
|
+
- 200: All services healthy
|
|
67
|
+
- 503: One or more services degraded
|
|
68
|
+
"""
|
|
69
|
+
checks = []
|
|
70
|
+
|
|
71
|
+
# Check Qdrant
|
|
72
|
+
# TODO: Implement actual Qdrant health check
|
|
73
|
+
checks.append(HealthCheckDetail(
|
|
74
|
+
service="qdrant",
|
|
75
|
+
status="healthy",
|
|
76
|
+
message="Vector database operational"
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
# Check Neo4j
|
|
80
|
+
# TODO: Implement actual Neo4j health check
|
|
81
|
+
checks.append(HealthCheckDetail(
|
|
82
|
+
service="neo4j",
|
|
83
|
+
status="healthy",
|
|
84
|
+
message="Graph database operational"
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
# Check OpenAI
|
|
88
|
+
# TODO: Implement actual OpenAI health check
|
|
89
|
+
checks.append(HealthCheckDetail(
|
|
90
|
+
service="openai",
|
|
91
|
+
status="healthy",
|
|
92
|
+
message="Embedding service accessible"
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
# Determine overall status
|
|
96
|
+
statuses = [check.status for check in checks]
|
|
97
|
+
overall_status = "healthy" if all(s == "healthy" for s in statuses) else "degraded"
|
|
98
|
+
|
|
99
|
+
logger.info(
|
|
100
|
+
"health_check_performed",
|
|
101
|
+
overall_status=overall_status,
|
|
102
|
+
checks=len(checks)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
response = HealthCheckResponse(
|
|
106
|
+
status=overall_status,
|
|
107
|
+
checks=checks
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Return 503 if degraded
|
|
111
|
+
if overall_status == "degraded":
|
|
112
|
+
return JSONResponse(
|
|
113
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
114
|
+
content=response.model_dump()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return response
|