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