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 { StorageView } from './components/features/StorageView'
|
||||||
import { HdfsView } from './components/features/HdfsView'
|
import { HdfsView } from './components/features/HdfsView'
|
||||||
import { DataGenView } from './components/features/DataGenView'
|
import { DataGenView } from './components/features/DataGenView'
|
||||||
|
import { ChangesView } from './components/features/ChangesView'
|
||||||
import { SearchView } from './components/features/SearchView'
|
import { SearchView } from './components/features/SearchView'
|
||||||
import { SshTerminal } from './components/features/SshTerminal'
|
import { SshTerminal } from './components/features/SshTerminal'
|
||||||
import { TerminalDock } from './components/features/TerminalDock'
|
import { TerminalDock } from './components/features/TerminalDock'
|
||||||
@@ -128,6 +129,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : cc.mainView === 'datagen' ? (
|
) : cc.mainView === 'datagen' ? (
|
||||||
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
|
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
|
||||||
|
) : cc.mainView === 'changes' ? (
|
||||||
|
<ChangesView liveChanges={cc.changes} />
|
||||||
) : cc.mainView === 'presentation' ? (
|
) : cc.mainView === 'presentation' ? (
|
||||||
<PresentationView />
|
<PresentationView />
|
||||||
) : cc.mainView === 'dataquality' ? (
|
) : 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 { GpuStatus, WorkloadData } from '../../types'
|
||||||
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
@@ -6,7 +6,7 @@ import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
|
|||||||
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
|
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
|
||||||
import { LabHealthPanel } from '../features/LabHealthPanel'
|
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 = {
|
type Props = {
|
||||||
workload: WorkloadData | null
|
workload: WorkloadData | null
|
||||||
@@ -25,6 +25,7 @@ type Props = {
|
|||||||
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
|
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
|
||||||
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
|
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
|
||||||
{ id: 'datagen', label: 'Data Generation', icon: Cpu },
|
{ id: 'datagen', label: 'Data Generation', icon: Cpu },
|
||||||
|
{ id: 'changes', label: 'Live Changes', icon: Activity },
|
||||||
{ id: 'presentation', label: 'Presentation', icon: Presentation },
|
{ id: 'presentation', label: 'Presentation', icon: Presentation },
|
||||||
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
|
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
|
||||||
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
|
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
AgentAnim,
|
AgentAnim,
|
||||||
Approval,
|
Approval,
|
||||||
|
CdcChange,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
FeedEntry,
|
FeedEntry,
|
||||||
GpuStatus,
|
GpuStatus,
|
||||||
@@ -68,7 +69,8 @@ export function useCommandCenter() {
|
|||||||
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
|
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
|
||||||
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
|
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
|
||||||
const [nodeBusy, setNodeBusy] = useState(false)
|
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 [genPulse, setGenPulse] = useState(false)
|
||||||
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const pulseFlow = useCallback(() => {
|
const pulseFlow = useCallback(() => {
|
||||||
@@ -135,6 +137,7 @@ export function useCommandCenter() {
|
|||||||
if (msg.type === 'terminal') appendTerminal(msg.line)
|
if (msg.type === 'terminal') appendTerminal(msg.line)
|
||||||
if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
|
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 === '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') {
|
if (msg.type === 'agent_dispatch') {
|
||||||
setSelectedAgentId(msg.agent_id)
|
setSelectedAgentId(msg.agent_id)
|
||||||
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
|
||||||
@@ -343,6 +346,7 @@ export function useCommandCenter() {
|
|||||||
nodeBusy,
|
nodeBusy,
|
||||||
mainView,
|
mainView,
|
||||||
setMainView,
|
setMainView,
|
||||||
|
changes,
|
||||||
genPulse,
|
genPulse,
|
||||||
pulseFlow,
|
pulseFlow,
|
||||||
approvalHighlight,
|
approvalHighlight,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type {
|
|||||||
PresentationData,
|
PresentationData,
|
||||||
Agent,
|
Agent,
|
||||||
Approval,
|
Approval,
|
||||||
|
CdcChange,
|
||||||
|
CdcStats,
|
||||||
FeedEntry,
|
FeedEntry,
|
||||||
GpuStatus,
|
GpuStatus,
|
||||||
StatusData,
|
StatusData,
|
||||||
@@ -92,6 +94,41 @@ export async function fetchPresentation(): Promise<PresentationData | null> {
|
|||||||
return fetchJson<PresentationData>('/api/presentation', 60000)
|
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) {
|
export async function decideApproval(id: string, approved: boolean, decidedBy: string, note: string) {
|
||||||
return fetch(`/api/approvals/${id}/decide`, {
|
return fetch(`/api/approvals/${id}/decide`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -46,6 +46,30 @@ export type FeedEntry = {
|
|||||||
level: string
|
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 = {
|
export type DomainStatus = {
|
||||||
level: 'ok' | 'warn' | 'down' | 'unknown'
|
level: 'ok' | 'warn' | 'down' | 'unknown'
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
Reference in New Issue
Block a user