feat(ui): Neo4j graph explorer with interactive relationship visualization
Add Graph sub-tab in Data Sources UI for Neo4j: force-directed SVG view of Product-Supplier nodes and SUPPLIES/RELATED_TO/PART_OF/COMPATIBLE_WITH edges. Backend GET /api/sql/graph/neo4j with rel_type filter and edge limit.
This commit is contained in:
@@ -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)
|
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]:
|
def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]:
|
||||||
label = object_name.split(".")[-1]
|
label = object_name.split(".")[-1]
|
||||||
cypher = f"MATCH (n:`{label}`) RETURN n LIMIT {limit}"
|
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)
|
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")
|
@router.post("/execute")
|
||||||
async def execute_sql(body: SqlRequest):
|
async def execute_sql(body: SqlRequest):
|
||||||
sql = body.sql.strip().rstrip(";")
|
sql = body.sql.strip().rstrip(";")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Network,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Table2,
|
Table2,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '../ui/Badge'
|
import { Badge } from '../ui/Badge'
|
||||||
import { DbShell } from './DbShell'
|
import { DbShell } from './DbShell'
|
||||||
|
import { Neo4jGraphView } from './Neo4jGraphView'
|
||||||
import { SqlWorkbench } from './SqlWorkbench'
|
import { SqlWorkbench } from './SqlWorkbench'
|
||||||
import {
|
import {
|
||||||
getSourceMeta,
|
getSourceMeta,
|
||||||
@@ -43,8 +45,9 @@ type Props = {
|
|||||||
focusEngine?: SourceEngine | null
|
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: 'browser', label: 'Browser', icon: FolderTree },
|
||||||
|
{ id: 'graph', label: 'Graph', icon: Network, neo4jOnly: true },
|
||||||
{ id: 'console', label: 'Query Console', icon: Database },
|
{ id: 'console', label: 'Query Console', icon: Database },
|
||||||
{ id: 'shell', label: 'Shell', icon: TerminalSquare },
|
{ id: 'shell', label: 'Shell', icon: TerminalSquare },
|
||||||
]
|
]
|
||||||
@@ -72,6 +75,10 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
if (focusEngine) setActive(focusEngine)
|
if (focusEngine) setActive(focusEngine)
|
||||||
}, [focusEngine])
|
}, [focusEngine])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active !== 'neo4j' && subTab === 'graph') setSubTab('browser')
|
||||||
|
}, [active, subTab])
|
||||||
|
|
||||||
const loadHealth = useCallback(async () => {
|
const loadHealth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/sql/health')
|
const r = await fetch('/api/sql/health')
|
||||||
@@ -123,9 +130,12 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
const refreshAll = () => {
|
const refreshAll = () => {
|
||||||
loadHealth()
|
loadHealth()
|
||||||
if (subTab === 'browser') loadCatalog(active)
|
if (subTab === 'browser') loadCatalog(active)
|
||||||
|
else if (subTab === 'graph' && active === 'neo4j') { /* Neo4jGraphView reloads itself */ }
|
||||||
else if (selectedObject) loadSample(active, selectedObject)
|
else if (selectedObject) loadSample(active, selectedObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleSubTabs = SUB_TABS.filter((t) => !t.neo4jOnly || active === 'neo4j')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-2 p-3">
|
<div className="flex h-full min-h-0 flex-col gap-2 p-3">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -200,7 +210,7 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => (
|
{visibleSubTabs.map(({ id, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -296,6 +306,10 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{subTab === 'graph' && active === 'neo4j' && (
|
||||||
|
<Neo4jGraphView />
|
||||||
|
)}
|
||||||
|
|
||||||
{subTab === 'console' && (
|
{subTab === 'console' && (
|
||||||
<SqlWorkbench engine={active} />
|
<SqlWorkbench engine={active} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Loader2, Network, RefreshCw, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
|
import { Badge } from '../ui/Badge'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type GraphNode = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
name: string
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphEdge = {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphResponse = {
|
||||||
|
ok: boolean
|
||||||
|
nodes: GraphNode[]
|
||||||
|
edges: GraphEdge[]
|
||||||
|
rel_types?: string[]
|
||||||
|
stats?: { total_nodes_db?: number; returned_nodes?: number; returned_edges?: number }
|
||||||
|
elapsed_ms?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodePos = { x: number; y: number; vx: number; vy: number }
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, { fill: string; stroke: string; text: string }> = {
|
||||||
|
Product: { fill: '#831843', stroke: '#f472b6', text: '#fbcfe8' },
|
||||||
|
Supplier: { fill: '#0c4a6e', stroke: '#38bdf8', text: '#bae6fd' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDGE_COLORS: Record<string, string> = {
|
||||||
|
SUPPLIES: '#f472b6',
|
||||||
|
RELATED_TO: '#a78bfa',
|
||||||
|
PART_OF: '#34d399',
|
||||||
|
COMPATIBLE_WITH: '#fbbf24',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_NODE = { fill: '#1e293b', stroke: '#94a3b8', text: '#e2e8f0' }
|
||||||
|
|
||||||
|
function layoutForce(
|
||||||
|
nodes: GraphNode[],
|
||||||
|
edges: GraphEdge[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
iterations = 140,
|
||||||
|
): Map<string, NodePos> {
|
||||||
|
const pos = new Map<string, NodePos>()
|
||||||
|
const cx = width / 2
|
||||||
|
const cy = height / 2
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
const angle = (2 * Math.PI * i) / Math.max(nodes.length, 1)
|
||||||
|
const r = Math.min(width, height) * 0.28
|
||||||
|
pos.set(n.id, { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), vx: 0, vy: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let iter = 0; iter < iterations; iter++) {
|
||||||
|
const alpha = 1 - iter / iterations
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const a = pos.get(nodes[i].id)!
|
||||||
|
const b = pos.get(nodes[j].id)!
|
||||||
|
let dx = a.x - b.x
|
||||||
|
let dy = a.y - b.y
|
||||||
|
const dist = Math.max(Math.hypot(dx, dy), 1)
|
||||||
|
const force = (800 * alpha) / dist
|
||||||
|
dx = (dx / dist) * force
|
||||||
|
dy = (dy / dist) * force
|
||||||
|
a.vx += dx
|
||||||
|
a.vy += dy
|
||||||
|
b.vx -= dx
|
||||||
|
b.vy -= dy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const e of edges) {
|
||||||
|
const a = pos.get(e.source)
|
||||||
|
const b = pos.get(e.target)
|
||||||
|
if (!a || !b) continue
|
||||||
|
let dx = b.x - a.x
|
||||||
|
let dy = b.y - a.y
|
||||||
|
const dist = Math.max(Math.hypot(dx, dy), 1)
|
||||||
|
const force = (dist - 90) * 0.04 * alpha
|
||||||
|
dx = (dx / dist) * force
|
||||||
|
dy = (dy / dist) * force
|
||||||
|
a.vx += dx
|
||||||
|
a.vy += dy
|
||||||
|
b.vx -= dx
|
||||||
|
b.vy -= dy
|
||||||
|
}
|
||||||
|
for (const n of nodes) {
|
||||||
|
const p = pos.get(n.id)!
|
||||||
|
p.vx += (cx - p.x) * 0.002 * alpha
|
||||||
|
p.vy += (cy - p.y) * 0.002 * alpha
|
||||||
|
p.vx *= 0.85
|
||||||
|
p.vy *= 0.85
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.x = Math.max(40, Math.min(width - 40, p.x))
|
||||||
|
p.y = Math.max(40, Math.min(height - 40, p.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCount(n?: number) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Neo4jGraphView() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [size, setSize] = useState({ w: 800, h: 520 })
|
||||||
|
const [data, setData] = useState<GraphResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [relType, setRelType] = useState<string>('')
|
||||||
|
const [limit, setLimit] = useState(60)
|
||||||
|
const [selected, setSelected] = useState<GraphNode | null>(null)
|
||||||
|
const [zoom, setZoom] = useState(1)
|
||||||
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
|
const dragRef = useRef<{ kind: 'pan' | 'node'; id?: string; sx: number; sy: number; ox: number; oy: number } | null>(null)
|
||||||
|
const [dragPos, setDragPos] = useState<Map<string, NodePos> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
setSize({ w: el.clientWidth || 800, h: el.clientHeight || 520 })
|
||||||
|
})
|
||||||
|
ro.observe(el)
|
||||||
|
setSize({ w: el.clientWidth || 800, h: el.clientHeight || 520 })
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (relType) q.set('rel_type', relType)
|
||||||
|
const r = await fetch(`/api/sql/graph/neo4j?${q}`)
|
||||||
|
const j: GraphResponse = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'Graph load failed')
|
||||||
|
setData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData(j)
|
||||||
|
setSelected(null)
|
||||||
|
setDragPos(null)
|
||||||
|
} catch {
|
||||||
|
setError('Graph API unavailable')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [limit, relType])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
if (!data?.nodes?.length) return new Map<string, NodePos>()
|
||||||
|
if (dragPos) return dragPos
|
||||||
|
return layoutForce(data.nodes, data.edges || [], size.w, size.h)
|
||||||
|
}, [data, size.w, size.h, dragPos])
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent, kind: 'pan' | 'node', id?: string) => {
|
||||||
|
if (kind === 'node' && id) {
|
||||||
|
const node = data?.nodes.find((n) => n.id === id)
|
||||||
|
if (node) setSelected(node)
|
||||||
|
}
|
||||||
|
dragRef.current = {
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
sx: e.clientX,
|
||||||
|
sy: e.clientY,
|
||||||
|
ox: kind === 'pan' ? pan.x : positions.get(id!)?.x ?? 0,
|
||||||
|
oy: kind === 'pan' ? pan.y : positions.get(id!)?.y ?? 0,
|
||||||
|
}
|
||||||
|
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
const d = dragRef.current
|
||||||
|
if (!d) return
|
||||||
|
const dx = e.clientX - d.sx
|
||||||
|
const dy = e.clientY - d.sy
|
||||||
|
if (d.kind === 'pan') {
|
||||||
|
setPan({ x: d.ox + dx, y: d.oy + dy })
|
||||||
|
} else if (d.id) {
|
||||||
|
const next = new Map(positions)
|
||||||
|
const p = next.get(d.id)
|
||||||
|
if (p) {
|
||||||
|
next.set(d.id, { ...p, x: d.ox + dx / zoom, y: d.oy + dy / zoom, vx: 0, vy: 0 })
|
||||||
|
setDragPos(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = () => { dragRef.current = null }
|
||||||
|
|
||||||
|
const relTypes = data?.rel_types || ['SUPPLIES', 'RELATED_TO', 'PART_OF', 'COMPATIBLE_WITH']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-pink-400">
|
||||||
|
<Network className="h-4 w-4" /> Graph Explorer
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={relType}
|
||||||
|
onChange={(e) => setRelType(e.target.value)}
|
||||||
|
className="rounded border border-border bg-background px-2 py-1 text-[10px] text-foreground"
|
||||||
|
>
|
||||||
|
<option value="">All relationships</option>
|
||||||
|
{relTypes.map((rt) => <option key={rt} value={rt}>{rt}</option>)}
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-1.5 text-[10px] text-foreground-muted">
|
||||||
|
Edges
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={20}
|
||||||
|
max={120}
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(Number(e.target.value))}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<span className="font-mono">{limit}</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={load} disabled={loading} className={cn('inline-flex items-center gap-1 rounded px-2 py-1 text-[10px]', subTabIdle)}>
|
||||||
|
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button type="button" onClick={() => setZoom((z) => Math.min(2.5, z + 0.15))} className={cn('rounded p-1', subTabIdle)} title="Zoom in"><ZoomIn className="h-3.5 w-3.5" /></button>
|
||||||
|
<button type="button" onClick={() => setZoom((z) => Math.max(0.4, z - 0.15))} className={cn('rounded p-1', subTabIdle)} title="Zoom out"><ZoomOut className="h-3.5 w-3.5" /></button>
|
||||||
|
<span className="font-mono text-[9px] text-foreground-faint">{Math.round(zoom * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.stats && (
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2 border-b border-border/40 px-3 py-1.5">
|
||||||
|
<Badge variant="default">DB total: {fmtCount(data.stats.total_nodes_db)} nodes</Badge>
|
||||||
|
<Badge variant="accent">Shown: {data.stats.returned_nodes} nodes · {data.stats.returned_edges} edges</Badge>
|
||||||
|
{data.elapsed_ms != null && <Badge variant="default">{data.elapsed_ms}ms</Badge>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
{/* Canvas */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative min-h-0 min-w-0 flex-1 cursor-grab bg-[#0a0e14] active:cursor-grabbing"
|
||||||
|
onPointerDown={(e) => { if (e.target === e.currentTarget) onPointerDown(e, 'pan') }}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0e14]/70">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-pink-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center text-[12px] text-danger">{error}</div>
|
||||||
|
)}
|
||||||
|
{!loading && data?.nodes?.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-[11px] text-foreground-faint">No graph data — try another filter</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<svg width={size.w} height={size.h} className="block">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow-supplies" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L8,3 L0,6 Z" fill="#f472b6" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<g transform={`translate(${pan.x},${pan.y}) scale(${zoom})`}>
|
||||||
|
{/* 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 (
|
||||||
|
<g key={e.id}>
|
||||||
|
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={col} strokeWidth={1.5} strokeOpacity={0.65} markerEnd="url(#arrow-supplies)" />
|
||||||
|
<text x={mx} y={my - 4} textAnchor="middle" fill={col} fontSize={8} opacity={0.9}>{e.type}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* 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 (
|
||||||
|
<g
|
||||||
|
key={n.id}
|
||||||
|
transform={`translate(${p.x},${p.y})`}
|
||||||
|
onPointerDown={(ev) => { ev.stopPropagation(); onPointerDown(ev, 'node', n.id) }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
r={r}
|
||||||
|
fill={style.fill}
|
||||||
|
stroke={isSel ? '#fff' : style.stroke}
|
||||||
|
strokeWidth={isSel ? 2.5 : 1.5}
|
||||||
|
filter={isSel ? 'drop-shadow(0 0 6px rgba(244,114,182,0.6))' : undefined}
|
||||||
|
/>
|
||||||
|
<text y={4} textAnchor="middle" fill={style.text} fontSize={9} fontWeight={600}>
|
||||||
|
{n.label === 'Product' ? 'P' : 'S'}
|
||||||
|
</text>
|
||||||
|
<text y={r + 12} textAnchor="middle" fill="#94a3b8" fontSize={8}>
|
||||||
|
{(n.name || '').slice(0, 14)}{(n.name || '').length > 14 ? '…' : ''}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute bottom-3 left-3 rounded-lg border border-border/60 bg-surface/90 p-2 text-[9px] backdrop-blur-sm">
|
||||||
|
<p className="mb-1 font-semibold uppercase tracking-wider text-foreground-faint">Legend</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{Object.entries(NODE_COLORS).map(([label, c]) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ background: c.stroke }} />
|
||||||
|
<span className="text-foreground-muted">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.entries(EDGE_COLORS).map(([label, c]) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<span className="h-0.5 w-3" style={{ background: c }} />
|
||||||
|
<span className="text-foreground-muted">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
<aside className="flex w-[260px] shrink-0 flex-col border-l border-border/60 bg-surface-raised/50">
|
||||||
|
<div className="shrink-0 border-b border-border/60 px-3 py-2">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Node details</p>
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-1 text-[12px] font-semibold text-foreground">{selected.name}</p>
|
||||||
|
<Badge variant="accent" className="mb-2">{selected.label}</Badge>
|
||||||
|
<p className="mb-2 font-mono text-[9px] text-foreground-faint">{selected.id}</p>
|
||||||
|
<table className="w-full text-left font-mono text-[9px]">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(selected.properties).map(([k, v]) => (
|
||||||
|
<tr key={k} className="border-b border-border/30">
|
||||||
|
<td className="py-1 pr-2 text-docker">{k}</td>
|
||||||
|
<td className="max-w-[120px] truncate py-1 text-foreground-muted">{v == null ? 'NULL' : String(v)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-foreground-faint">Click a node to inspect its properties and relationships.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Activity, Boxes, Database, Network } from 'lucide-react'
|
|||||||
|
|
||||||
export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
||||||
|
|
||||||
export type SourceSubTab = 'browser' | 'console' | 'shell'
|
export type SourceSubTab = 'browser' | 'console' | 'shell' | 'graph'
|
||||||
|
|
||||||
export type CatalogObject = {
|
export type CatalogObject = {
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user