Fix Dockhand auth for topology flow and accurate CDC change stats.

Pass DOCKHAND_API_TOKEN to container inventory calls so pipeline_active
and topology animation work after Authentik. Replace ring-buffer-only CDC
stats with minute rollups (no 1000 cap), add 15m/1h/6h/24h window selector
on the Live Changes tab, and poll recent events on an interval.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-07-01 15:14:48 +02:00
parent cdc8aa4bf7
commit b46e7f01dd
10 changed files with 537 additions and 106 deletions
+352 -56
View File
@@ -1,14 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Activity, Radio, RefreshCw } from 'lucide-react'
import { fetchChanges, fetchChangeStats } from '../../lib/api'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Activity, Radio, RefreshCw, PlusCircle, Pencil, Trash2, Database, Layers, TrendingUp, Cable } from 'lucide-react'
import { fetchChanges, fetchChangeStats, resyncSources } 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 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' },
}
const SOURCE_COLOR: Record<string, string> = {
@@ -21,9 +21,15 @@ const SOURCE_COLOR: Record<string, string> = {
const SOURCES = ['all', 'postgres', 'mysql', 'mongodb', 'cassandra', 'neo4j']
const OPS = ['all', 'insert', 'update', 'delete']
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
function opOf(o: string) {
return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30' }
return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30', color: '#94a3b8' }
}
function timeAgo(ts: string) {
@@ -34,6 +40,147 @@ function timeAgo(ts: string) {
return `${Math.floor(d / 3600000)}h ago`
}
// 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>
)
}
function Diff({ change }: { change: CdcChange }) {
const keys = useMemo(() => {
const set = new Set<string>()
@@ -82,19 +229,94 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
const [op, setOp] = useState('all')
const [expanded, setExpanded] = useState<string | null>(null)
const [connected, setConnected] = useState(false)
const [flash, setFlash] = useState(false)
const [resyncing, setResyncing] = useState(false)
const [resyncMsg, setResyncMsg] = useState<string | null>(null)
const [windowMin, setWindowMin] = useState(15)
// 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
}, [])
const load = useCallback(async () => {
const [c, s] = await Promise.all([fetchChanges({ limit: 150 }), fetchChangeStats(15)])
const [c, s] = await Promise.all([
fetchChanges({ limit: 300, minutes: windowMin }),
fetchChangeStats(windowMin),
])
setSeed(c.changes)
setConnected(c.connected)
if (s) setStats(s)
}, [])
applyStats(s)
}, [applyStats, windowMin])
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])
useEffect(() => {
load()
const iv = setInterval(() => fetchChangeStats(15).then((s) => s && setStats(s)), 5000)
return () => clearInterval(iv)
}, [load])
const iv = setInterval(() => fetchChangeStats(windowMin).then((s) => applyStats(s)), 2500)
const listIv = setInterval(() => {
fetchChanges({ limit: 300, minutes: windowMin }).then((c) => {
setSeed(c.changes)
setConnected(c.connected)
})
}, 5000)
return () => {
clearInterval(iv)
clearInterval(listIv)
}
}, [load, applyStats, windowMin])
// Fold freshly-arrived WS changes into the overlay → instant top-of-page update.
useEffect(() => {
if (!liveChanges.length) return
if (!primed.current) {
primed.current = true
lastSeenId.current = liveChanges[0].id
return
}
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])
// Merge live (WS) with seeded backlog, dedupe by id, newest first.
const merged = useMemo(() => {
@@ -109,78 +331,152 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
[merged, source, op],
)
const maxBucket = Math.max(1, ...(stats?.buckets || []).map((b) => b.n))
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
const perMin = (stats?.rate_per_min ?? total / Math.max(1, windowMin)) + (overlay.total / Math.max(1, windowMin))
const windowLabel = TIME_WINDOWS.find((w) => w.minutes === windowMin)?.label ?? `${windowMin}m`
const opSegments = useMemo(() => (
['insert', 'update', 'delete', 'snapshot']
.map((k) => ({ label: k, value: (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0), color: opOf(k).color }))
.filter((s) => s.value > 0)
), [stats?.by_op, overlay])
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])
const maxSource = Math.max(1, ...sourceRows.map(([, v]) => v))
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])
const maxTable = Math.max(1, ...tableRows.map(([, v]) => v))
// 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])
return (
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto scrollbar-thin pr-1">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex shrink-0 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
<Activity className={cn('h-4 w-4 text-docker', flash && 'animate-pulse')} /> New &amp; Changed Data
</h1>
<p className="text-[11px] text-foreground-muted">
Real-time Debezium change data capture from all source databases via Kafka
Live Debezium change data capture every insert, update &amp; delete across all source databases, streamed via Kafka
</p>
</div>
<div className="flex items-center gap-3">
<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>
<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>
{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>
<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 */}
{/* KPI row */}
<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>}
<KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub={`inserts · last ${windowLabel}`} />
<KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub={`modified rows · ${windowLabel}`} />
<KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub={`removed rows · ${windowLabel}`} />
<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">
<TrendingUp className="h-3 w-3" /> Change volume last {windowLabel}
{windowMin >= 120 && <span className="normal-case text-foreground-faint">(hourly buckets)</span>}
</div>
<VolumeArea buckets={liveBuckets} />
</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 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>
)}
</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>}
{/* By system + top tables */}
<div className="grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-2">
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<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>}
</div>
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<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>}
</div>
</div>
</div>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<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" />
<span className="text-[9px] uppercase tracking-wide text-foreground-faint">Source</span>
{SOURCES.map((s) => (
<button key={s} type="button" onClick={() => setSource(s)}
@@ -200,10 +496,10 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
</div>
{/* Live list */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin rounded-lg border border-border/60 bg-surface-raised">
<div className="min-h-[180px] 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.
Waiting for changes trigger data generation (Data Flow Generate data) or agent DML to see live CDC events.
</div>
)}
{filtered.map((c) => (
+17 -4
View File
@@ -100,21 +100,34 @@ export async function fetchPresentation(): Promise<PresentationData | null> {
return fetchJson<PresentationData>('/api/presentation', 60000)
}
export async function fetchChanges(opts: { source?: string; op?: string; limit?: number } = {}) {
export async function fetchChanges(opts: { source?: string; op?: string; limit?: number; minutes?: 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 }>(
p.set('limit', String(opts.limit ?? 200))
if (opts.minutes) p.set('minutes', String(opts.minutes))
const j = await fetchJson<{ changes?: CdcChange[]; connected?: boolean; consumed?: number; last_ts?: string | null }>(
`/api/changes?${p.toString()}`, 8000,
)
return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0 }
return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0, last_ts: j?.last_ts }
}
export async function fetchChangeStats(minutes = 15): Promise<CdcStats | null> {
return fetchJson<CdcStats>(`/api/changes/stats?minutes=${minutes}`, 8000)
}
export type ConnectorState = { name: string; state?: string; failed?: number[] }
export type ResyncResult = { ok: boolean; restarted?: string[]; healthy?: number; total?: number; after?: ConnectorState[] }
export async function resyncSources(force = true): Promise<ResyncResult | null> {
const r = await fetch('/api/pipeline/streaming/resync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
})
return r.ok ? ((await r.json()) as ResyncResult) : null
}
export async function fetchAgentOpsStatus() {
return fetchJson<Record<string, unknown>>('/api/agent-ops/status', 8000)
}
+1
View File
@@ -168,6 +168,7 @@ export const INFRA_CATALOG: InfraNode[] = [
ssh: 'ssh root@10.0.20.106',
apps: [
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
{ label: 'Dockhand env 8', url: 'http://10.0.21.45:8082', port: '8082' },
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
],
topoIds: ['llm', 'cons-ml'],
+7
View File
@@ -62,12 +62,19 @@ export type CdcStats = {
ok: boolean
window_minutes: number
total: number
inserts?: number
updates?: number
deletes?: number
rate_per_min?: 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
buffered?: number
buffer_cap?: number
last_ts?: string | null
}
export type PiiColumn = { name: string; category: string; masked: boolean; policy_locked?: boolean }