feat: Spark Workbench everywhere, autonomous Hadoop offload & LLM masking-aware

- Data Hub with Hadoop tab (HDFS/Iceberg browser, Spark, pipeline)
- Databricks-style Lakehouse Workbench (Trino engine, live exec matrix,
  materialize to Iceberg/S3); reused & embedded in every source-DB UI
- HDFS -> Kafka -> Spark -> Iceberg/S3 pipeline; WebHDFS hostname resolver
- Data Flow master pulse switch (Run/Pause/Stop) gating animated edges
- Data Custodian autonomous Hadoop offload loop (batch counterpart to CDC),
  pulsing source -> HDFS edges; toggle in Data Flow
- LLM now autonomously aware of all latest platform changes (live platform
  context) and enforces masking policy: never reveals masked PII, still
  answers helpfully with aggregates/explanations
This commit is contained in:
mo
2026-06-27 19:37:50 +00:00
parent 5828113f53
commit 46b9c50e73
39 changed files with 5476 additions and 725 deletions
+592
View File
@@ -0,0 +1,592 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Activity,
Cpu,
Database,
ExternalLink,
Gauge,
HardDrive,
Layers,
Loader2,
Play,
Plus,
RefreshCw,
Save,
Server,
Square,
Table2,
Trash2,
Zap,
} from 'lucide-react'
import type { SparkLive, SparkRun, SparkRunStats } from '../../types'
import {
cancelSparkRun,
createSparkRun,
fetchSparkCatalogs,
fetchSparkColumns,
fetchSparkLive,
fetchSparkRun,
fetchSparkRuns,
fetchSparkSchemas,
fetchSparkTables,
} from '../../lib/api'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Tab = 'workbench' | 'cluster' | 'runs' | 'ui'
type Operation = 'preview' | 'filter' | 'aggregate' | 'profile' | 'join' | 'sql'
type Column = { name: string; type: string }
type Metric = { fn: string; col: string; alias?: string }
const OPERATIONS: { id: Operation; label: string; desc: string }[] = [
{ id: 'preview', label: 'Preview', desc: 'Sample rows from a table' },
{ id: 'filter', label: 'Filter', desc: 'WHERE predicate on a table' },
{ id: 'aggregate', label: 'Aggregate', desc: 'Group by + sum/avg/count/min/max' },
{ id: 'profile', label: 'Profile', desc: 'Row count, distinct & non-null per column' },
{ id: 'join', label: 'Join', desc: 'Join two tables on keys' },
{ id: 'sql', label: 'SQL', desc: 'Run arbitrary distributed SQL' },
]
const AGG_FNS = ['count', 'sum', 'avg', 'min', 'max', 'approx_distinct', 'count_distinct']
function fmtNum(n?: number | null) {
if (n == null) return '—'
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`
return String(n)
}
function fmtBytes(n?: number | null) {
if (n == null) return '—'
let v = n
for (const u of ['B', 'KB', 'MB', 'GB', 'TB']) {
if (v < 1024) return `${v.toFixed(u === 'B' ? 0 : 1)} ${u}`
v /= 1024
}
return `${v.toFixed(1)} PB`
}
function fmtMs(n?: number | null) {
if (n == null) return '—'
if (n < 1000) return `${n} ms`
if (n < 60000) return `${(n / 1000).toFixed(1)} s`
return `${(n / 60000).toFixed(1)} m`
}
const STATE_COLOR: Record<string, string> = {
QUEUED: 'text-amber-300 bg-amber-500/15',
RUNNING: 'text-sky-300 bg-sky-500/15',
FINISHED: 'text-emerald-300 bg-emerald-500/15',
FAILED: 'text-rose-300 bg-rose-500/15',
CANCELED: 'text-foreground-muted bg-surface-overlay',
}
export function SparkView({ embedded }: { embedded?: boolean }) {
const [tab, setTab] = useState<Tab>('workbench')
const [live, setLive] = useState<SparkLive | null>(null)
const loadLive = useCallback(async () => {
const l = await fetchSparkLive()
if (l) setLive(l)
}, [])
useEffect(() => {
loadLive()
const iv = setInterval(loadLive, 4000)
return () => clearInterval(iv)
}, [loadLive])
const spark = live?.spark
const alive = spark?.ui_ok && (spark?.status || '').toUpperCase() === 'ALIVE'
const activeRuns = live?.active_runs ?? []
return (
<div className={cn('flex min-h-0 flex-1 flex-col gap-2', embedded ? 'p-2' : 'p-3')}>
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Zap className="h-4 w-4 text-amber-400" />
Spark Lakehouse Workbench
</h2>
<p className="text-[10px] text-foreground-muted">
Select data · transform on the distributed engine · materialize to Iceberg / S3 · live cluster metrics
</p>
</div>
<div className="flex items-center gap-2">
<LiveChip label="Cluster" value={alive ? 'ALIVE' : 'down'} ok={!!alive} />
<LiveChip label="Cores" value={`${spark?.cores_used ?? 0}/${spark?.cores ?? 0}`} ok={(spark?.cores_used ?? 0) > 0} />
<LiveChip label="Active jobs" value={String(activeRuns.length)} ok={activeRuns.length > 0} />
<a href="/spark-ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[10px] text-docker hover:bg-docker/10">
<ExternalLink className="h-3 w-3" /> Native UI
</a>
</div>
</header>
<div className="panel flex shrink-0 gap-1 px-2 py-1.5">
{(['workbench', 'cluster', 'runs', 'ui'] as Tab[]).map((t) => (
<button key={t} type="button" onClick={() => setTab(t)} className={cn('rounded-md px-2.5 py-1 text-[10px] font-medium capitalize', tab === t ? subTabActive : subTabIdle)}>
{t === 'ui' ? 'Spark UI' : t}
{t === 'runs' && activeRuns.length > 0 && <span className="ml-1 rounded-full bg-sky-500/30 px-1 text-[8px] text-sky-200">{activeRuns.length}</span>}
</button>
))}
</div>
{tab === 'workbench' && <LakehouseWorkbench />}
{tab === 'cluster' && <ClusterPanel live={live} />}
{tab === 'runs' && <RunsPanel live={live} />}
{tab === 'ui' && (
<div className="panel min-h-0 flex-1 overflow-hidden p-1">
<iframe title="Spark Master UI" src="/spark-ui/" className="h-full min-h-[420px] w-full rounded-md border-0 bg-black" />
</div>
)}
</div>
)
}
/* ── Workbench: data selector + operation builder + live run ─────────────── */
export function LakehouseWorkbench({ lockedCatalog }: { lockedCatalog?: string }) {
const [catalogs, setCatalogs] = useState<string[]>([])
const [catalog, setCatalog] = useState(lockedCatalog || 'iceberg')
const [schemas, setSchemas] = useState<string[]>([])
const [schema, setSchema] = useState('')
const [tables, setTables] = useState<{ name: string; fqn: string }[]>([])
const [table, setTable] = useState('')
const [columns, setColumns] = useState<Column[]>([])
const [op, setOp] = useState<Operation>('preview')
const [limit, setLimit] = useState(200)
const [where, setWhere] = useState('')
const [groupBy, setGroupBy] = useState<string[]>([])
const [metrics, setMetrics] = useState<Metric[]>([{ fn: 'count', col: '*' }])
const [profileCols, setProfileCols] = useState<string[]>([])
const [sql, setSql] = useState('SELECT region, count(*) AS orders, sum(amount) AS revenue\nFROM iceberg.hadoop.historical_sales_hdfs\nGROUP BY region\nORDER BY revenue DESC')
const [rightTable, setRightTable] = useState('')
const [leftKey, setLeftKey] = useState('')
const [rightKey, setRightKey] = useState('')
const [joinType, setJoinType] = useState('INNER')
const [matEnabled, setMatEnabled] = useState(false)
const [matSchema, setMatSchema] = useState('hadoop')
const [matTable, setMatTable] = useState('')
const [matMode, setMatMode] = useState('create')
const [run, setRun] = useState<SparkRun | null>(null)
const [submitting, setSubmitting] = useState(false)
const [err, setErr] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (lockedCatalog) { setCatalog(lockedCatalog); return }
fetchSparkCatalogs().then((c) => {
setCatalogs(c)
if (c.length && !c.includes(catalog)) setCatalog(c[0])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lockedCatalog])
useEffect(() => {
if (!catalog) return
setSchema(''); setTables([]); setTable(''); setColumns([])
fetchSparkSchemas(catalog).then((s) => { setSchemas(s); if (s.length) setSchema(s[0]) })
}, [catalog])
useEffect(() => {
if (!catalog || !schema) return
setTable(''); setColumns([])
fetchSparkTables(catalog, schema).then((t) => { setTables(t); if (t.length) setTable(t[0].fqn) })
}, [catalog, schema])
useEffect(() => {
if (!table) { setColumns([]); return }
fetchSparkColumns(table).then(setColumns)
setGroupBy([]); setProfileCols([])
}, [table])
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const startPolling = useCallback((runId: string) => {
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
const j = await fetchSparkRun(runId)
if (j?.run) {
setRun(j.run)
if (['FINISHED', 'FAILED', 'CANCELED'].includes(j.run.state)) {
if (pollRef.current) clearInterval(pollRef.current)
}
}
}, 700)
}, [])
const onRun = async () => {
setErr(null); setSubmitting(true); setRun(null)
const body: Record<string, unknown> = { operation: op, limit }
if (op !== 'sql' && op !== 'join') body.table = table
if (op === 'filter') body.where = where
if (op === 'aggregate') { body.table = table; body.group_by = groupBy; body.metrics = metrics }
if (op === 'profile') { body.table = table; body.columns = profileCols.length ? profileCols : columns.slice(0, 8).map((c) => c.name) }
if (op === 'sql') body.sql = sql
if (op === 'join') {
body.left = table; body.right = rightTable
body.left_key = leftKey; body.right_key = rightKey; body.join_type = joinType
}
if (matEnabled && matTable) body.materialize = { enabled: true, schema: matSchema, table: matTable, mode: matMode }
try {
const r = await createSparkRun(body)
if (!r.ok || !r.run_id) { setErr(r.error || 'Failed to submit'); setSubmitting(false); return }
startPolling(r.run_id)
} catch {
setErr('Submit failed')
} finally {
setSubmitting(false)
}
}
const onCancel = async () => { if (run) await cancelSparkRun(run.id) }
const colNames = columns.map((c) => c.name)
return (
<div className="flex min-h-0 flex-1 gap-2 overflow-hidden">
{/* data selector */}
<aside className="panel flex w-60 shrink-0 flex-col gap-2 overflow-y-auto p-3">
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold text-foreground">
<Database className="h-3.5 w-3.5 text-emerald-400" /> Data
</h3>
{!lockedCatalog && (
<Field label="Catalog">
<select value={catalog} onChange={(e) => setCatalog(e.target.value)} className={selectCls}>
{catalogs.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
)}
<Field label="Schema">
<select value={schema} onChange={(e) => setSchema(e.target.value)} className={selectCls}>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Table">
<select value={table} onChange={(e) => setTable(e.target.value)} className={selectCls}>
{tables.map((t) => <option key={t.fqn} value={t.fqn}>{t.name}</option>)}
</select>
</Field>
<div className="min-h-0 flex-1">
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">
{columns.length} columns
</p>
<div className="scrollbar-thin space-y-0.5 overflow-y-auto">
{columns.map((c) => (
<div key={c.name} className="flex items-center justify-between gap-1 rounded px-1.5 py-0.5 text-[9px] hover:bg-surface-overlay">
<span className="truncate font-mono text-foreground">{c.name}</span>
<span className="shrink-0 text-[8px] text-foreground-faint">{c.type}</span>
</div>
))}
</div>
</div>
</aside>
{/* builder + run */}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div className="panel shrink-0 space-y-2 p-3">
<div className="flex flex-wrap gap-1">
{OPERATIONS.map((o) => (
<button key={o.id} type="button" onClick={() => setOp(o.id)} title={o.desc}
className={cn('rounded-md px-2.5 py-1 text-[10px] font-medium', op === o.id ? subTabActive : subTabIdle)}>
{o.label}
</button>
))}
</div>
{op === 'sql' && (
<textarea value={sql} onChange={(e) => setSql(e.target.value)} rows={4} spellCheck={false}
className="w-full rounded-md border border-border bg-[#0d1117] p-2 font-mono text-[11px] text-emerald-100 outline-none" />
)}
{op === 'filter' && (
<Field label="WHERE predicate">
<input value={where} onChange={(e) => setWhere(e.target.value)} placeholder="amount > 1000 AND region = 'EU'" className={inputCls} />
</Field>
)}
{op === 'aggregate' && (
<div className="space-y-2">
<Field label="Group by">
<MultiChips options={colNames} selected={groupBy} onToggle={(c) =>
setGroupBy((g) => g.includes(c) ? g.filter((x) => x !== c) : [...g, c])} />
</Field>
<div>
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Metrics</p>
{metrics.map((m, i) => (
<div key={i} className="mb-1 flex items-center gap-1">
<select value={m.fn} onChange={(e) => setMetrics((ms) => ms.map((x, j) => j === i ? { ...x, fn: e.target.value } : x))} className={cn(selectCls, 'w-32')}>
{AGG_FNS.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
<select value={m.col} onChange={(e) => setMetrics((ms) => ms.map((x, j) => j === i ? { ...x, col: e.target.value } : x))} className={cn(selectCls, 'flex-1')}>
<option value="*">*</option>
{colNames.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button type="button" onClick={() => setMetrics((ms) => ms.filter((_, j) => j !== i))} className="rounded p-1 text-foreground-muted hover:text-rose-300">
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
<button type="button" onClick={() => setMetrics((ms) => [...ms, { fn: 'sum', col: colNames[0] || '*' }])}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:text-foreground">
<Plus className="h-3 w-3" /> Add metric
</button>
</div>
</div>
)}
{op === 'profile' && (
<Field label="Columns (default: first 8)">
<MultiChips options={colNames} selected={profileCols} onToggle={(c) =>
setProfileCols((g) => g.includes(c) ? g.filter((x) => x !== c) : [...g, c])} />
</Field>
)}
{op === 'join' && (
<div className="grid grid-cols-2 gap-2">
<Field label="Right table (fqn)">
<input value={rightTable} onChange={(e) => setRightTable(e.target.value)} placeholder="postgres_sales.public.customers" className={inputCls} />
</Field>
<Field label="Join type">
<select value={joinType} onChange={(e) => setJoinType(e.target.value)} className={selectCls}>
{['INNER', 'LEFT', 'RIGHT', 'FULL'].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Left key">
<select value={leftKey} onChange={(e) => setLeftKey(e.target.value)} className={selectCls}>
<option value=""></option>
{colNames.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
<Field label="Right key">
<input value={rightKey} onChange={(e) => setRightKey(e.target.value)} placeholder="customer_id" className={inputCls} />
</Field>
</div>
)}
{/* materialize + run row */}
<div className="flex flex-wrap items-end gap-2 border-t border-border pt-2">
<label className="flex items-center gap-1.5 text-[10px] text-foreground">
<input type="checkbox" checked={matEnabled} onChange={(e) => setMatEnabled(e.target.checked)} />
<Save className="h-3 w-3 text-violet-300" /> Materialize Iceberg/S3
</label>
{matEnabled && (
<>
<input value={matSchema} onChange={(e) => setMatSchema(e.target.value)} placeholder="schema" className={cn(inputCls, 'w-24')} />
<input value={matTable} onChange={(e) => setMatTable(e.target.value)} placeholder="new_table" className={cn(inputCls, 'w-32')} />
<select value={matMode} onChange={(e) => setMatMode(e.target.value)} className={cn(selectCls, 'w-28')}>
<option value="create">create</option>
<option value="replace">replace</option>
<option value="insert">insert into</option>
</select>
</>
)}
{!matEnabled && (
<Field label="Limit" inline>
<input type="number" value={limit} onChange={(e) => setLimit(Math.max(1, Math.min(500, +e.target.value)))} className={cn(inputCls, 'w-20')} />
</Field>
)}
<div className="ml-auto flex gap-2">
{run && run.state === 'RUNNING' && (
<button type="button" onClick={onCancel} className="inline-flex items-center gap-1 rounded-md border border-rose-400/50 bg-rose-500/15 px-3 py-1.5 text-[11px] text-rose-200 hover:bg-rose-500/25">
<Square className="h-3.5 w-3.5" /> Cancel
</button>
)}
<button type="button" onClick={onRun} disabled={submitting}
className="inline-flex items-center gap-1.5 rounded-md border border-amber-400/50 bg-amber-500/15 px-4 py-1.5 text-[11px] font-medium text-amber-200 hover:bg-amber-500/25 disabled:opacity-50">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Run on Spark
</button>
</div>
</div>
{err && <p className="text-[11px] text-rose-300">{err}</p>}
</div>
{/* live run + results */}
<div className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
{run ? <RunLive run={run} /> : (
<div className="flex flex-1 items-center justify-center text-[11px] text-foreground-muted">
Build an operation and Run to see the live execution matrix.
</div>
)}
</div>
</div>
</div>
)
}
function RunLive({ run }: { run: SparkRun }) {
const s = run.stats || {}
const pct = s.progress_pct ?? (run.state === 'FINISHED' ? 100 : 0)
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-[11px] font-semibold text-foreground">{run.label}</p>
{run.target && <p className="truncate font-mono text-[9px] text-violet-300"> {run.target}</p>}
</div>
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-medium', STATE_COLOR[run.state] || STATE_COLOR.QUEUED)}>
{run.state}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-overlay">
<div className="h-full rounded-full bg-gradient-to-r from-sky-400 to-emerald-400 transition-all" style={{ width: `${pct}%` }} />
</div>
<SparkMatrix stats={s} />
{run.error && <p className="mt-2 rounded bg-rose-500/10 p-2 font-mono text-[10px] text-rose-300">{run.error}</p>}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{run.columns && run.columns.length > 0 ? (
<table className="w-full border-collapse text-[10px]">
<thead className="sticky top-0 bg-surface">
<tr>{run.columns.map((c) => <th key={c} className="border-b border-border px-2 py-1 text-left font-semibold text-foreground-muted">{c}</th>)}</tr>
</thead>
<tbody>
{(run.rows || []).map((row, i) => (
<tr key={i} className="hover:bg-surface-overlay/40">
{row.map((cell, j) => <td key={j} className="border-b border-border/50 px-2 py-1 font-mono text-foreground">{cell == null ? '∅' : String(cell)}</td>)}
</tr>
))}
</tbody>
</table>
) : run.state === 'FINISHED' && run.target ? (
<p className="p-3 text-[11px] text-emerald-300"> Materialized to {run.target}{run.update_type ? ` (${run.update_type})` : ''}</p>
) : null}
</div>
</div>
)
}
function SparkMatrix({ stats }: { stats: SparkRunStats }) {
const cells: { label: string; value: string; icon: typeof Cpu }[] = [
{ label: 'Splits', value: `${fmtNum(stats.completed_splits)}/${fmtNum(stats.total_splits)}`, icon: Layers },
{ label: 'Running', value: fmtNum(stats.running_splits), icon: Activity },
{ label: 'Rows', value: fmtNum(stats.processed_rows), icon: Table2 },
{ label: 'Input', value: fmtBytes(stats.processed_bytes), icon: HardDrive },
{ label: 'CPU', value: fmtMs(stats.cpu_time_ms), icon: Cpu },
{ label: 'Wall', value: fmtMs(stats.elapsed_ms ?? stats.wall_time_ms), icon: Gauge },
{ label: 'Peak mem', value: fmtBytes(stats.peak_memory_bytes), icon: Server },
{ label: 'Nodes', value: fmtNum(stats.nodes), icon: Cpu },
]
return (
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:grid-cols-8">
{cells.map((c) => (
<div key={c.label} className="rounded-md border border-border bg-surface-overlay/30 px-2 py-1.5">
<div className="flex items-center gap-1 text-[8px] uppercase tracking-wider text-foreground-muted">
<c.icon className="h-2.5 w-2.5" /> {c.label}
</div>
<p className="font-mono text-[11px] font-semibold tabular-nums text-foreground">{c.value}</p>
</div>
))}
</div>
)
}
/* ── Cluster panel ───────────────────────────────────────────────────────── */
function ClusterPanel({ live }: { live: SparkLive | null }) {
const spark = live?.spark
return (
<div className="panel grid min-h-0 flex-1 gap-3 overflow-y-auto p-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Workers" value={String(spark?.alive_workers ?? 0)} icon={Server} />
<Metric label="Cores" value={`${spark?.cores_used ?? 0} / ${spark?.cores ?? 0}`} icon={Cpu} />
<Metric label="Memory" value={`${fmtNum(spark?.memory_used_mb)} / ${fmtNum(spark?.memory_mb)} MB`} icon={Activity} />
<Metric label="Active jobs" value={String(live?.active_runs?.length ?? 0)} icon={Zap} />
<div className="col-span-full">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-muted">Workers</h3>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{(spark?.workers || []).map((w) => (
<div key={w.id} className="rounded-lg border border-border bg-surface-overlay/30 p-3">
<p className="truncate font-mono text-[10px] text-foreground">{w.host || w.id}</p>
<p className="text-[9px] text-foreground-muted">{w.cores_used}/{w.cores} cores · {w.memory_mb} MB · {w.state}</p>
</div>
))}
{!spark?.workers?.length && <p className="text-[10px] text-foreground-muted">No worker details</p>}
</div>
</div>
</div>
)
}
/* ── Runs history ────────────────────────────────────────────────────────── */
function RunsPanel({ live }: { live: SparkLive | null }) {
const [runs, setRuns] = useState<SparkRun[]>([])
useEffect(() => {
fetchSparkRuns().then(setRuns)
const iv = setInterval(() => fetchSparkRuns().then(setRuns), 3000)
return () => clearInterval(iv)
}, [])
const list = runs.length ? runs : (live?.recent_runs ?? [])
return (
<div className="panel min-h-0 flex-1 overflow-y-auto p-3">
{list.length === 0 ? (
<p className="p-4 text-center text-[11px] text-foreground-muted">No runs yet. Submit an operation from the Workbench.</p>
) : (
<div className="space-y-1.5">
{list.map((r) => (
<div key={r.id} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-surface-overlay/20 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-[11px] font-medium text-foreground">{r.label}</p>
<p className="truncate font-mono text-[9px] text-foreground-muted">{r.sql.slice(0, 90)}</p>
</div>
<div className="flex shrink-0 items-center gap-3 text-[9px] text-foreground-muted">
<span>{fmtNum(r.stats?.processed_rows)} rows</span>
<span>{fmtMs(r.stats?.elapsed_ms)}</span>
<span className={cn('rounded-full px-2 py-0.5 font-medium', STATE_COLOR[r.state] || STATE_COLOR.QUEUED)}>{r.state}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
/* ── small UI helpers ────────────────────────────────────────────────────── */
const selectCls = 'w-full rounded-md border border-border bg-surface px-2 py-1 text-[10px] text-foreground outline-none'
const inputCls = 'rounded-md border border-border bg-surface px-2 py-1 text-[10px] text-foreground outline-none'
function Field({ label, children, inline }: { label: string; children: React.ReactNode; inline?: boolean }) {
return (
<label className={cn('text-[9px] font-semibold uppercase tracking-wider text-foreground-muted', inline ? 'flex items-center gap-1.5' : 'block')}>
{label}
<div className={inline ? '' : 'mt-1'}>{children}</div>
</label>
)
}
function MultiChips({ options, selected, onToggle }: { options: string[]; selected: string[]; onToggle: (c: string) => void }) {
return (
<div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto">
{options.map((o) => (
<button key={o} type="button" onClick={() => onToggle(o)}
className={cn('rounded border px-1.5 py-0.5 font-mono text-[9px]', selected.includes(o) ? 'border-emerald-400/50 bg-emerald-500/15 text-emerald-200' : 'border-border text-foreground-muted hover:bg-surface-overlay')}>
{o}
</button>
))}
{!options.length && <span className="text-[9px] text-foreground-faint">select a table</span>}
</div>
)
}
function Metric({ label, value, icon: Icon }: { label: string; value: string; icon: typeof Cpu }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/25 p-3">
<div className="mb-1 flex items-center gap-1.5 text-[9px] uppercase tracking-wider text-foreground-muted">
<Icon className="h-3 w-3" /> {label}
</div>
<p className="text-lg font-semibold tabular-nums text-foreground">{value}</p>
</div>
)
}
function LiveChip({ label, value, ok }: { label: string; value: string; ok: boolean }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[9px]">
<span className={cn('h-1.5 w-1.5 rounded-full', ok ? 'bg-emerald-400' : 'bg-amber-400')} />
<span className="text-foreground-muted">{label}</span>
<span className="font-mono font-semibold text-foreground">{value}</span>
</span>
)
}