ui: Data Generation view (per-DB tabs, light generate, run status) + topology pulse
This commit is contained in:
@@ -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 -> Debezium -> Kafka -> 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" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare } from 'lucide-react'
|
||||
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu } 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' | 'approvals'
|
||||
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals'
|
||||
|
||||
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: 'datagen', label: 'Data Generation', icon: Cpu },
|
||||
{ id: 'presentation', label: 'Presentation', icon: Presentation },
|
||||
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
|
||||
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
|
||||
|
||||
Reference in New Issue
Block a user