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
+3
View File
@@ -18,6 +18,7 @@ import { KnowledgeChatView } from './components/features/KnowledgeChatView'
import { StorageView } from './components/features/StorageView'
import { HdfsView } from './components/features/HdfsView'
import { DataGenView } from './components/features/DataGenView'
import { ChangesView } from './components/features/ChangesView'
import { SearchView } from './components/features/SearchView'
import { SshTerminal } from './components/features/SshTerminal'
import { TerminalDock } from './components/features/TerminalDock'
@@ -128,6 +129,8 @@ export default function App() {
/>
) : cc.mainView === 'datagen' ? (
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
) : cc.mainView === 'changes' ? (
<ChangesView liveChanges={cc.changes} />
) : cc.mainView === 'presentation' ? (
<PresentationView />
) : cc.mainView === 'dataquality' ? (
+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>
)
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu } from 'lucide-react'
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu, Activity } from 'lucide-react'
import type { GpuStatus, WorkloadData } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils'
@@ -6,7 +6,7 @@ import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
import { LabHealthPanel } from '../features/LabHealthPanel'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals'
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals' | 'changes' | 'dataflow'
type Props = {
workload: WorkloadData | null
@@ -25,6 +25,7 @@ type Props = {
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
{ id: 'datagen', label: 'Data Generation', icon: Cpu },
{ id: 'changes', label: 'Live Changes', icon: Activity },
{ id: 'presentation', label: 'Presentation', icon: Presentation },
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
+5 -1
View File
@@ -19,6 +19,7 @@ import type {
Agent,
AgentAnim,
Approval,
CdcChange,
ChatMessage,
FeedEntry,
GpuStatus,
@@ -68,7 +69,8 @@ export function useCommandCenter() {
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
const [nodeBusy, setNodeBusy] = useState(false)
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen'>('platform')
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'changes' | 'dataflow'>('platform')
const [changes, setChanges] = useState<CdcChange[]>([])
const [genPulse, setGenPulse] = useState(false)
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pulseFlow = useCallback(() => {
@@ -135,6 +137,7 @@ export function useCommandCenter() {
if (msg.type === 'terminal') appendTerminal(msg.line)
if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
if (msg.type === 'cdc_change' && msg.entry) setChanges((prev) => [msg.entry, ...prev].slice(0, 400))
if (msg.type === 'agent_dispatch') {
setSelectedAgentId(msg.agent_id)
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
@@ -343,6 +346,7 @@ export function useCommandCenter() {
nodeBusy,
mainView,
setMainView,
changes,
genPulse,
pulseFlow,
approvalHighlight,
+37
View File
@@ -2,6 +2,8 @@ import type {
PresentationData,
Agent,
Approval,
CdcChange,
CdcStats,
FeedEntry,
GpuStatus,
StatusData,
@@ -92,6 +94,41 @@ export async function fetchPresentation(): Promise<PresentationData | null> {
return fetchJson<PresentationData>('/api/presentation', 60000)
}
export async function fetchChanges(opts: { source?: string; op?: string; limit?: number } = {}) {
const p = new URLSearchParams()
if (opts.source) p.set('source', opts.source)
if (opts.op) p.set('op', opts.op)
p.set('limit', String(opts.limit ?? 150))
const j = await fetchJson<{ changes?: CdcChange[]; connected?: boolean; consumed?: number }>(
`/api/changes?${p.toString()}`, 8000,
)
return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0 }
}
export async function fetchChangeStats(minutes = 15): Promise<CdcStats | null> {
return fetchJson<CdcStats>(`/api/changes/stats?minutes=${minutes}`, 8000)
}
export async function fetchAgentOpsStatus() {
return fetchJson<Record<string, unknown>>('/api/agent-ops/status', 8000)
}
export function toggleAgentOps(enabled?: boolean) {
return fetch('/api/agent-ops/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(enabled === undefined ? {} : { enabled }),
})
}
export function runAgentOpOnce(source?: string, op?: string) {
return fetch('/api/agent-ops/run-once', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, op }),
})
}
export async function decideApproval(id: string, approved: boolean, decidedBy: string, note: string) {
return fetch(`/api/approvals/${id}/decide`, {
method: 'POST',
+24
View File
@@ -46,6 +46,30 @@ export type FeedEntry = {
level: string
}
export type CdcChange = {
id: string
ts: string
source: string
table: string
topic: string
op: string
summary: string
before: Record<string, unknown> | null
after: Record<string, unknown> | null
}
export type CdcStats = {
ok: boolean
window_minutes: number
total: number
by_source: Record<string, number>
by_op: Record<string, number>
by_table: Record<string, number>
buckets: { t: string; n: number }[]
connected: boolean
consumed: number
}
export type DomainStatus = {
level: 'ok' | 'warn' | 'down' | 'unknown'
label: string