ui: Data Generation view (per-DB tabs, light generate, run status) + topology pulse

This commit is contained in:
mo
2026-06-26 01:17:51 +00:00
parent bac3a90775
commit be526bb0e3
6 changed files with 286 additions and 7 deletions
+249
View File
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Database, Boxes, Activity, Cpu, Network, Layers, Play, Loader2, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type SourceKey = 'all' | 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
type SourceMeta = {
key: SourceKey
label: string
icon: typeof Database
accent: string
target: string
cdc: boolean
desc: string
defaultRows: number
}
const SOURCES: SourceMeta[] = [
{ key: 'all', label: 'All sources', icon: Layers, accent: 'text-violet-400', target: 'alle 5 databases', cdc: true, desc: 'Genereer tegelijk in alle databases.', defaultRows: 5000 },
{ key: 'postgres', label: 'PostgreSQL', icon: Database, accent: 'text-sky-400', target: 'sales_orders', cdc: true, desc: 'Sales orders. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
{ key: 'mysql', label: 'MySQL', icon: Database, accent: 'text-amber-400', target: 'employee_events', cdc: true, desc: 'HR employee events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
{ key: 'mongodb', label: 'MongoDB', icon: Boxes, accent: 'text-emerald-400', target: 'supplychain.events', cdc: true, desc: 'Supply chain events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
{ key: 'cassandra', label: 'Cassandra', icon: Activity, accent: 'text-cyan-400', target: 'device_metrics', cdc: false, desc: 'Telemetry metrics. Zichtbaar via Trino.', defaultRows: 5000 },
{ key: 'neo4j', label: 'Neo4j', icon: Network, accent: 'text-pink-400', target: 'Product/Supplier graph', cdc: false, desc: 'Graafdata (producten, leveranciers, relaties).', defaultRows: 2000 },
]
type RunInfo = { run_id?: string; state?: string; start?: string; end?: string; conf?: { rows?: number } }
type Props = {
onPulse: () => void
onOpenPlatform: () => void
}
const COUNT_KEYS: SourceKey[] = ['postgres', 'mysql', 'mongodb', 'cassandra']
export function DataGenView({ onPulse, onOpenPlatform }: Props) {
const [active, setActive] = useState<SourceKey>('all')
const [rows, setRows] = useState<Record<SourceKey, number>>(
Object.fromEntries(SOURCES.map((s) => [s.key, s.defaultRows])) as Record<SourceKey, number>,
)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [runs, setRuns] = useState<Record<string, RunInfo[]>>({})
const [counts, setCounts] = useState<Record<string, number | null>>({})
const [msg, setMsg] = useState<string | null>(null)
const pollRef = useRef<Record<string, ReturnType<typeof setInterval>>>({})
const meta = SOURCES.find((s) => s.key === active)!
const loadCounts = useCallback(async () => {
try {
const r = await fetch('/api/pipeline/sync')
const j = await r.json()
if (j.ok) setCounts(j.counts || {})
} catch { /* */ }
}, [])
const loadRuns = useCallback(async (source: SourceKey) => {
try {
const r = await fetch(`/api/pipeline/runs/${source}?limit=5`)
const j = await r.json()
if (j.ok) setRuns((prev) => ({ ...prev, [source]: j.runs || [] }))
return (j.runs || [])[0] as RunInfo | undefined
} catch {
return undefined
}
}, [])
useEffect(() => {
loadCounts()
SOURCES.forEach((s) => loadRuns(s.key))
return () => { Object.values(pollRef.current).forEach(clearInterval) }
}, [loadCounts, loadRuns])
const startPolling = useCallback((source: SourceKey) => {
if (pollRef.current[source]) clearInterval(pollRef.current[source])
pollRef.current[source] = setInterval(async () => {
const latest = await loadRuns(source)
if (latest && (latest.state === 'success' || latest.state === 'failed')) {
clearInterval(pollRef.current[source])
delete pollRef.current[source]
setBusy((b) => ({ ...b, [source]: false }))
loadCounts()
if (latest.state === 'success') setMsg(`${source}: klaar — nieuwe data gegenereerd`)
else setMsg(`${source}: run mislukt — check Airflow logs`)
}
}, 3000)
}, [loadRuns, loadCounts])
const generate = useCallback(async (source: SourceKey) => {
setMsg(null)
setBusy((b) => ({ ...b, [source]: true }))
onPulse()
try {
const r = await fetch(`/api/pipeline/generate/${source}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows: rows[source] }),
})
const j = await r.json()
if (!j.ok) {
setMsg(`Fout: ${j.error || 'kon niet triggeren'}`)
setBusy((b) => ({ ...b, [source]: false }))
return
}
setMsg(`${source}: gestart (run ${String(j.run_id).slice(-8)})`)
startPolling(source)
} catch {
setMsg('API niet bereikbaar')
setBusy((b) => ({ ...b, [source]: false }))
}
}, [rows, onPulse, startPolling])
const stateBadge = (state?: string) => {
if (state === 'success') return <span className="inline-flex items-center gap-1 text-emerald-400"><CheckCircle2 className="h-3 w-3" /> success</span>
if (state === 'failed') return <span className="inline-flex items-center gap-1 text-danger"><XCircle className="h-3 w-3" /> failed</span>
if (state === 'running' || state === 'queued') return <span className="inline-flex items-center gap-1 text-amber-400"><Loader2 className="h-3 w-3 animate-spin" /> {state}</span>
return <span className="text-foreground-faint">{state || '—'}</span>
}
const latest = (runs[active] || [])[0]
return (
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Cpu className="h-4 w-4 text-violet-400" /> Data Generation
</h2>
<p className="text-[10px] text-foreground-muted">
Genereer per database nieuwe data en pulse de hele flow: bron -&gt; Debezium -&gt; Kafka -&gt; S3
</p>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => { loadCounts(); SOURCES.forEach((s) => loadRuns(s.key)) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className="inline h-3 w-3" /> Refresh
</button>
<button type="button" onClick={onOpenPlatform} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabActive)}>
Bekijk topology pulse
</button>
</div>
</header>
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border px-3 py-2">
{SOURCES.map((s) => {
const Icon = s.icon
return (
<button
key={s.key}
type="button"
onClick={() => setActive(s.key)}
className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-medium', active === s.key ? subTabActive : subTabIdle)}
>
<Icon className={cn('h-3.5 w-3.5', s.accent)} />
{s.label}
{busy[s.key] && <Loader2 className="h-3 w-3 animate-spin text-amber-400" />}
</button>
)
})}
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-4">
<div className="max-w-2xl space-y-4">
<div className="rounded-lg border border-border bg-surface-overlay/40 p-4">
<div className="mb-1 flex items-center gap-2">
<meta.icon className={cn('h-5 w-5', meta.accent)} />
<h3 className="text-sm font-semibold text-foreground">{meta.label}</h3>
{meta.cdc ? (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] text-emerald-400">CDC actief</span>
) : (
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-foreground-muted">geen CDC-stream</span>
)}
</div>
<p className="mb-3 text-[11px] text-foreground-muted">{meta.desc} Doel: <span className="font-mono">{meta.target}</span></p>
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Aantal rijen
<input
type="number"
min={1}
max={2000000}
value={rows[active]}
onChange={(e) => setRows((r) => ({ ...r, [active]: Number(e.target.value) }))}
className="w-40 rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground"
/>
</label>
<button
type="button"
disabled={!!busy[active]}
onClick={() => generate(active)}
className="inline-flex items-center gap-2 rounded-md bg-violet-500/90 px-4 py-2 text-[12px] font-semibold text-black hover:bg-violet-400 disabled:opacity-40"
>
{busy[active] ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Genereer data
</button>
{active !== 'all' && COUNT_KEYS.includes(active) && (
<div className="text-[11px] text-foreground-muted">
Huidige rijen: <span className="font-mono text-foreground">{counts[active]?.toLocaleString() ?? '…'}</span>
</div>
)}
</div>
{msg && <p className="mt-3 text-[11px] text-foreground-muted">{msg}</p>}
</div>
<div className="rounded-lg border border-border p-4">
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">Recente runs {meta.label}</h4>
{latest ? (
<table className="w-full text-left text-[11px]">
<thead>
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
<th className="py-1 pr-2">State</th>
<th className="py-1 pr-2">Rows</th>
<th className="py-1 pr-2">Start</th>
<th className="py-1 pr-2">Eind</th>
</tr>
</thead>
<tbody>
{(runs[active] || []).map((run) => (
<tr key={run.run_id} className="border-b border-border/40">
<td className="py-1 pr-2">{stateBadge(run.state)}</td>
<td className="py-1 pr-2 font-mono text-foreground-muted">{run.conf?.rows ?? '—'}</td>
<td className="py-1 pr-2 text-foreground-faint">{run.start?.slice(11, 19) || '—'}</td>
<td className="py-1 pr-2 text-foreground-faint">{run.end?.slice(11, 19) || '—'}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-[11px] text-foreground-faint">Nog geen runs.</p>
)}
</div>
<div className="rounded-lg border border-border p-4">
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">Live tellingen (Trino)</h4>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{COUNT_KEYS.map((k) => (
<div key={k} className="rounded border border-border/60 px-2 py-1.5">
<div className="text-[9px] uppercase text-foreground-faint">{k}</div>
<div className="font-mono text-[12px] text-foreground">{counts[k]?.toLocaleString() ?? '…'}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -253,9 +253,10 @@ type Props = {
animations: Record<string, AgentAnim>
selectedNodeId: string | null
onNodeClick: (nodeId: string) => void
pulse?: boolean
}
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick, pulse = false }: Props) {
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
const canvasRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
@@ -292,7 +293,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
[animations],
)
const edgesLive = pipelineActive || anyBusy
const edgesLive = pulse || pipelineActive || anyBusy
useEffect(() => {
fetch("/api/search/health").then(r => r.json()).then(j => {
@@ -343,7 +344,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
}, [measureAnchors, metrics, llmLabel])
return (
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<section className={cn('panel flex h-full min-h-0 flex-1 flex-col overflow-hidden', pulse && 'topo-pulsing')}>
<header
className="flex shrink-0 flex-col gap-0.5 border-b border-border px-2 py-1"
style={{ background: 'var(--topo-header-bg)' }}
@@ -402,7 +403,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
const longArc = colGap > 1 || (Math.abs(b.y - a.y) > 48 && Math.abs(toX - fromX) > 120)
const d = flowPath(fromX, a.y, toX, b.y, { backward, longArc })
const live = edgesLive
const dur = 1.8 + (i % 5) * 0.35
const dur = (pulse ? 0.85 : 1.8) + (i % 5) * (pulse ? 0.12 : 0.35)
return (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
<path d={d} className="topo-edge-glow" />