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:
mo
2026-06-27 15:48:56 +00:00
parent a4c9b60079
commit 5828113f53
4 changed files with 495 additions and 3 deletions
+16 -2
View File
@@ -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 (
<div className="flex h-full min-h-0 flex-col gap-2 p-3">
{/* Header */}
@@ -200,7 +210,7 @@ export function DataSourcesView({ focusEngine }: Props) {
</p>
</div>
<div className="flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => (
{visibleSubTabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
@@ -296,6 +306,10 @@ export function DataSourcesView({ focusEngine }: Props) {
</div>
)}
{subTab === 'graph' && active === 'neo4j' && (
<Neo4jGraphView />
)}
{subTab === 'console' && (
<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>
)
}