Navegador

navegador / navegador / explorer / server.py
Blame History Raw 298 lines
1
# Copyright CONFLICT LLC 2026 (weareconflict.com)
2
"""
3
ExplorerServer — lightweight HTTP server for graph visualisation.
4
5
Runs in a daemon thread so it does not block the caller. Uses only
6
Python's built-in http.server and json modules (no Flask/FastAPI).
7
8
Routes
9
------
10
GET / — self-contained HTML visualisation page
11
GET /api/graph — full graph as {nodes: [...], edges: [...]}
12
GET /api/search?q= — search nodes by name (case-insensitive substring)
13
GET /api/node/<name>— node details + immediate neighbours
14
GET /api/stats — {nodes: N, edges: M, node_types: {...}, edge_types: {...}}
15
"""
16
17
from __future__ import annotations
18
19
import json
20
import logging
21
import threading
22
from http.server import BaseHTTPRequestHandler, HTTPServer
23
from typing import TYPE_CHECKING, Any
24
from urllib.parse import parse_qs, unquote, urlparse
25
26
from .templates import HTML_TEMPLATE
27
28
if TYPE_CHECKING:
29
from navegador.graph.store import GraphStore
30
31
logger = logging.getLogger(__name__)
32
33
# ── Cypher helpers ─────────────────────────────────────────────────────────
34
35
36
def _query(store: "GraphStore", cypher: str, params: dict[str, Any] | None = None) -> list:
37
"""Run a Cypher query and return the result_set list (or [])."""
38
try:
39
result = store.query(cypher, params or {})
40
return result.result_set or []
41
except Exception as exc:
42
logger.warning("Graph query failed: %s", exc)
43
return []
44
45
46
def _get_all_nodes(store: "GraphStore") -> list[dict]:
47
rows = _query(
48
store,
49
"MATCH (n) RETURN id(n) AS id, labels(n)[0] AS label, "
50
"n.name AS name, properties(n) AS props",
51
)
52
result = []
53
for row in rows:
54
nid, label, name, props = row[0], row[1], row[2], row[3]
55
node_props = dict(props) if isinstance(props, dict) else {}
56
result.append(
57
{
58
"id": str(nid),
59
"label": label or "default",
60
"name": name or str(nid),
61
"props": node_props,
62
}
63
)
64
return result
65
66
67
def _get_all_edges(store: "GraphStore") -> list[dict]:
68
rows = _query(
69
store,
70
"MATCH (a)-[r]->(b) RETURN id(a) AS src, id(b) AS tgt, type(r) AS rel",
71
)
72
result = []
73
for row in rows:
74
src, tgt, rel = row[0], row[1], row[2]
75
result.append({"source": str(src), "target": str(tgt), "type": rel or ""})
76
return result
77
78
79
def _search_nodes(store: "GraphStore", query: str, limit: int = 50) -> list[dict]:
80
q = query.lower()
81
rows = _query(
82
store,
83
"MATCH (n) WHERE toLower(n.name) CONTAINS $q "
84
"RETURN labels(n)[0] AS label, n.name AS name, "
85
"coalesce(n.file_path, '') AS file_path, "
86
"coalesce(n.domain, '') AS domain "
87
"LIMIT $limit",
88
{"q": q, "limit": limit},
89
)
90
result = []
91
for row in rows:
92
result.append(
93
{
94
"label": row[0] or "",
95
"name": row[1] or "",
96
"file_path": row[2] or "",
97
"domain": row[3] or "",
98
}
99
)
100
return result
101
102
103
def _get_node_detail(store: "GraphStore", name: str) -> dict:
104
# Node properties
105
rows = _query(
106
store,
107
"MATCH (n) WHERE n.name = $name "
108
"RETURN labels(n)[0] AS label, properties(n) AS props "
109
"LIMIT 1",
110
{"name": name},
111
)
112
if not rows:
113
return {"name": name, "label": "", "props": {}, "neighbors": []}
114
115
label = rows[0][0] or ""
116
props = dict(rows[0][1]) if isinstance(rows[0][1], dict) else {}
117
118
# Outbound neighbours
119
out_rows = _query(
120
store,
121
"MATCH (n)-[r]->(nb) WHERE n.name = $name "
122
"RETURN labels(nb)[0] AS nb_label, nb.name AS nb_name, type(r) AS rel "
123
"LIMIT 100",
124
{"name": name},
125
)
126
# Inbound neighbours
127
in_rows = _query(
128
store,
129
"MATCH (nb)-[r]->(n) WHERE n.name = $name "
130
"RETURN labels(nb)[0] AS nb_label, nb.name AS nb_name, type(r) AS rel "
131
"LIMIT 100",
132
{"name": name},
133
)
134
135
seen: set[str] = set()
136
neighbors = []
137
for row in list(out_rows) + list(in_rows):
138
nb_label, nb_name, rel = row[0] or "", row[1] or "", row[2] or ""
139
key = f"{nb_name}|{rel}"
140
if key not in seen:
141
seen.add(key)
142
neighbors.append({"label": nb_label, "name": nb_name, "rel": rel})
143
144
return {"name": name, "label": label, "props": props, "neighbors": neighbors}
145
146
147
def _get_stats(store: "GraphStore") -> dict:
148
node_count = store.node_count()
149
edge_count = store.edge_count()
150
151
node_type_rows = _query(
152
store, "MATCH (n) RETURN labels(n)[0] AS type, count(n) AS c ORDER BY c DESC"
153
)
154
edge_type_rows = _query(
155
store, "MATCH ()-[r]->() RETURN type(r) AS type, count(r) AS c ORDER BY c DESC"
156
)
157
158
return {
159
"nodes": node_count,
160
"edges": edge_count,
161
"node_types": {r[0]: r[1] for r in node_type_rows if r[0]},
162
"edge_types": {r[0]: r[1] for r in edge_type_rows if r[0]},
163
}
164
165
166
# ── Request handler ────────────────────────────────────────────────────────
167
168
169
def _make_handler(store: "GraphStore"):
170
"""Return a BaseHTTPRequestHandler subclass bound to *store*."""
171
172
class _Handler(BaseHTTPRequestHandler):
173
_store = store
174
175
# silence default access log to keep CLI output clean
176
def log_message(self, fmt, *args):
177
logger.debug(fmt, *args)
178
179
def _send_json(self, data: Any, status: int = 200) -> None:
180
body = json.dumps(data, default=str).encode()
181
self.send_response(status)
182
self.send_header("Content-Type", "application/json")
183
self.send_header("Content-Length", str(len(body)))
184
self.send_header("Access-Control-Allow-Origin", "*")
185
self.end_headers()
186
self.wfile.write(body)
187
188
def _send_html(self, html: str) -> None:
189
body = html.encode()
190
self.send_response(200)
191
self.send_header("Content-Type", "text/html; charset=utf-8")
192
self.send_header("Content-Length", str(len(body)))
193
self.end_headers()
194
self.wfile.write(body)
195
196
def do_GET(self):
197
parsed = urlparse(self.path)
198
path = parsed.path.rstrip("/") or "/"
199
qs = parse_qs(parsed.query)
200
201
# ── Root — HTML page
202
if path == "/":
203
self._send_html(HTML_TEMPLATE)
204
205
# ── Full graph
206
elif path == "/api/graph":
207
nodes = _get_all_nodes(self._store)
208
edges = _get_all_edges(self._store)
209
self._send_json({"nodes": nodes, "edges": edges})
210
211
# ── Search
212
elif path == "/api/search":
213
q = qs.get("q", [""])[0]
214
results = _search_nodes(self._store, q) if q else []
215
self._send_json({"nodes": results})
216
217
# ── Node detail — /api/node/<name>
218
elif path.startswith("/api/node/"):
219
raw_name = path[len("/api/node/") :]
220
name = unquote(raw_name)
221
detail = _get_node_detail(self._store, name)
222
self._send_json(detail)
223
224
# ── Stats
225
elif path == "/api/stats":
226
self._send_json(_get_stats(self._store))
227
228
else:
229
self._send_json({"error": "not found"}, 404)
230
231
return _Handler
232
233
234
# ── ExplorerServer ─────────────────────────────────────────────────────────
235
236
237
class ExplorerServer:
238
"""
239
Lightweight HTTP server that serves the Navegador graph explorer UI.
240
241
Args:
242
store: A ``GraphStore`` instance (SQLite or Redis-backed).
243
host: Bind address (default ``127.0.0.1``).
244
port: TCP port (default ``8080``).
245
246
Example::
247
248
server = ExplorerServer(store, port=8080)
249
server.start()
250
# … do other work or sleep …
251
server.stop()
252
"""
253
254
def __init__(self, store: "GraphStore", host: str = "127.0.0.1", port: int = 8080) -> None:
255
self.store = store
256
self.host = host
257
self.port = port
258
self._server: HTTPServer | None = None
259
self._thread: threading.Thread | None = None
260
261
@property
262
def url(self) -> str:
263
return f"http://{self.host}:{self.port}"
264
265
def start(self) -> None:
266
"""Start the HTTP server in a background daemon thread."""
267
if self._server is not None:
268
raise RuntimeError("ExplorerServer is already running")
269
270
handler_class = _make_handler(self.store)
271
self._server = HTTPServer((self.host, self.port), handler_class)
272
self._thread = threading.Thread(
273
target=self._server.serve_forever,
274
name="navegador-explorer",
275
daemon=True,
276
)
277
self._thread.start()
278
logger.info("ExplorerServer started at %s", self.url)
279
280
def stop(self) -> None:
281
"""Shut down the HTTP server and wait for the thread to finish."""
282
if self._server is None:
283
return
284
self._server.shutdown()
285
self._server.server_close()
286
if self._thread is not None:
287
self._thread.join(timeout=5)
288
self._server = None
289
self._thread = None
290
logger.info("ExplorerServer stopped")
291
292
def __enter__(self) -> "ExplorerServer":
293
self.start()
294
return self
295
296
def __exit__(self, *_) -> None:
297
self.stop()
298

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button