feat(ui): Live Changes (CDC) tab — real-time Debezium stream with filters, volume bars and before/after diff
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user