diff --git a/api/sql_console.py b/api/sql_console.py index ad7a5b0..aecacf3 100644 --- a/api/sql_console.py +++ b/api/sql_console.py @@ -502,6 +502,86 @@ def _sample_cassandra(object_name: str, limit: int) -> dict[str, Any]: return _run_cassandra(f"SELECT * FROM {ks}.{table} LIMIT {limit}", limit) +def _graph_neo4j(edge_limit: int = 60, rel_type: str | None = None) -> dict[str, Any]: + """Return a sampled subgraph (nodes + edges) for interactive graph visualization.""" + t0 = time.perf_counter() + driver = _neo4j_driver() + try: + with driver.session() as session: + total = session.run("MATCH (n) RETURN count(n) AS c").single()["c"] + rel_types = [ + r["relationshipType"] + for r in session.run( + "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType" + ) + ] + if rel_type: + cypher = """ + MATCH (a)-[r]->(b) + WHERE type(r) = $rel_type + WITH a, r, b LIMIT $limit + RETURN a, r, b, labels(a)[0] AS la, type(r) AS rt, labels(b)[0] AS lb + """ + params: dict[str, Any] = {"rel_type": rel_type, "limit": edge_limit} + else: + cypher = """ + MATCH (a)-[r]->(b) + WITH a, r, b LIMIT $limit + RETURN a, r, b, labels(a)[0] AS la, type(r) AS rt, labels(b)[0] AS lb + """ + params = {"limit": edge_limit} + records = session.run(cypher, **params).data() + + def _node_key(node: Any, label: str) -> str: + props = dict(node) + if label == "Product" and props.get("product_id") is not None: + return f"Product:{props['product_id']}" + if label == "Supplier" and props.get("supplier_id") is not None: + return f"Supplier:{props['supplier_id']}" + nid = getattr(node, "element_id", None) or getattr(node, "id", None) + return f"{label}:{nid}" + + nodes_map: dict[str, dict[str, Any]] = {} + edges: list[dict[str, Any]] = [] + for rec in records: + a, b, rt = rec["a"], rec["b"], rec["rt"] + la, lb = rec["la"], rec["lb"] + aid, bid = _node_key(a, la), _node_key(b, lb) + if aid not in nodes_map: + ap = dict(a) + nodes_map[aid] = { + "id": aid, + "label": la, + "name": ap.get("name") or str(ap.get("product_id") or ap.get("supplier_id") or aid), + "properties": {k: _fmt(v) for k, v in ap.items()}, + } + if bid not in nodes_map: + bp = dict(b) + nodes_map[bid] = { + "id": bid, + "label": lb, + "name": bp.get("name") or str(bp.get("product_id") or bp.get("supplier_id") or bid), + "properties": {k: _fmt(v) for k, v in bp.items()}, + } + edges.append({"id": f"{aid}|{rt}|{bid}", "source": aid, "target": bid, "type": rt}) + + elapsed_ms = int((time.perf_counter() - t0) * 1000) + return { + "ok": True, + "nodes": list(nodes_map.values()), + "edges": edges, + "rel_types": rel_types, + "stats": { + "total_nodes_db": total, + "returned_nodes": len(nodes_map), + "returned_edges": len(edges), + }, + "elapsed_ms": elapsed_ms, + } + finally: + driver.close() + + def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]: label = object_name.split(".")[-1] cypher = f"MATCH (n:`{label}`) RETURN n LIMIT {limit}" @@ -590,6 +670,20 @@ async def get_sample(engine: str, object: str = Query(..., min_length=1), limit: return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502) +@router.get("/graph/neo4j") +async def get_neo4j_graph( + limit: int = Query(60, ge=10, le=150), + rel_type: str | None = Query(None), +): + try: + result = _graph_neo4j(limit, rel_type or None) + if not result.get("ok"): + return JSONResponse(result, status_code=422) + return result + except Exception as exc: + return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502) + + @router.post("/execute") async def execute_sql(body: SqlRequest): sql = body.sql.strip().rstrip(";") diff --git a/ui/src/components/features/DataSourcesView.tsx b/ui/src/components/features/DataSourcesView.tsx index 6ea5ba1..7e3edd8 100644 --- a/ui/src/components/features/DataSourcesView.tsx +++ b/ui/src/components/features/DataSourcesView.tsx @@ -5,6 +5,7 @@ import { Database, FolderTree, Loader2, + Network, RefreshCw, Server, Table2, @@ -12,6 +13,7 @@ import { } from 'lucide-react' import { Badge } from '../ui/Badge' import { DbShell } from './DbShell' +import { Neo4jGraphView } from './Neo4jGraphView' import { SqlWorkbench } from './SqlWorkbench' import { getSourceMeta, @@ -43,8 +45,9 @@ type Props = { focusEngine?: SourceEngine | null } -const SUB_TABS: { id: SourceSubTab; label: string; icon: typeof FolderTree }[] = [ +const SUB_TABS: { id: SourceSubTab; label: string; icon: typeof FolderTree; neo4jOnly?: boolean }[] = [ { id: 'browser', label: 'Browser', icon: FolderTree }, + { id: 'graph', label: 'Graph', icon: Network, neo4jOnly: true }, { id: 'console', label: 'Query Console', icon: Database }, { id: 'shell', label: 'Shell', icon: TerminalSquare }, ] @@ -72,6 +75,10 @@ export function DataSourcesView({ focusEngine }: Props) { if (focusEngine) setActive(focusEngine) }, [focusEngine]) + useEffect(() => { + if (active !== 'neo4j' && subTab === 'graph') setSubTab('browser') + }, [active, subTab]) + const loadHealth = useCallback(async () => { try { const r = await fetch('/api/sql/health') @@ -123,9 +130,12 @@ export function DataSourcesView({ focusEngine }: Props) { const refreshAll = () => { loadHealth() if (subTab === 'browser') loadCatalog(active) + else if (subTab === 'graph' && active === 'neo4j') { /* Neo4jGraphView reloads itself */ } else if (selectedObject) loadSample(active, selectedObject) } + const visibleSubTabs = SUB_TABS.filter((t) => !t.neo4jOnly || active === 'neo4j') + return (
{/* Header */} @@ -200,7 +210,7 @@ export function DataSourcesView({ focusEngine }: Props) {

- {SUB_TABS.map(({ id, label, icon: Icon }) => ( + {visibleSubTabs.map(({ id, label, icon: Icon }) => ( +
+ + + {Math.round(zoom * 100)}% +
+
+ + {data?.stats && ( +
+ DB total: {fmtCount(data.stats.total_nodes_db)} nodes + Shown: {data.stats.returned_nodes} nodes · {data.stats.returned_edges} edges + {data.elapsed_ms != null && {data.elapsed_ms}ms} +
+ )} + +
+ {/* Canvas */} +
{ if (e.target === e.currentTarget) onPointerDown(e, 'pan') }} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + > + {loading && ( +
+ +
+ )} + {error && ( +
{error}
+ )} + {!loading && data?.nodes?.length === 0 && ( +
No graph data — try another filter
+ )} + + + + + + + + + {/* Edges */} + {(data?.edges || []).map((e) => { + const a = positions.get(e.source) + const b = positions.get(e.target) + if (!a || !b) return null + const col = EDGE_COLORS[e.type] || '#64748b' + const mx = (a.x + b.x) / 2 + const my = (a.y + b.y) / 2 + return ( + + + {e.type} + + ) + })} + {/* Nodes */} + {(data?.nodes || []).map((n) => { + const p = positions.get(n.id) + if (!p) return null + const style = NODE_COLORS[n.label] || DEFAULT_NODE + const isSel = selected?.id === n.id + const r = n.label === 'Supplier' ? 22 : 18 + return ( + { ev.stopPropagation(); onPointerDown(ev, 'node', n.id) }} + style={{ cursor: 'pointer' }} + > + + + {n.label === 'Product' ? 'P' : 'S'} + + + {(n.name || '').slice(0, 14)}{(n.name || '').length > 14 ? '…' : ''} + + + ) + })} + + + + {/* Legend */} +
+

Legend

+
+ {Object.entries(NODE_COLORS).map(([label, c]) => ( +
+ + {label} +
+ ))} + {Object.entries(EDGE_COLORS).map(([label, c]) => ( +
+ + {label} +
+ ))} +
+
+
+ + {/* Detail panel */} + +
+ + ) +} diff --git a/ui/src/lib/dataSourceCatalog.ts b/ui/src/lib/dataSourceCatalog.ts index c8b01a1..c48dd01 100644 --- a/ui/src/lib/dataSourceCatalog.ts +++ b/ui/src/lib/dataSourceCatalog.ts @@ -3,7 +3,7 @@ import { Activity, Boxes, Database, Network } from 'lucide-react' export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j' -export type SourceSubTab = 'browser' | 'console' | 'shell' +export type SourceSubTab = 'browser' | 'console' | 'shell' | 'graph' export type CatalogObject = { type: string