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 }) => (