2026-06-28 22:41:48 +00:00
import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
import { Activity , Radio , RefreshCw , PlusCircle , Pencil , Trash2 , Database , Layers , TrendingUp } from 'lucide-react'
2026-06-27 01:44:26 +02:00
import { fetchChanges , fetchChangeStats } from '../../lib/api'
import type { CdcChange , CdcStats } from '../../types'
import { cn } from '../../lib/utils'
2026-06-28 22:41:48 +00:00
const OP_STYLE : Record < string , { label : string ; cls : string ; color : string }> = {
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' },
2026-06-27 01:44:26 +02:00
}
const SOURCE_COLOR : Record < string , string > = {
postgres : '#38bdf8' ,
mysql : '#f59e0b' ,
mongodb : '#34d399' ,
cassandra : '#a78bfa' ,
neo4j : '#f472b6' ,
}
const SOURCES = [ 'all' , 'postgres' , 'mysql' , 'mongodb' , 'cassandra' , 'neo4j' ]
const OPS = [ 'all' , 'insert' , 'update' , 'delete' ]
function opOf ( o : string ) {
2026-06-28 22:41:48 +00:00
return OP_STYLE [ o ] || { label : o.toUpperCase (), cls : 'bg-slate-500/15 text-slate-300 border-slate-500/30' , color : '#94a3b8' }
2026-06-27 01:44:26 +02:00
}
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`
}
2026-06-28 22:41:48 +00:00
// 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 (
< div className = "relative overflow-hidden rounded-lg border border-border/60 bg-surface-raised p-3" >
< div className = "absolute -right-3 -top-3 h-14 w-14 rounded-full opacity-[0.12] blur-xl" style = {{ background : accent }} />
< div className = "flex items-center gap-1.5 text-[9px] uppercase tracking-wide text-foreground-faint" >
< Icon className = "h-3 w-3" style = {{ color : accent }} /> { label }
</ div >
< div className = "mt-1 text-2xl font-semibold tabular-nums text-foreground" style = {{ textShadow : `0 0 18px ${ accent } 22` }}>
{ Math . round ( v ). toLocaleString ()}
</ div >
{ sub && < div className = "text-[10px] text-foreground-muted" >{ sub }</ div >}
</ div >
)
}
// 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 (
< div className = "flex items-center gap-4" >
< svg viewBox = "0 0 110 110" className = "h-28 w-28 shrink-0 -rotate-90" >
< circle cx = "55" cy = "55" r = { R } fill = "none" stroke = "rgba(148,163,184,0.12)" strokeWidth = "13" />
{ total > 0 && segments . map (( s ) => {
const frac = s . value / total
const dash = frac * C
const el = (
< circle key = { s . label } cx = "55" cy = "55" r = { R } fill = "none" stroke = { s . color } strokeWidth = "13"
strokeDasharray = { ` ${ dash } ${ C - dash } ` } strokeDashoffset = { - offset } strokeLinecap = "butt"
style = {{ transition : 'stroke-dasharray .6s ease, stroke-dashoffset .6s ease' }} />
)
offset += dash
return el
})}
< g className = "rotate-90" style = {{ transformOrigin : '55px 55px' }}>
< text x = "55" y = "51" textAnchor = "middle" className = "fill-foreground text-[16px] font-semibold tabular-nums" >{ total . toLocaleString ()}</ text >
< text x = "55" y = "65" textAnchor = "middle" className = "fill-foreground-faint text-[7px] uppercase tracking-wider" > changes </ text >
</ g >
</ svg >
< div className = "flex-1 space-y-1.5" >
{ segments . map (( s ) => (
< div key = { s . label } className = "flex items-center gap-2 text-[11px]" >
< span className = "h-2.5 w-2.5 rounded-sm" style = {{ background : s.color }} />
< span className = "capitalize text-foreground-muted" >{ s . label }</ span >
< span className = "ml-auto font-mono text-foreground" >{ s . value . toLocaleString ()}</ span >
< span className = "w-9 text-right font-mono text-foreground-faint" >{ total ? Math . round (( s . value / total ) * 100 ) : 0 } % </ span >
</ div >
))}
</ div >
</ div >
)
}
// 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 (
< div >
< div className = "relative" >
< svg viewBox = { `0 0 ${ w } ${ h } ` } preserveAspectRatio = "none" className = "h-28 w-full" >
< defs >
< linearGradient id = "cdcvol" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
< stop offset = "0%" stopColor = "#38bdf8" stopOpacity = "0.45" />
< stop offset = "100%" stopColor = "#38bdf8" stopOpacity = "0" />
</ linearGradient >
</ defs >
{[ 0.25 , 0.5 , 0.75 ]. map (( g ) => (
< line key = { g } x1 = { pad } x2 = { w - pad } y1 = { pad + g * ( h - pad * 2 )} y2 = { pad + g * ( h - pad * 2 )} stroke = "rgba(148,163,184,0.08)" strokeWidth = "1" />
))}
{ buckets . length > 0 && < path d = { area } fill = "url(#cdcvol)" />}
{ buckets . length > 0 && < path d = { line } fill = "none" stroke = "#38bdf8" strokeWidth = "2" vectorEffect = "non-scaling-stroke" />}
{ buckets . length > 0 && (
< circle cx = { pts [ pts . length - 1 ][ 0 ]} cy = { pts [ pts . length - 1 ][ 1 ]} r = "3.5" fill = "#38bdf8" >
< animate attributeName = "r" values = "3.5;6;3.5" dur = "1.6s" repeatCount = "indefinite" />
</ circle >
)}
</ svg >
< div className = "pointer-events-none absolute left-1.5 top-1 text-[9px] font-mono text-foreground-faint" >{ max } / min </ div >
</ div >
< div className = "mt-1 flex justify-between text-[9px] font-mono text-foreground-faint" >
< span >{ data [ 0 ] ? . t || '—' }</ span >
< span className = "text-docker" > now · { last ? . n ?? 0 } / min </ span >
</ div >
{ buckets . length === 0 && < div className = "mt-1 text-center text-[10px] text-foreground-faint" > No changes in the window yet … </ div >}
</ div >
)
}
function BarRow ({ label , value , max , color } : { label : string ; value : number ; max : number ; color : string }) {
return (
< div className = "flex items-center gap-2" >
< span className = "flex w-24 shrink-0 items-center gap-1.5 text-[11px] capitalize text-foreground-muted" >
< span className = "h-2 w-2 shrink-0 rounded-full" style = {{ background : color }} /> { label }
</ span >
< div className = "h-2.5 flex-1 overflow-hidden rounded-full bg-surface" >
< div className = "h-full rounded-full transition-all duration-500" style = {{ width : ` ${ Math . max ( 3 , ( value / max ) * 100 ) } %` , background : color }} />
</ div >
< span className = "w-12 shrink-0 text-right font-mono text-[11px] text-foreground" >{ value . toLocaleString ()}</ span >
</ div >
)
}
2026-06-27 01:44:26 +02:00
function Diff ({ change } : { change : CdcChange }) {
const keys = useMemo (() => {
const set = new Set < string >()
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 (
< div className = "mt-2 overflow-hidden rounded border border-border/60" >
< table className = "w-full text-[10px]" >
< thead >
< tr className = "bg-surface-raised text-foreground-faint" >
< th className = "px-2 py-1 text-left font-medium" > column </ th >
< th className = "px-2 py-1 text-left font-medium" > before </ th >
< th className = "px-2 py-1 text-left font-medium" > after </ th >
</ tr >
</ thead >
< tbody >
{ keys . map (( k ) => {
const b = ( change . before || {})[ k ]
const a = ( change . after || {})[ k ]
const changed = JSON . stringify ( b ) !== JSON . stringify ( a )
return (
< tr key = { k } className = { cn ( 'border-t border-border/40' , changed && 'bg-amber-500/5' )}>
< td className = "px-2 py-1 font-mono text-foreground-muted" >{ k }</ td >
< td className = "px-2 py-1 font-mono text-rose-300/80" >{ fmt ( b )}</ td >
< td className = { cn ( 'px-2 py-1 font-mono' , changed ? 'text-emerald-300' : 'text-foreground-muted' )}>{ fmt ( a )}</ td >
</ tr >
)
})}
</ tbody >
</ table >
</ div >
)
}
export function ChangesView ({ liveChanges } : { liveChanges : CdcChange [] }) {
const [ seed , setSeed ] = useState < CdcChange [] >([])
const [ stats , setStats ] = useState < CdcStats | null >( null )
const [ source , setSource ] = useState ( 'all' )
const [ op , setOp ] = useState ( 'all' )
const [ expanded , setExpanded ] = useState < string | null >( null )
const [ connected , setConnected ] = useState ( false )
2026-06-28 22:41:48 +00:00
const [ flash , setFlash ] = useState ( false )
const prevTotal = useRef ( 0 )
2026-06-27 01:44:26 +02:00
const load = useCallback ( async () => {
const [ c , s ] = await Promise . all ([ fetchChanges ({ limit : 150 }), fetchChangeStats ( 15 )])
setSeed ( c . changes )
setConnected ( c . connected )
if ( s ) setStats ( s )
}, [])
useEffect (() => {
load ()
2026-06-28 22:41:48 +00:00
const iv = setInterval (() => fetchChangeStats ( 15 ). then (( s ) => s && setStats ( s )), 4000 )
2026-06-27 01:44:26 +02:00
return () => clearInterval ( iv )
}, [ load ])
2026-06-28 22:41:48 +00:00
// Pulse the header when fresh changes arrive.
useEffect (() => {
const t = stats ? . total ?? 0
if ( t > prevTotal . current ) {
setFlash ( true )
const id = setTimeout (() => setFlash ( false ), 900 )
prevTotal . current = t
return () => clearTimeout ( id )
}
prevTotal . current = t
}, [ stats ? . total ])
2026-06-27 01:44:26 +02:00
// Merge live (WS) with seeded backlog, dedupe by id, newest first.
const merged = useMemo (() => {
const byId = new Map < string , CdcChange >()
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 ],
)
2026-06-28 22:41:48 +00:00
const byOp = stats ? . by_op || {}
const inserts = byOp . insert || 0
const updates = byOp . update || 0
const deletes = byOp . delete || 0
const total = stats ? . total ?? 0
const perMin = total / Math . max ( 1 , stats ? . window_minutes ?? 15 )
const opSegments = useMemo (() => (
[ 'insert' , 'update' , 'delete' , 'snapshot' ]
. map (( k ) => ({ label : k , value : byOp [ k ] || 0 , color : opOf ( k ). color }))
. filter (( s ) => s . value > 0 )
), [ byOp ])
const sourceRows = useMemo (() => (
Object . entries ( stats ? . by_source || {}). sort (( a , b ) => b [ 1 ] - a [ 1 ])
), [ stats ? . by_source ])
const maxSource = Math . max ( 1 , ... sourceRows . map (([, v ]) => v ))
const tableRows = useMemo (() => (
Object . entries ( stats ? . by_table || {}). sort (( a , b ) => b [ 1 ] - a [ 1 ]). slice ( 0 , 7 )
), [ stats ? . by_table ])
const maxTable = Math . max ( 1 , ... tableRows . map (([, v ]) => v ))
2026-06-27 01:44:26 +02:00
return (
2026-06-28 22:41:48 +00:00
< div className = "flex h-full min-h-0 flex-col gap-3 overflow-y-auto scrollbar-thin pr-1" >
2026-06-27 01:44:26 +02:00
{ /* Header */ }
2026-06-28 22:41:48 +00:00
< div className = "flex shrink-0 items-center justify-between" >
2026-06-27 01:44:26 +02:00
< div >
< h1 className = "flex items-center gap-2 text-base font-semibold text-foreground" >
2026-06-28 22:41:48 +00:00
< Activity className = { cn ( 'h-4 w-4 text-docker' , flash && 'animate-pulse' )} /> New & amp ; Changed Data
2026-06-27 01:44:26 +02:00
</ h1 >
< p className = "text-[11px] text-foreground-muted" >
2026-06-28 22:41:48 +00:00
Live Debezium change data capture — every insert , update & amp ; delete across all source databases , streamed via Kafka
2026-06-27 01:44:26 +02:00
</ p >
</ div >
< div className = "flex items-center gap-3" >
< span className = { cn ( 'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-medium' ,
connected ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300' : 'border-rose-500/40 bg-rose-500/10 text-rose-300' )}>
< Radio className = { cn ( 'h-3 w-3' , connected && 'animate-pulse' )} /> { connected ? 'STREAMING' : 'OFFLINE' }
</ span >
< button type = "button" onClick = { load } className = "flex items-center gap-1 rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:text-docker" >
< RefreshCw className = "h-3 w-3" /> Refresh
</ button >
</ div >
</ div >
2026-06-28 22:41:48 +00:00
{ /* KPI row */ }
2026-06-27 01:44:26 +02:00
< div className = "grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4" >
2026-06-28 22:41:48 +00:00
< KpiCard label = "New records" value = { inserts } accent = "#34d399" icon = { PlusCircle } sub = "inserts · last 15m" />
< KpiCard label = "Updates" value = { updates } accent = "#fbbf24" icon = { Pencil } sub = "modified rows · 15m" />
< KpiCard label = "Deletes" value = { deletes } accent = "#fb7185" icon = { Trash2 } sub = "removed rows · 15m" />
< KpiCard label = "Throughput" value = { Math . round ( perMin )} accent = "#38bdf8" icon = { TrendingUp } sub = { `changes/min · ${ ( stats ? . consumed ?? 0 ). toLocaleString () } total consumed` } />
</ div >
{ /* Volume + operation mix */ }
< div className = "grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-3" >
< div className = "rounded-lg border border-border/60 bg-surface-raised p-3 lg:col-span-2" >
< div className = "mb-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint" >
< TrendingUp className = "h-3 w-3" /> Change volume — last 15 minutes
</ div >
< VolumeArea buckets = { stats ? . buckets || []} />
2026-06-27 01:44:26 +02:00
</ div >
< div className = "rounded-lg border border-border/60 bg-surface-raised p-3" >
2026-06-28 22:41:48 +00:00
< div className = "mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint" >
< Layers className = "h-3 w-3" /> Operation mix
</ div >
{ opSegments . length ? < Donut segments = { opSegments } total = { total } /> : (
< div className = "flex h-28 items-center justify-center text-[10px] text-foreground-faint" > No changes yet … </ div >
)}
2026-06-27 01:44:26 +02:00
</ div >
2026-06-28 22:41:48 +00:00
</ div >
{ /* By system + top tables */ }
< div className = "grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-2" >
2026-06-27 01:44:26 +02:00
< div className = "rounded-lg border border-border/60 bg-surface-raised p-3" >
2026-06-28 22:41:48 +00:00
< div className = "mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint" >
< Database className = "h-3 w-3" /> New & amp ; changed by system
</ div >
< div className = "space-y-2" >
{ sourceRows . length ? sourceRows . map (([ k , v ]) => (
< BarRow key = { k } label = { k } value = { v } max = { maxSource } color = { SOURCE_COLOR [ k ] || '#94a3b8' } />
)) : < div className = "text-[10px] text-foreground-faint" > No source activity in the window … </ div >}
2026-06-27 01:44:26 +02:00
</ div >
</ div >
< div className = "rounded-lg border border-border/60 bg-surface-raised p-3" >
2026-06-28 22:41:48 +00:00
< div className = "mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint" >
< Layers className = "h-3 w-3" /> Most active tables / collections
</ div >
< div className = "space-y-2" >
{ tableRows . length ? tableRows . map (([ k , v ]) => (
< BarRow key = { k } label = { k } value = { v } max = { maxTable } color = "#818cf8" />
)) : < div className = "text-[10px] text-foreground-faint" > No table activity yet … </ div >}
2026-06-27 01:44:26 +02:00
</ div >
</ div >
</ div >
{ /* Filters */ }
< div className = "flex shrink-0 flex-wrap items-center gap-2" >
2026-06-28 22:41:48 +00:00
< span className = "text-[10px] font-semibold uppercase tracking-wide text-foreground-muted" > Latest changes </ span >
< span className = "mx-1 h-3 w-px bg-border/60" />
2026-06-27 01:44:26 +02:00
< span className = "text-[9px] uppercase tracking-wide text-foreground-faint" > Source </ span >
{ SOURCES . map (( s ) => (
< button key = { s } type = "button" onClick = {() => setSource ( s )}
className = { cn ( 'rounded-full border px-2.5 py-0.5 text-[10px] capitalize' ,
source === s ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border/60 text-foreground-muted hover:text-foreground' )}>
{ s }
</ button >
))}
< span className = "ml-3 text-[9px] uppercase tracking-wide text-foreground-faint" > Op </ span >
{ OPS . map (( o ) => (
< button key = { o } type = "button" onClick = {() => setOp ( o )}
className = { cn ( 'rounded-full border px-2.5 py-0.5 text-[10px] capitalize' ,
op === o ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border/60 text-foreground-muted hover:text-foreground' )}>
{ o }
</ button >
))}
</ div >
{ /* Live list */ }
2026-06-28 22:41:48 +00:00
< div className = "min-h-[180px] rounded-lg border border-border/60 bg-surface-raised" >
2026-06-27 01:44:26 +02:00
{ filtered . length === 0 && (
< div className = "p-6 text-center text-[11px] text-foreground-faint" >
2026-06-28 22:41:48 +00:00
Waiting for changes … trigger data generation ( Data Flow → Generate data ) or agent DML to see live CDC events .
2026-06-27 01:44:26 +02:00
</ div >
)}
{ filtered . map (( c ) => (
< div key = { c . id } className = "border-b border-border/40 last:border-0" >
< button type = "button" onClick = {() => setExpanded ( expanded === c . id ? null : c . id )}
className = "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-surface" >
< span className = { cn ( 'w-[68px] shrink-0 rounded border px-1 py-0.5 text-center text-[9px] font-semibold' , opOf ( c . op ). cls )}>{ opOf ( c . op ). label }</ span >
< span className = "flex shrink-0 items-center gap-1 text-[10px] text-foreground-muted" >
< span className = "h-2 w-2 rounded-full" style = {{ background : SOURCE_COLOR [ c . source ] || '#94a3b8' }} />
< span className = "font-mono" >{ c . source }.{ c . table }</ span >
</ span >
< span className = "min-w-0 flex-1 truncate font-mono text-[10px] text-foreground" >{ c . summary || '—' }</ span >
< span className = "shrink-0 text-[9px] text-foreground-faint" >{ timeAgo ( c . ts )}</ span >
</ button >
{ expanded === c . id && < div className = "px-3 pb-3" >< Diff change = { c } /></ div >}
</ div >
))}
</ div >
</ div >
)
}