2026-06-28 22:41:48 +00:00
import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
2026-06-29 13:31:11 +00:00
import { Activity , Radio , RefreshCw , PlusCircle , Pencil , Trash2 , Database , Layers , TrendingUp , Cable } from 'lucide-react'
import { fetchChanges , fetchChangeStats , resyncSources } from '../../lib/api'
2026-06-27 01:44:26 +02:00
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' ]
2026-07-01 15:14:48 +02:00
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
2026-06-27 01:44:26 +02:00
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 )
2026-06-29 13:31:11 +00:00
const [ resyncing , setResyncing ] = useState ( false )
const [ resyncMsg , setResyncMsg ] = useState < string | null >( null )
2026-07-22 00:31:35 +00:00
const [ windowMin , setWindowMin ] = useState ( 15 )
2026-06-28 23:33:21 +00:00
// 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 < string , number >, by_source : {} as Record < string , number >, by_table : {} as Record < string , number > }
const [ overlay , setOverlay ] = useState ( emptyOverlay )
const lastSeenId = useRef < string | null >( 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
}, [])
2026-06-27 01:44:26 +02:00
const load = useCallback ( async () => {
2026-07-22 00:31:35 +00:00
const [ c , s ] = await Promise . all ([ fetchChanges ({ limit : 200 , minutes : windowMin }), fetchChangeStats ( windowMin )])
2026-06-27 01:44:26 +02:00
setSeed ( c . changes )
setConnected ( c . connected )
2026-06-28 23:33:21 +00:00
applyStats ( s )
2026-07-22 00:31:35 +00:00
}, [ applyStats , windowMin ])
2026-06-27 01:44:26 +02:00
2026-06-29 13:31:11 +00:00
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 ])
2026-06-27 01:44:26 +02:00
useEffect (() => {
load ()
2026-07-22 00:31:35 +00:00
const iv = setInterval (() => fetchChangeStats ( windowMin ). then (( s ) => applyStats ( s )), 2500 )
2026-06-27 01:44:26 +02:00
return () => clearInterval ( iv )
2026-07-22 00:31:35 +00:00
}, [ load , applyStats , windowMin ])
2026-06-27 01:44:26 +02:00
2026-06-28 23:33:21 +00:00
// Fold freshly-arrived WS changes into the overlay → instant top-of-page update.
2026-06-28 22:41:48 +00:00
useEffect (() => {
2026-06-28 23:33:21 +00:00
if ( ! liveChanges . length ) return
if ( ! primed . current ) {
primed . current = true
lastSeenId . current = liveChanges [ 0 ]. id
return
2026-06-28 22:41:48 +00:00
}
2026-06-28 23:33:21 +00:00
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 ])
2026-06-28 22:41:48 +00:00
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 23:33:21 +00:00
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
2026-06-28 22:41:48 +00:00
const perMin = total / Math . max ( 1 , stats ? . window_minutes ?? 15 )
const opSegments = useMemo (() => (
[ 'insert' , 'update' , 'delete' , 'snapshot' ]
2026-06-28 23:33:21 +00:00
. map (( k ) => ({ label : k , value : ( stats ? . by_op ? .[ k ] || 0 ) + ( overlay . by_op [ k ] || 0 ), color : opOf ( k ). color }))
2026-06-28 22:41:48 +00:00
. filter (( s ) => s . value > 0 )
2026-06-28 23:33:21 +00:00
), [ stats ? . by_op , overlay ])
2026-06-28 22:41:48 +00:00
2026-06-28 23:33:21 +00:00
const sourceRows = useMemo (() => {
const m : Record < string , number > = { ...( 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 ])
2026-06-28 22:41:48 +00:00
const maxSource = Math . max ( 1 , ... sourceRows . map (([, v ]) => v ))
2026-06-28 23:33:21 +00:00
const tableRows = useMemo (() => {
const m : Record < string , number > = { ...( 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 ])
2026-06-28 22:41:48 +00:00
const maxTable = Math . max ( 1 , ... tableRows . map (([, v ]) => v ))
2026-06-27 01:44:26 +02:00
2026-06-28 23:33:21 +00:00
// 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 ])
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" >
2026-07-01 15:14:48 +02:00
< div className = "flex items-center gap-1 rounded border border-border/60 p-0.5" >
{ TIME_WINDOWS . map (( w ) => (
< button
key = { w . minutes }
type = "button"
onClick = {() => setWindowMin ( w . minutes )}
className = { cn (
'rounded px-2 py-0.5 text-[10px]' ,
windowMin === w . minutes
? 'bg-docker/20 text-docker'
: 'text-foreground-muted hover:text-foreground' ,
)}
>
{ w . label }
</ button >
))}
</ div >
2026-06-27 01:44:26 +02:00
< 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 >
2026-06-29 13:31:11 +00:00
{ resyncMsg && (
< span className = "hidden text-[10px] text-amber-300/90 md:inline" >{ resyncMsg }</ span >
)}
< button
type = "button"
onClick = { doResync }
disabled = { resyncing }
title = "Restart all Debezium source connectors so CDC catches up after a database outage"
className = "flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[10px] font-medium text-amber-300 hover:bg-amber-500/20 disabled:opacity-60"
>
< Cable className = { cn ( 'h-3 w-3' , resyncing && 'animate-spin' )} /> { resyncing ? 'Re-syncing…' : 'Re-sync sources' }
</ button >
2026-06-27 01:44:26 +02:00
< 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-07-22 00:31:35 +00:00
< KpiCard label = "New records" value = { inserts } accent = "#34d399" icon = { PlusCircle } sub = { `inserts · last ${ windowMin } m` } />
< KpiCard label = "Updates" value = { updates } accent = "#fbbf24" icon = { Pencil } sub = { `modified rows · ${ windowMin } m` } />
< KpiCard label = "Deletes" value = { deletes } accent = "#fb7185" icon = { Trash2 } sub = { `removed rows · ${ windowMin } m` } />
2026-06-28 22:41:48 +00:00
< 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" >
2026-07-22 00:31:35 +00:00
< TrendingUp className = "h-3 w-3" /> Change volume — selected window
2026-06-28 22:41:48 +00:00
</ div >
2026-06-28 23:33:21 +00:00
< VolumeArea buckets = { liveBuckets } />
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 >
)
}