Files
atc-agents/ui/src/components/features/SqlWorkbench.tsx
T
mo a4c9b60079 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
2026-06-27 15:42:37 +00:00

238 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
type Sample = { id: string; label: string; sql: string }
type SqlResult = {
ok: boolean
columns?: string[]
rows?: unknown[][]
row_count?: number
elapsed_ms?: number
error?: string
sql?: string
}
type Engine = SourceEngine | 'trino'
type Props = {
engine: Engine
compact?: boolean
}
const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string; queryLabel: string }> = {
postgres: {
title: 'PostgreSQL SQL Console',
sub: 'DB Vault · 10.0.21.51:5432 · user mo',
accent: 'text-sky-400',
queryLabel: 'SQL',
},
mysql: {
title: 'MySQL SQL Console',
sub: 'DB Vault · 10.0.21.51:3306 · database hr · user mo',
accent: 'text-amber-400',
queryLabel: 'SQL',
},
mongodb: {
title: 'MongoDB Query Console',
sub: 'DB Vault · 10.0.21.51:27017 · supplychain',
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',
accent: 'text-violet-400',
queryLabel: 'SQL',
},
}
export function SqlWorkbench({ engine, compact }: Props) {
const meta = ENGINE_META[engine]
const [samples, setSamples] = useState<Sample[]>([])
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 }
} | null>(null)
const [loading, setLoading] = useState(false)
const [benchLoading, setBenchLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadSamples = useCallback(async () => {
const r = await fetch(`/api/sql/samples/${engine}`)
if (r.ok) {
const j = await r.json()
setSamples(j.samples || [])
if (j.samples?.[0]) setSql(j.samples[0].sql)
}
}, [engine])
useEffect(() => {
loadSamples()
setResult(null)
setBenchmark(null)
setError(null)
}, [engine, loadSamples])
const run = async (query?: string) => {
const q = (query ?? sql).trim()
if (!q) return
setLoading(true)
setError(null)
try {
const r = await fetch('/api/sql/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ engine, sql: q }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Query failed')
setResult(null)
return
}
setSql(q)
setResult(j)
} catch {
setError('SQL API unavailable')
} finally {
setLoading(false)
}
}
const runBenchmark = async () => {
setBenchLoading(true)
setError(null)
try {
const r = await fetch('/api/sql/benchmark', { method: 'POST' })
const j = await r.json()
if (!r.ok) {
setError(j.error || 'Benchmark failed')
return
}
setBenchmark(j)
} catch {
setError('Benchmark failed')
} finally {
setBenchLoading(false)
}
}
return (
<div className="flex h-full min-h-0 flex-col text-foreground">
<header className={cn('flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border/60', compact ? 'px-2 py-1' : 'px-3 py-2')}>
<div>
<h3 className={cn('flex items-center gap-1.5 font-semibold', meta.accent, compact ? 'text-xs' : 'text-sm')}>
<Database className={compact ? 'h-3 w-3' : 'h-4 w-4'} />
{meta.title}
</h3>
<p className="text-[9px] text-foreground-muted">{meta.sub}</p>
</div>
<div className="flex gap-1.5">
<button type="button" onClick={() => run()} disabled={loading} className={cn('inline-flex items-center gap-1 rounded px-2 py-1 text-[10px]', subTabActive)}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
Run
</button>
{(engine === 'postgres' || engine === 'trino') && (
<button type="button" onClick={runBenchmark} disabled={benchLoading} className={cn('inline-flex items-center gap-1 rounded px-2 py-1 text-[10px]', subTabIdle)}>
{benchLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3 text-amber-400" />}
PG vs Trino
</button>
)}
</div>
</header>
<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">Demo queries</p>
<div className="scrollbar-thin min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{samples.map((s) => (
<button
key={s.id}
type="button"
onClick={() => { setSql(s.sql); run(s.sql) }}
className="block w-full rounded border border-transparent px-1.5 py-0.5 text-left text-[9px] hover:border-docker/30 hover:bg-docker/5"
>
<span className="font-medium text-foreground">{s.label}</span>
</button>
))}
</div>
</aside>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<textarea
value={sql}
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-[72px] max-h-[140px]',
)}
spellCheck={false}
placeholder={`${meta.queryLabel}…`}
/>
{error && <p className="shrink-0 px-2 py-0.5 text-[10px] text-danger">{error}</p>}
{benchmark?.comparison && (
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[10px]">
<span className="font-semibold text-amber-300">Benchmark: </span>
PG <span className="font-mono">{benchmark.comparison.postgres_ms}ms</span>
{' · '}Trino <span className="font-mono">{benchmark.comparison.trino_ms}ms</span>
{' — '}
<span className="font-semibold text-success">
{benchmark.comparison.faster} {benchmark.comparison.speedup_factor ? `${benchmark.comparison.speedup_factor}×` : ''}
</span>
</div>
)}
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-1.5">
{result?.ok && result.columns && (
<>
<p className="mb-0.5 font-mono text-[8px] text-foreground-faint">
{result.row_count} rows · {result.elapsed_ms}ms
</p>
<table className="w-full text-left font-mono text-[9px]">
<thead>
<tr className="border-b border-border text-docker">
{result.columns.map((c) => <th key={c} className="px-1 py-0.5">{c}</th>)}
</tr>
</thead>
<tbody>
{result.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-[160px] truncate px-1 py-0.5 text-foreground-muted">
{cell === null ? 'NULL' : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
)}
{!result && !loading && (
<p className="py-3 text-center text-[10px] text-foreground-faint">Pick a command or write {meta.queryLabel}</p>
)}
</div>
</div>
</div>
</div>
)
}