feat(ui): Data Sources UI — enterprise browser for all 5 source databases

New "Data Sources UI" tab with per-database Browser (catalog + sample data),
Query Console (SqlWorkbench) and embedded interactive Shell (DbShell via SSH).

Backend:
- Extend sql_console.py with Cassandra (CQL) + Neo4j (Cypher) engines
- Add GET /api/sql/catalog/{engine} and GET /api/sql/sample/{engine}
- ssh_terminal: optional initial_command for auto-launching DB CLIs

Frontend:
- DataSourcesView with 5-DB rail, health dots, Browser/Console/Shell sub-tabs
- DbShell embedded xterm terminal with docker exec CLI per engine
- Deep-link topology DB nodes to Data Sources UI (no SQL dock on platform)
- WorkbenchPanel restricted to agent mode only — frees dashboard space
This commit is contained in:
mo
2026-06-27 15:42:37 +00:00
parent b8a5b10bc4
commit a4c9b60079
10 changed files with 1027 additions and 76 deletions
+7 -3
View File
@@ -21,6 +21,7 @@ import { DataGenView } from './components/features/DataGenView'
import { ChangesView } from './components/features/ChangesView'
import { DataFlowView } from './components/features/DataFlowView'
import { SearchView } from './components/features/SearchView'
import { DataSourcesView } from './components/features/DataSourcesView'
import { SshTerminal } from './components/features/SshTerminal'
import { TerminalDock } from './components/features/TerminalDock'
import { WorkbenchPanel } from './components/features/WorkbenchPanel'
@@ -38,6 +39,7 @@ export default function App() {
const mainScrollRef = useRef<HTMLDivElement>(null)
const isPlatform = cc.mainView === 'platform'
const isDataSources = cc.mainView === 'datasources'
const openApprovals = () => {
cc.setMainView('approvals')
@@ -79,9 +81,9 @@ export default function App() {
onOpenSsh={() => setSshOpen(true)}
/>
<div ref={mainScrollRef} className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', isPlatform ? 'overflow-hidden' : 'scrollbar-thin overflow-y-auto')}>
<div ref={mainScrollRef} className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', (isPlatform || isDataSources) ? 'overflow-hidden' : 'scrollbar-thin overflow-y-auto')}>
<div className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
<div className={cn('flex min-h-0 min-w-0 flex-1 flex-col', isPlatform ? 'gap-1 overflow-hidden p-1.5' : 'min-h-0 gap-2 p-3')}>
<div className={cn('flex min-h-0 min-w-0 flex-1 flex-col', (isPlatform || isDataSources) ? 'gap-1 overflow-hidden p-1.5' : 'min-h-0 gap-2 p-3')}>
{isPlatform && (
<>
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
@@ -119,7 +121,7 @@ export default function App() {
</>
)}
<div className={cn('flex min-h-0 flex-col', isPlatform ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
<div className={cn('flex min-h-0 flex-col', (isPlatform || isDataSources) ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
{cc.mainView === 'platform' ? (
<PlatformTopology
workload={cc.workload}
@@ -128,6 +130,8 @@ export default function App() {
onNodeClick={cc.selectNode}
pulse={cc.genPulse}
/>
) : cc.mainView === 'datasources' ? (
<DataSourcesView focusEngine={cc.dataSourceFocus} />
) : cc.mainView === 'datagen' ? (
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
) : cc.mainView === 'changes' ? (
@@ -0,0 +1,311 @@
import { useCallback, useEffect, useState } from 'react'
import {
Activity,
ChevronRight,
Database,
FolderTree,
Loader2,
RefreshCw,
Server,
Table2,
TerminalSquare,
} from 'lucide-react'
import { Badge } from '../ui/Badge'
import { DbShell } from './DbShell'
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 }[] = [
{ id: 'browser', label: 'Browser', icon: FolderTree },
{ 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])
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 (selectedObject) loadSample(active, selectedObject)
}
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">
{SUB_TABS.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 === 'console' && (
<SqlWorkbench engine={active} />
)}
{subTab === 'shell' && (
<DbShell initialCommand={meta.shellCommand} />
)}
</div>
</div>
</div>
</div>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Plug, RotateCcw, TerminalSquare } from 'lucide-react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Props = {
host?: string
port?: string
username?: string
initialCommand?: string
className?: string
}
type ConnState = 'form' | 'connecting' | 'connected' | 'closed'
export function DbShell({
host = '10.0.21.51',
port = '22',
username = 'root',
initialCommand,
className,
}: Props) {
const [password, setPassword] = useState('')
const [state, setState] = useState<ConnState>('form')
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const [remember, setRemember] = useState(false)
const termRef = useRef<HTMLDivElement | null>(null)
const term = useRef<Terminal | null>(null)
const fit = useRef<FitAddon | null>(null)
const ws = useRef<WebSocket | null>(null)
const teardown = useCallback(() => {
try { ws.current?.close() } catch { /* */ }
ws.current = null
try { term.current?.dispose() } catch { /* */ }
term.current = null
fit.current = null
}, [])
const connect = useCallback(() => {
if (!password) return
setState('connecting')
setStatusMsg(`Connecting to ${username}@${host}:${port}`)
const t = new Terminal({
cursorBlink: true,
fontSize: 12,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#0a0e14', foreground: '#d6deeb', cursor: '#7ee787' },
})
const fitAddon = new FitAddon()
t.loadAddon(fitAddon)
term.current = t
fit.current = fitAddon
requestAnimationFrame(() => {
if (!termRef.current) return
t.open(termRef.current)
try { fitAddon.fit() } catch { /* */ }
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws/ssh`)
ws.current = socket
socket.onopen = () => {
socket.send(JSON.stringify({
type: 'connect',
host,
port: Number(port) || 22,
username,
password,
cols: t.cols,
rows: t.rows,
initial_command: initialCommand,
}))
}
socket.onmessage = (ev) => {
let msg: { type?: string; data?: string; message?: string }
try { msg = JSON.parse(ev.data) } catch { return }
if (msg.type === 'data') {
t.write(msg.data || '')
} else if (msg.type === 'status') {
setStatusMsg(msg.message || null)
} else if (msg.type === 'connected') {
setState('connected')
setStatusMsg(null)
if (remember) sessionStorage.setItem('ds-ssh-pass', password)
t.focus()
} else if (msg.type === 'error') {
setState('closed')
setStatusMsg(msg.message || 'Error')
t.writeln(`\r\n\x1b[31m${msg.message}\x1b[0m`)
} else if (msg.type === 'closed') {
setState('closed')
t.writeln('\r\n\x1b[33m*** Session closed ***\x1b[0m')
}
}
socket.onclose = () => {
setState((s) => (s === 'connected' ? 'closed' : s))
}
t.onData((d) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'data', data: d }))
})
})
}, [host, port, username, password, initialCommand, remember])
const disconnect = useCallback(() => {
try { ws.current?.send(JSON.stringify({ type: 'disconnect' })) } catch { /* */ }
teardown()
setState('form')
setStatusMsg(null)
}, [teardown])
useEffect(() => {
const saved = sessionStorage.getItem('ds-ssh-pass')
if (saved) setPassword(saved)
}, [])
useEffect(() => {
if (state !== 'connected' && state !== 'closed') return
const onResize = () => {
try {
fit.current?.fit()
const tt = term.current
if (tt && ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ type: 'resize', cols: tt.cols, rows: tt.rows }))
}
} catch { /* */ }
}
const ro = new ResizeObserver(onResize)
if (termRef.current) ro.observe(termRef.current)
window.addEventListener('resize', onResize)
onResize()
return () => { ro.disconnect(); window.removeEventListener('resize', onResize) }
}, [state])
useEffect(() => () => teardown(), [teardown])
return (
<div className={cn('flex h-full min-h-0 flex-col bg-[#0a0e14]', className)}>
<header className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-foreground">
<TerminalSquare className="h-4 w-4 text-emerald-400" />
Interactive Shell
{state === 'connected' && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-normal text-emerald-400">live</span>
)}
{initialCommand && (
<span className="max-w-[420px] truncate font-mono text-[9px] font-normal text-foreground-muted" title={initialCommand}>
{initialCommand}
</span>
)}
</span>
{(state === 'connected' || state === 'closed') && (
<button type="button" onClick={disconnect} className="text-foreground-muted hover:text-foreground" title="Disconnect">
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</header>
{state === 'form' ? (
<form
className="flex flex-1 flex-col justify-center gap-3 p-6"
onSubmit={(e) => { e.preventDefault(); connect() }}
>
<p className="text-[11px] text-foreground-muted">
SSH to <span className="font-mono text-foreground">{username}@{host}:{port}</span> and launch the database CLI.
Password is used for this session only.
</p>
<label className="flex max-w-sm flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
SSH Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
className="rounded border border-border bg-background px-3 py-2 text-[12px] text-foreground"
placeholder="root password for DB Vault"
/>
</label>
<label className="flex items-center gap-2 text-[10px] text-foreground-muted">
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
Remember for this browser session
</label>
<button
type="submit"
disabled={!password}
className={cn('inline-flex max-w-sm items-center justify-center gap-2 rounded-md px-4 py-2 text-[12px] font-semibold', subTabActive, 'disabled:opacity-40')}
>
<Plug className="h-4 w-4" /> Connect &amp; Launch CLI
</button>
</form>
) : (
<div className="relative min-h-0 flex-1">
{state === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0e14]/80 text-[12px] text-foreground-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {statusMsg || 'Connecting…'}
</div>
)}
<div ref={termRef} className="h-full p-2" />
</div>
)}
</div>
)
}
+18 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Database, Loader2, Play, Zap } from 'lucide-react'
import type { SourceEngine } from '../../lib/dataSourceCatalog'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
@@ -14,7 +15,7 @@ type SqlResult = {
sql?: string
}
type Engine = 'postgres' | 'mysql' | 'mongodb' | 'trino'
type Engine = SourceEngine | 'trino'
type Props = {
engine: Engine
@@ -25,7 +26,7 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
postgres: {
title: 'PostgreSQL SQL Console',
sub: 'DB Vault · 10.0.21.51:5432 · user mo',
accent: 'text-blue-400',
accent: 'text-sky-400',
queryLabel: 'SQL',
},
mysql: {
@@ -40,6 +41,18 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
accent: 'text-emerald-400',
queryLabel: 'Command',
},
cassandra: {
title: 'Cassandra CQL Console',
sub: 'DB Vault · 10.0.21.51:9042 · keyspace telemetry',
accent: 'text-cyan-400',
queryLabel: 'CQL',
},
neo4j: {
title: 'Neo4j Cypher Console',
sub: 'DB Vault · 10.0.21.51:7687 · graph database',
accent: 'text-pink-400',
queryLabel: 'Cypher',
},
trino: {
title: 'Trino SQL Console',
sub: 'Lakehouse · 10.0.21.50:8089 · federated queries',
@@ -51,7 +64,7 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
export function SqlWorkbench({ engine, compact }: Props) {
const meta = ENGINE_META[engine]
const [samples, setSamples] = useState<Sample[]>([])
const [sql, setSql] = useState('SELECT version();')
const [sql, setSql] = useState('')
const [result, setResult] = useState<SqlResult | null>(null)
const [benchmark, setBenchmark] = useState<{
comparison?: { postgres_ms?: number; trino_ms?: number; faster?: string; speedup_factor?: number }
@@ -146,7 +159,7 @@ export function SqlWorkbench({ engine, compact }: Props) {
<div className="flex min-h-0 flex-1 overflow-hidden">
<aside className={cn('flex min-h-0 shrink-0 flex-col border-r border-border/60 p-1.5', compact ? 'w-44' : 'w-52')}>
<p className="mb-0.5 text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">10 demo commands</p>
<p className="mb-0.5 text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">Demo queries</p>
<div className="scrollbar-thin min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{samples.map((s) => (
<button
@@ -167,7 +180,7 @@ export function SqlWorkbench({ engine, compact }: Props) {
onChange={(e) => setSql(e.target.value)}
className={cn(
'shrink-0 resize-y border-b border-border/60 bg-[#0d1117] p-1.5 font-mono text-[10px] text-emerald-100 outline-none focus:ring-1 focus:ring-docker/40',
compact ? 'min-h-[48px] max-h-[96px]' : 'min-h-[56px] max-h-[120px]',
compact ? 'min-h-[48px] max-h-[96px]' : 'min-h-[72px] max-h-[140px]',
)}
spellCheck={false}
placeholder={`${meta.queryLabel}`}
+3 -10
View File
@@ -2,10 +2,9 @@ import { useEffect, useRef, useState } from 'react'
import { GripHorizontal } from 'lucide-react'
import type { Agent, TerminalLine } from '../../types'
import { AgentWorkbench } from './AgentWorkbench'
import { SqlWorkbench } from './SqlWorkbench'
type Props = {
mode: 'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null
mode: 'agent' | null
agent: Agent | null
lines: TerminalLine[]
busy: boolean
@@ -38,7 +37,7 @@ export function WorkbenchPanel({ mode, agent, lines, busy, onSendPrompt }: Props
}
}, [])
if (!mode) return null
if (!mode || mode !== 'agent' || !agent) return null
return (
<section
@@ -61,13 +60,7 @@ export function WorkbenchPanel({ mode, agent, lines, busy, onSendPrompt }: Props
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{mode === 'agent' && agent && (
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} compact />
)}
{mode === 'sql-postgres' && <SqlWorkbench engine="postgres" compact />}
{mode === 'sql-mysql' && <SqlWorkbench engine="mysql" compact />}
{mode === 'sql-mongodb' && <SqlWorkbench engine="mongodb" compact />}
{mode === 'sql-trino' && <SqlWorkbench engine="trino" compact />}
<AgentWorkbench agent={agent} lines={lines} busy={busy} onSendPrompt={onSendPrompt} compact />
</div>
</section>
)
+3 -2
View File
@@ -1,4 +1,4 @@
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu, Activity, GitBranch } from 'lucide-react'
import { Database, DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu, Activity, GitBranch } from 'lucide-react'
import type { GpuStatus, WorkloadData } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils'
@@ -6,7 +6,7 @@ import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
import { LabHealthPanel } from '../features/LabHealthPanel'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals' | 'changes' | 'dataflow'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals' | 'changes' | 'dataflow' | 'datasources'
type Props = {
workload: WorkloadData | null
@@ -24,6 +24,7 @@ type Props = {
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
{ id: 'datasources', label: 'Data Sources UI', icon: Database },
{ id: 'datagen', label: 'Data Generation', icon: Cpu },
{ id: 'changes', label: 'Live Changes', icon: Activity },
{ id: 'dataflow', label: 'Data Flow', icon: GitBranch },
+14 -18
View File
@@ -15,6 +15,7 @@ import {
} from '../lib/api'
import { AGENT_NODE, NODE_ALIASES, wsUrl } from '../lib/constants'
import { resolveInfraNode } from '../lib/infraCatalog'
import { resolveSourceEngine, type SourceEngine } from '../lib/dataSourceCatalog'
import type {
Agent,
AgentAnim,
@@ -37,19 +38,9 @@ function resolveProbeId(nodeId: string) {
}
const SQL_NODE_ENGINES: Record<string, 'postgres' | 'mysql' | 'mongodb' | 'trino'> = {
postgresql: 'postgres',
'src-postgres': 'postgres',
mysql: 'mysql',
'src-mysql': 'mysql',
mongodb: 'mongodb',
'src-mongo': 'mongodb',
trino: 'trino',
'query-trino': 'trino',
}
function resolveSqlEngine(nodeId: string): 'postgres' | 'mysql' | 'mongodb' | 'trino' | null {
return SQL_NODE_ENGINES[nodeId] || SQL_NODE_ENGINES[resolveProbeId(nodeId)] || null
function resolveSourceFromNode(nodeId: string): SourceEngine | null {
const probeId = resolveProbeId(nodeId)
return resolveSourceEngine(nodeId, probeId)
}
export function useCommandCenter() {
@@ -69,7 +60,7 @@ export function useCommandCenter() {
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
const [nodeBusy, setNodeBusy] = useState(false)
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'changes' | 'dataflow'>('platform')
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'changes' | 'dataflow' | 'datasources'>('platform')
const [changes, setChanges] = useState<CdcChange[]>([])
const [genPulse, setGenPulse] = useState(false)
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -79,7 +70,8 @@ export function useCommandCenter() {
genPulseTimer.current = setTimeout(() => setGenPulse(false), 60000)
}, [])
const [approvalHighlight, setApprovalHighlight] = useState(false)
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null)
const [workbenchMode, setWorkbenchMode] = useState<'agent' | null>(null)
const [dataSourceFocus, setDataSourceFocus] = useState<SourceEngine | null>(null)
const [chatExpanded, setChatExpanded] = useState(false)
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [terminalExpanded, setTerminalExpanded] = useState(true)
@@ -237,9 +229,11 @@ export function useCommandCenter() {
const stub = findNodeStub(nodeId)
if (!stub) return
const probeId = resolveProbeId(nodeId)
const sqlEng = resolveSqlEngine(nodeId)
if (sqlEng) {
setWorkbenchMode(`sql-${sqlEng}` as 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino')
const sourceEng = resolveSourceFromNode(nodeId)
if (sourceEng) {
setMainView('datasources')
setDataSourceFocus(sourceEng)
setWorkbenchMode(null)
setSelectedAgentId(null)
} else if (options?.keepWorkbench) {
setWorkbenchMode(options.keepWorkbench)
@@ -352,6 +346,8 @@ export function useCommandCenter() {
approvalHighlight,
setApprovalHighlight,
workbenchMode,
dataSourceFocus,
setDataSourceFocus,
inspectorLines,
terminalSubjectId,
terminalExpanded,
+136
View File
@@ -0,0 +1,136 @@
import type { LucideIcon } from 'lucide-react'
import { Activity, Boxes, Database, Network } from 'lucide-react'
export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
export type SourceSubTab = 'browser' | 'console' | 'shell'
export type CatalogObject = {
type: string
schema: string
name: string
fqn: string
row_count?: number | null
size_mb?: number
}
export type SourceMeta = {
engine: SourceEngine
label: string
shortLabel: string
icon: LucideIcon
accent: string
accentBg: string
border: string
host: string
port: string
database: string
container: string
shellCommand: string
description: string
cdc: boolean
}
export const SOURCE_CATALOG: SourceMeta[] = [
{
engine: 'postgres',
label: 'PostgreSQL',
shortLabel: 'PG',
icon: Database,
accent: 'text-sky-400',
accentBg: 'bg-sky-500/10',
border: 'border-sky-500/30',
host: '10.0.21.51',
port: '5432',
database: 'postgres',
container: 'postgres_sales',
shellCommand: 'docker exec -it postgres_sales psql -U mo -d postgres',
description: 'Operational sales orders — CDC source via Debezium',
cdc: true,
},
{
engine: 'mysql',
label: 'MySQL',
shortLabel: 'MY',
icon: Database,
accent: 'text-amber-400',
accentBg: 'bg-amber-500/10',
border: 'border-amber-500/30',
host: '10.0.21.51',
port: '3306',
database: 'hr',
container: 'mysql_hr',
shellCommand: 'docker exec -it mysql_hr mysql -umo -pDell2026! hr',
description: 'HR employee events — CDC source via Debezium',
cdc: true,
},
{
engine: 'mongodb',
label: 'MongoDB',
shortLabel: 'MG',
icon: Boxes,
accent: 'text-emerald-400',
accentBg: 'bg-emerald-500/10',
border: 'border-emerald-500/30',
host: '10.0.21.51',
port: '27017',
database: 'supplychain',
container: 'mongodb_supplychain',
shellCommand: 'docker exec -it mongodb_supplychain mongo supplychain',
description: 'Supply chain events — CDC source via Debezium',
cdc: true,
},
{
engine: 'cassandra',
label: 'Cassandra',
shortLabel: 'CS',
icon: Activity,
accent: 'text-cyan-400',
accentBg: 'bg-cyan-500/10',
border: 'border-cyan-500/30',
host: '10.0.21.51',
port: '9042',
database: 'telemetry',
container: 'cassandra_telemetry',
shellCommand: 'docker exec -it cassandra_telemetry cqlsh',
description: 'Device telemetry time-series — queryable via Trino',
cdc: false,
},
{
engine: 'neo4j',
label: 'Neo4j',
shortLabel: 'NJ',
icon: Network,
accent: 'text-pink-400',
accentBg: 'bg-pink-500/10',
border: 'border-pink-500/30',
host: '10.0.21.51',
port: '7687',
database: 'graph',
container: 'neo4j_graph',
shellCommand: 'docker exec -it neo4j_graph cypher-shell -u neo4j -p testpwd',
description: 'Product/supplier graph — 4.5M nodes',
cdc: false,
},
]
export const SOURCE_NODE_ENGINES: Record<string, SourceEngine> = {
postgresql: 'postgres',
'src-postgres': 'postgres',
mysql: 'mysql',
'src-mysql': 'mysql',
mongodb: 'mongodb',
'src-mongo': 'mongodb',
cassandra: 'cassandra',
'src-cassandra': 'cassandra',
neo4j: 'neo4j',
'src-neo4j': 'neo4j',
}
export function resolveSourceEngine(nodeId: string, probeId?: string): SourceEngine | null {
return SOURCE_NODE_ENGINES[nodeId] || (probeId ? SOURCE_NODE_ENGINES[probeId] : null) || null
}
export function getSourceMeta(engine: SourceEngine): SourceMeta {
return SOURCE_CATALOG.find((s) => s.engine === engine) || SOURCE_CATALOG[0]
}