import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Activity, Radio, RefreshCw, PlusCircle, Pencil, Trash2, Database, Layers, TrendingUp, Cable } from 'lucide-react' import { fetchChanges, fetchChangeStats, resyncSources } from '../../lib/api' import type { CdcChange, CdcStats } from '../../types' import { cn } from '../../lib/utils' const OP_STYLE: Record = { insert: { label: 'INSERT', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', color: '#34d399' }, update: { label: 'UPDATE', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30', color: '#fbbf24' }, delete: { label: 'DELETE', cls: 'bg-rose-500/15 text-rose-300 border-rose-500/30', color: '#fb7185' }, snapshot: { label: 'SNAPSHOT', cls: 'bg-sky-500/15 text-sky-300 border-sky-500/30', color: '#38bdf8' }, } const SOURCE_COLOR: Record = { postgres: '#38bdf8', mysql: '#f59e0b', mongodb: '#34d399', cassandra: '#a78bfa', neo4j: '#f472b6', } const SOURCES = ['all', 'postgres', 'mysql', 'mongodb', 'cassandra', 'neo4j'] const OPS = ['all', 'insert', 'update', 'delete'] const TIME_WINDOWS = [ { label: '15 min', minutes: 15 }, { label: '1 hour', minutes: 60 }, { label: '6 hours', minutes: 360 }, { label: '24 hours', minutes: 1440 }, ] as const function opOf(o: string) { return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30', color: '#94a3b8' } } function timeAgo(ts: string) { const d = Date.now() - new Date(ts).getTime() if (d < 1000) return 'now' if (d < 60000) return `${Math.floor(d / 1000)}s ago` if (d < 3600000) return `${Math.floor(d / 60000)}m ago` return `${Math.floor(d / 3600000)}h ago` } // Smoothly animates a number toward its target so counters tick up nicely. function useTween(target: number, ms = 700) { const [val, setVal] = useState(target) const from = useRef(target) const start = useRef(0) const raf = useRef(0) useEffect(() => { from.current = val start.current = performance.now() const step = (now: number) => { const t = Math.min(1, (now - start.current) / ms) const eased = 1 - Math.pow(1 - t, 3) setVal(from.current + (target - from.current) * eased) if (t < 1) raf.current = requestAnimationFrame(step) } raf.current = requestAnimationFrame(step) return () => cancelAnimationFrame(raf.current) // eslint-disable-next-line react-hooks/exhaustive-deps }, [target, ms]) return val } function KpiCard({ label, value, accent, icon: Icon, sub }: { label: string; value: number; accent: string; icon: typeof PlusCircle; sub?: string }) { const v = useTween(value) return (
{label}
{Math.round(v).toLocaleString()}
{sub &&
{sub}
}
) } // SVG donut for the operation mix. function Donut({ segments, total }: { segments: { label: string; value: number; color: string }[]; total: number }) { const R = 42 const C = 2 * Math.PI * R let offset = 0 return (
{total > 0 && segments.map((s) => { const frac = s.value / total const dash = frac * C const el = ( ) offset += dash return el })} {total.toLocaleString()} changes
{segments.map((s) => (
{s.label} {s.value.toLocaleString()} {total ? Math.round((s.value / total) * 100) : 0}%
))}
) } // Smooth area chart of per-minute change volume. function VolumeArea({ buckets }: { buckets: { t: string; n: number }[] }) { const w = 600 const h = 120 const pad = 6 const data = buckets.length ? buckets : [{ t: '', n: 0 }] const max = Math.max(1, ...data.map((b) => b.n)) const stepX = data.length > 1 ? (w - pad * 2) / (data.length - 1) : 0 const pts = data.map((b, i) => { const x = pad + i * stepX const y = h - pad - (b.n / max) * (h - pad * 2) return [x, y] as const }) const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ') const area = `${line} L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z` const last = data[data.length - 1] return (
{[0.25, 0.5, 0.75].map((g) => ( ))} {buckets.length > 0 && } {buckets.length > 0 && } {buckets.length > 0 && ( )}
{max}/min
{data[0]?.t || '—'} now · {last?.n ?? 0}/min
{buckets.length === 0 &&
No changes in the window yet…
}
) } function BarRow({ label, value, max, color }: { label: string; value: number; max: number; color: string }) { return (
{label}
{value.toLocaleString()}
) } function Diff({ change }: { change: CdcChange }) { const keys = useMemo(() => { const set = new Set() for (const k of Object.keys(change.before || {})) set.add(k) for (const k of Object.keys(change.after || {})) set.add(k) return Array.from(set).filter((k) => k !== 'payload').slice(0, 24) }, [change]) const fmt = (v: unknown) => { if (v === null || v === undefined) return '∅' if (typeof v === 'object') return JSON.stringify(v).slice(0, 60) return String(v).slice(0, 60) } return (
{keys.map((k) => { const b = (change.before || {})[k] const a = (change.after || {})[k] const changed = JSON.stringify(b) !== JSON.stringify(a) return ( ) })}
column before after
{k} {fmt(b)} {fmt(a)}
) } export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) { const [seed, setSeed] = useState([]) const [stats, setStats] = useState(null) const [source, setSource] = useState('all') const [op, setOp] = useState('all') const [expanded, setExpanded] = useState(null) const [connected, setConnected] = useState(false) const [flash, setFlash] = useState(false) const [resyncing, setResyncing] = useState(false) const [resyncMsg, setResyncMsg] = useState(null) const [windowMin, setWindowMin] = useState(15) // Live overlay: CDC events counted straight off the WebSocket stream since the // last server stats snapshot. The top KPIs/charts = authoritative server stats // (refreshed every 2.5s) + this overlay, so they move in lock-step with the // bottom feed instead of lagging behind it. const emptyOverlay = { total: 0, by_op: {} as Record, by_source: {} as Record, by_table: {} as Record } const [overlay, setOverlay] = useState(emptyOverlay) const lastSeenId = useRef(null) const primed = useRef(false) const applyStats = useCallback((s: CdcStats | null) => { if (!s) return setStats(s) setOverlay({ total: 0, by_op: {}, by_source: {}, by_table: {} }) // server is now authoritative }, []) const load = useCallback(async () => { const [c, s] = await Promise.all([fetchChanges({ limit: 200, minutes: windowMin }), fetchChangeStats(windowMin)]) setSeed(c.changes) setConnected(c.connected) applyStats(s) }, [applyStats, windowMin]) const doResync = useCallback(async () => { setResyncing(true) setResyncMsg('Restarting Debezium connectors…') try { const res = await resyncSources(true) if (res) { setResyncMsg(`Re-synced · ${res.healthy ?? 0}/${res.total ?? 0} connectors healthy`) setTimeout(() => load(), 2500) } else { setResyncMsg('Re-sync failed — check ETL Guardian terminal') } } catch { setResyncMsg('Re-sync failed — check ETL Guardian terminal') } finally { setResyncing(false) setTimeout(() => setResyncMsg(null), 7000) } }, [load]) useEffect(() => { load() const iv = setInterval(() => fetchChangeStats(windowMin).then((s) => applyStats(s)), 2500) return () => clearInterval(iv) }, [load, applyStats, windowMin]) // Fold freshly-arrived WS changes into the overlay → instant top-of-page update. useEffect(() => { if (!liveChanges.length) return if (!primed.current) { primed.current = true lastSeenId.current = liveChanges[0].id return } const idx = liveChanges.findIndex((c) => c.id === lastSeenId.current) const fresh = idx === -1 ? liveChanges : liveChanges.slice(0, idx) if (!fresh.length) return lastSeenId.current = liveChanges[0].id setOverlay((o) => { const next = { total: o.total + fresh.length, by_op: { ...o.by_op }, by_source: { ...o.by_source }, by_table: { ...o.by_table } } for (const c of fresh) { next.by_op[c.op] = (next.by_op[c.op] || 0) + 1 next.by_source[c.source] = (next.by_source[c.source] || 0) + 1 next.by_table[c.table] = (next.by_table[c.table] || 0) + 1 } return next }) setFlash(true) const id = setTimeout(() => setFlash(false), 800) return () => clearTimeout(id) }, [liveChanges]) // Merge live (WS) with seeded backlog, dedupe by id, newest first. const merged = useMemo(() => { const byId = new Map() for (const c of liveChanges) byId.set(c.id, c) for (const c of seed) if (!byId.has(c.id)) byId.set(c.id, c) return Array.from(byId.values()).sort((x, y) => (y.ts > x.ts ? 1 : -1)) }, [liveChanges, seed]) const filtered = useMemo( () => merged.filter((c) => (source === 'all' || c.source === source) && (op === 'all' || c.op === op)).slice(0, 200), [merged, source, op], ) const opVal = (k: string) => (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0) const inserts = opVal('insert') const updates = opVal('update') const deletes = opVal('delete') const total = (stats?.total ?? 0) + overlay.total const perMin = total / Math.max(1, stats?.window_minutes ?? 15) const opSegments = useMemo(() => ( ['insert', 'update', 'delete', 'snapshot'] .map((k) => ({ label: k, value: (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0), color: opOf(k).color })) .filter((s) => s.value > 0) ), [stats?.by_op, overlay]) const sourceRows = useMemo(() => { const m: Record = { ...(stats?.by_source || {}) } for (const [k, v] of Object.entries(overlay.by_source)) m[k] = (m[k] || 0) + v return Object.entries(m).sort((a, b) => b[1] - a[1]) }, [stats?.by_source, overlay]) const maxSource = Math.max(1, ...sourceRows.map(([, v]) => v)) const tableRows = useMemo(() => { const m: Record = { ...(stats?.by_table || {}) } for (const [k, v] of Object.entries(overlay.by_table)) m[k] = (m[k] || 0) + v return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 7) }, [stats?.by_table, overlay]) const maxTable = Math.max(1, ...tableRows.map(([, v]) => v)) // Volume chart: bump the current-minute bar with the live overlay so the curve // visibly rises as changes stream in. const liveBuckets = useMemo(() => { const b = (stats?.buckets || []).map((x) => ({ ...x })) if (overlay.total) { if (b.length) b[b.length - 1] = { ...b[b.length - 1], n: b[b.length - 1].n + overlay.total } else b.push({ t: 'now', n: overlay.total }) } return b }, [stats?.buckets, overlay.total]) return (
{/* Header */}

New & Changed Data

Live Debezium change data capture — every insert, update & delete across all source databases, streamed via Kafka

{TIME_WINDOWS.map((w) => ( ))}
{connected ? 'STREAMING' : 'OFFLINE'} {resyncMsg && ( {resyncMsg} )}
{/* KPI row */}
{/* Volume + operation mix */}
Change volume — selected window
Operation mix
{opSegments.length ? : (
No changes yet…
)}
{/* By system + top tables */}
New & changed by system
{sourceRows.length ? sourceRows.map(([k, v]) => ( )) :
No source activity in the window…
}
Most active tables / collections
{tableRows.length ? tableRows.map(([k, v]) => ( )) :
No table activity yet…
}
{/* Filters */}
Latest changes Source {SOURCES.map((s) => ( ))} Op {OPS.map((o) => ( ))}
{/* Live list */}
{filtered.length === 0 && (
Waiting for changes… trigger data generation (Data Flow → Generate data) or agent DML to see live CDC events.
)} {filtered.map((c) => (
{expanded === c.id &&
}
))}
) }