feat(ui): Live Changes (CDC) tab — real-time Debezium stream with filters, volume bars and before/after diff

This commit is contained in:
mo
2026-06-27 01:44:26 +02:00
parent 1a454f76cf
commit 921342442f
6 changed files with 299 additions and 3 deletions
+227
View File
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Activity, Radio, RefreshCw } from 'lucide-react'
import { fetchChanges, fetchChangeStats } from '../../lib/api'
import type { CdcChange, CdcStats } from '../../types'
import { cn } from '../../lib/utils'
const OP_STYLE: Record<string, { label: string; cls: string }> = {
insert: { label: 'INSERT', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' },
update: { label: 'UPDATE', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30' },
delete: { label: 'DELETE', cls: 'bg-rose-500/15 text-rose-300 border-rose-500/30' },
snapshot: { label: 'SNAPSHOT', cls: 'bg-sky-500/15 text-sky-300 border-sky-500/30' },
}
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) {
return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30' }
}
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`
}
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)
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()
const iv = setInterval(() => fetchChangeStats(15).then((s) => s && setStats(s)), 5000)
return () => clearInterval(iv)
}, [load])
// 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],
)
const maxBucket = Math.max(1, ...(stats?.buckets || []).map((b) => b.n))
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
<Activity className="h-4 w-4 text-docker" /> Live Changes · CDC Stream
</h1>
<p className="text-[11px] text-foreground-muted">
Real-time Debezium change data capture from all source databases via Kafka
</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>
{/* Stat cards */}
<div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4">
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">Changes / 15 min</div>
<div className="text-xl font-semibold text-foreground">{stats?.total ?? 0}</div>
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">Total consumed</div>
<div className="text-xl font-semibold text-foreground">{stats?.consumed ?? 0}</div>
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">By operation</div>
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(stats?.by_op || {}).map(([k, v]) => (
<span key={k} className={cn('rounded border px-1.5 py-0.5 text-[9px]', opOf(k).cls)}>{opOf(k).label} {v}</span>
))}
{!Object.keys(stats?.by_op || {}).length && <span className="text-[10px] text-foreground-faint"></span>}
</div>
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">By source</div>
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(stats?.by_source || {}).map(([k, v]) => (
<span key={k} className="flex items-center gap-1 rounded border border-border/60 px-1.5 py-0.5 text-[9px] text-foreground-muted">
<span className="h-2 w-2 rounded-full" style={{ background: SOURCE_COLOR[k] || '#94a3b8' }} />{k} {v}
</span>
))}
{!Object.keys(stats?.by_source || {}).length && <span className="text-[10px] text-foreground-faint"></span>}
</div>
</div>
</div>
{/* Volume sparkbars */}
<div className="shrink-0 rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="mb-1.5 text-[9px] uppercase tracking-wide text-foreground-faint">Change volume per minute (last 15m)</div>
<div className="flex h-16 items-end gap-0.5">
{(stats?.buckets || []).map((b) => (
<div key={b.t} className="group relative flex-1" title={`${b.t}: ${b.n}`}>
<div className="w-full rounded-t bg-docker/70 transition-all group-hover:bg-docker" style={{ height: `${Math.max(4, (b.n / maxBucket) * 100)}%` }} />
</div>
))}
{!(stats?.buckets || []).length && <div className="text-[10px] text-foreground-faint">No changes in the window yet</div>}
</div>
</div>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<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 */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin rounded-lg border border-border/60 bg-surface-raised">
{filtered.length === 0 && (
<div className="p-6 text-center text-[11px] text-foreground-faint">
Waiting for changes trigger data generation or agent DML to see live CDC events.
</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>
)
}