Files
atc-agents/ui/src/components/features/DataSourcesView.tsx
T

326 lines
14 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from 'react'
import {
Activity,
ChevronRight,
Database,
FolderTree,
Loader2,
Network,
RefreshCw,
Server,
Table2,
TerminalSquare,
} from 'lucide-react'
import { Badge } from '../ui/Badge'
import { DbShell } from './DbShell'
import { Neo4jGraphView } from './Neo4jGraphView'
import { SqlWorkbench } from './SqlWorkbench'
import {
getSourceMeta,
SOURCE_CATALOG,
type CatalogObject,
type SourceEngine,
type SourceSubTab,
} from '../../lib/dataSourceCatalog'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type HealthMap = Record<string, { ok: boolean; error?: string | null }>
type CatalogResponse = {
engine: string
version?: string
total_nodes?: number
objects: CatalogObject[]
}
type SampleResponse = {
ok: boolean
columns?: string[]
rows?: unknown[][]
row_count?: number
elapsed_ms?: number
error?: string
}
type Props = {
focusEngine?: SourceEngine | null
}
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 },
]
function fmtCount(n?: number | null) {
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 DataSourcesView({ focusEngine }: Props) {
const [active, setActive] = useState<SourceEngine>(focusEngine || 'postgres')
const [subTab, setSubTab] = useState<SourceSubTab>('browser')
const [health, setHealth] = useState<HealthMap>({})
const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [selectedObject, setSelectedObject] = useState<CatalogObject | null>(null)
const [sample, setSample] = useState<SampleResponse | null>(null)
const [sampleLoading, setSampleLoading] = useState(false)
const meta = getSourceMeta(active)
useEffect(() => {
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')
if (r.ok) setHealth(await r.json())
} catch { /* */ }
}, [])
const loadCatalog = useCallback(async (engine: SourceEngine) => {
setCatalogLoading(true)
setCatalog(null)
setSelectedObject(null)
setSample(null)
try {
const r = await fetch(`/api/sql/catalog/${engine}`)
if (r.ok) {
const j: CatalogResponse = await r.json()
setCatalog(j)
const first = j.objects?.find((o) => o.type === 'table' || o.type === 'collection' || o.type === 'node_label')
if (first) setSelectedObject(first)
}
} catch { /* */ } finally {
setCatalogLoading(false)
}
}, [])
const loadSample = useCallback(async (engine: SourceEngine, obj: CatalogObject) => {
setSampleLoading(true)
setSample(null)
try {
const r = await fetch(`/api/sql/sample/${engine}?object=${encodeURIComponent(obj.fqn)}&limit=50`)
const j = await r.json()
setSample(j)
} catch {
setSample({ ok: false, error: 'Sample API unavailable' })
} finally {
setSampleLoading(false)
}
}, [])
useEffect(() => { loadHealth() }, [loadHealth])
useEffect(() => {
if (subTab === 'browser') loadCatalog(active)
}, [active, subTab, loadCatalog])
useEffect(() => {
if (selectedObject && subTab === 'browser') loadSample(active, selectedObject)
}, [selectedObject, active, subTab, loadSample])
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 */}
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
<Server className="h-5 w-5 text-docker" />
Data Sources UI
</h1>
<p className="text-[11px] text-foreground-muted">
Enterprise data browser schema exploration, query console &amp; interactive shells for all source databases
</p>
</div>
<button type="button" onClick={refreshAll} className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</header>
<div className="flex min-h-0 flex-1 gap-2 overflow-hidden">
{/* Left rail — database cards */}
<aside className="panel flex w-[220px] shrink-0 flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-3 py-2">
<p className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Source Databases</p>
</div>
<div className="scrollbar-thin min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{SOURCE_CATALOG.map((src) => {
const Icon = src.icon
const up = health[src.engine]?.ok
const selected = active === src.engine
return (
<button
key={src.engine}
type="button"
onClick={() => { setActive(src.engine); setSubTab('browser') }}
className={cn(
'flex w-full flex-col gap-1 rounded-lg border p-2.5 text-left transition-all',
selected ? cn(src.border, src.accentBg, 'shadow-sm') : 'border-transparent hover:border-border hover:bg-surface-overlay',
)}
>
<div className="flex items-center justify-between">
<span className={cn('flex items-center gap-1.5 text-[12px] font-semibold', selected ? src.accent : 'text-foreground')}>
<Icon className="h-4 w-4" />
{src.label}
</span>
<span className={cn('h-2 w-2 rounded-full', up === true ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]' : up === false ? 'bg-red-400' : 'bg-foreground-faint')} title={up ? 'Online' : up === false ? 'Offline' : 'Unknown'} />
</div>
<p className="text-[9px] leading-snug text-foreground-muted">{src.description}</p>
<div className="flex flex-wrap gap-1">
<Badge variant="default">{src.host}:{src.port}</Badge>
{src.cdc && <Badge variant="accent">CDC</Badge>}
</div>
</button>
)
})}
</div>
</aside>
{/* Main panel */}
<div className="panel flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{/* Engine header */}
<div className={cn('flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5', meta.accentBg)}>
<div>
<h2 className={cn('flex items-center gap-2 text-sm font-semibold', meta.accent)}>
<meta.icon className="h-4 w-4" />
{meta.label}
{catalog?.version && (
<span className="font-mono text-[10px] font-normal text-foreground-muted">v{catalog.version.split(' ')[0]?.slice(0, 20)}</span>
)}
</h2>
<p className="font-mono text-[10px] text-foreground-muted">
{meta.host}:{meta.port} · {meta.database} · container {meta.container}
</p>
</div>
<div className="flex gap-1">
{visibleSubTabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setSubTab(id)}
className={cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-medium', subTab === id ? subTabActive : subTabIdle)}
>
<Icon className="h-3 w-3" /> {label}
</button>
))}
</div>
</div>
{/* Sub-tab content */}
<div className="min-h-0 flex-1 overflow-hidden">
{subTab === 'browser' && (
<div className="flex h-full min-h-0">
{/* Object tree */}
<div className="flex w-[280px] shrink-0 flex-col border-r border-border/60">
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Objects</span>
{catalogLoading && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-1.5">
{catalog?.objects?.map((obj) => (
<button
key={obj.fqn}
type="button"
onClick={() => setSelectedObject(obj)}
className={cn(
'mb-0.5 flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-[10px] transition-colors',
selectedObject?.fqn === obj.fqn ? subTabActive : 'hover:bg-surface-overlay',
)}
>
{obj.type === 'node_label' ? <Activity className="h-3 w-3 shrink-0 text-pink-400" /> : <Table2 className="h-3 w-3 shrink-0 text-foreground-muted" />}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{obj.name}</p>
<p className="truncate font-mono text-[8px] text-foreground-faint">{obj.schema}{obj.type === 'relationship' ? ' · rel' : ''}</p>
</div>
<span className="shrink-0 font-mono text-[9px] text-foreground-muted">{fmtCount(obj.row_count)}</span>
<ChevronRight className="h-3 w-3 shrink-0 text-foreground-faint" />
</button>
))}
{!catalogLoading && !catalog?.objects?.length && (
<p className="p-4 text-center text-[10px] text-foreground-faint">No objects found</p>
)}
</div>
</div>
{/* Sample data grid */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-[10px] font-semibold text-foreground">
{selectedObject ? (
<>Sample: <span className="font-mono text-docker">{selectedObject.fqn}</span></>
) : 'Select an object'}
</span>
{sampleLoading && <Loader2 className="h-3 w-3 animate-spin" />}
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-2">
{sample?.ok && sample.columns && (
<>
<p className="mb-1 font-mono text-[9px] text-foreground-faint">
{sample.row_count} rows · {sample.elapsed_ms}ms
</p>
<table className="w-full text-left font-mono text-[10px]">
<thead>
<tr className="sticky top-0 border-b border-border bg-surface-raised text-docker">
{sample.columns.map((c) => <th key={c} className="px-2 py-1">{c}</th>)}
</tr>
</thead>
<tbody>
{sample.rows?.map((row, i) => (
<tr key={i} className="border-b border-border/30 hover:bg-white/5">
{row.map((cell, j) => (
<td key={j} className="max-w-[200px] truncate px-2 py-1 text-foreground-muted">
{cell === null || cell === undefined ? 'NULL' : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
)}
{sample && !sample.ok && (
<p className="p-4 text-[11px] text-danger">{sample.error || 'Failed to load sample'}</p>
)}
{!selectedObject && !sampleLoading && (
<p className="py-8 text-center text-[11px] text-foreground-faint">Select a table, collection or label to preview data</p>
)}
</div>
</div>
</div>
)}
{subTab === 'graph' && active === 'neo4j' && (
<Neo4jGraphView />
)}
{subTab === 'console' && (
<SqlWorkbench engine={active} />
)}
{subTab === 'shell' && (
<DbShell initialCommand={meta.shellCommand} />
)}
</div>
</div>
</div>
</div>
)
}