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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user