feat: Nessie catalog UI + Iceberg structure explorer + live Data Flow online/offline

Add Project Nessie on lake01 with Command Center Iceberg tab (snapshots,
manifests, data files, time-travel SQL). Data Flow pulses only when endpoints
are reachable; offline nodes/edges render red.
This commit is contained in:
mo
2026-07-22 00:31:35 +00:00
parent d5fba208a3
commit b54f5c7a06
9 changed files with 815 additions and 23 deletions
+9 -8
View File
@@ -232,6 +232,7 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
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
@@ -248,11 +249,11 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
}, [])
const load = useCallback(async () => {
const [c, s] = await Promise.all([fetchChanges({ limit: 200 }), fetchChangeStats(15)])
const [c, s] = await Promise.all([fetchChanges({ limit: 200, minutes: windowMin }), fetchChangeStats(windowMin)])
setSeed(c.changes)
setConnected(c.connected)
applyStats(s)
}, [applyStats])
}, [applyStats, windowMin])
const doResync = useCallback(async () => {
setResyncing(true)
@@ -275,9 +276,9 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
useEffect(() => {
load()
const iv = setInterval(() => fetchChangeStats(15).then((s) => applyStats(s)), 2500)
const iv = setInterval(() => fetchChangeStats(windowMin).then((s) => applyStats(s)), 2500)
return () => clearInterval(iv)
}, [load, applyStats])
}, [load, applyStats, windowMin])
// Fold freshly-arrived WS changes into the overlay → instant top-of-page update.
useEffect(() => {
@@ -410,9 +411,9 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
{/* KPI row */}
<div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4">
<KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub="inserts · last 15m" />
<KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub="modified rows · 15m" />
<KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub="removed rows · 15m" />
<KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub={`inserts · last ${windowMin}m`} />
<KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub={`modified rows · ${windowMin}m`} />
<KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub={`removed rows · ${windowMin}m`} />
<KpiCard label="Throughput" value={Math.round(perMin)} accent="#38bdf8" icon={TrendingUp} sub={`changes/min · ${(stats?.consumed ?? 0).toLocaleString()} total consumed`} />
</div>
@@ -420,7 +421,7 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
<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 15 minutes
<TrendingUp className="h-3 w-3" /> Change volume selected window
</div>
<VolumeArea buckets={liveBuckets} />
</div>
@@ -18,6 +18,7 @@ import {
Radio,
GitBranch,
Lock,
Layers,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { TrinoFederationView, type SubTab } from './TrinoFederationView'
@@ -26,14 +27,16 @@ import { LineageView } from './LineageView'
import { GovernanceOwnershipView } from './GovernanceOwnershipView'
import { GovernanceAccessView } from './GovernanceAccessView'
import { ObservabilityView } from './ObservabilityView'
import { IcebergNessieView } from './IcebergNessieView'
type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability'
type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability' | 'iceberg'
const TABS: { id: ExplorerTab; label: string; icon: typeof Users; hint: string; live?: boolean }[] = [
{ id: 'business', label: 'Business Overview', icon: BarChart3, hint: 'Customers, orders, workforce, supply chain & telemetry across every source' },
{ id: 'live', label: 'Live', icon: Radio, hint: 'Realtime business activity — live counters, ingestion throughput & region matrix', live: true },
{ id: 'federated', label: 'Federated (Trino)', icon: Network, hint: 'One SQL across all 5 databases + region scorecard joined live' },
{ id: 'lake', label: 'Hadoop Lake', icon: HardDrive, hint: 'All business data mirrored as external Iceberg tables on HDFS' },
{ id: 'iceberg', label: 'Iceberg', icon: Layers, hint: 'Nessie catalog · snapshots · manifests · data files · time travel' },
{ id: 'dictionary', label: 'Data Dictionary', icon: ShieldCheck, hint: 'Every table & column with PII / masking status — exactly what the assistant sees' },
{ id: 'lineage', label: 'Lineage', icon: GitBranch, hint: 'End-to-end data lineage with column-level PII tracing from source to curated layer' },
{ id: 'ownership', label: 'Ownership', icon: UserCog, hint: 'Data owners, stewards, tiers & business glossary — accountability per dataset' },
@@ -310,6 +313,7 @@ export function DataExplorerView() {
{view === 'ownership' && <div className="flex min-h-0 flex-1 flex-col"><GovernanceOwnershipView /></div>}
{view === 'access' && <div className="flex min-h-0 flex-1 flex-col"><GovernanceAccessView /></div>}
{view === 'observability' && <div className="flex min-h-0 flex-1 flex-col"><ObservabilityView /></div>}
{view === 'iceberg' && <div className="flex min-h-0 flex-1 flex-col"><IcebergNessieView /></div>}
{/* ───────── BUSINESS OVERVIEW ───────── */}
{view === 'business' && (
+15 -11
View File
@@ -22,6 +22,7 @@ const NODE_KIND: Record<string, { ring: string; chip: string; dot: string }> = {
rag: { ring: 'border-pink-400/60', chip: 'bg-pink-500/15 text-pink-300 border-pink-400/40', dot: '#f472b6' },
llm: { ring: 'border-rose-400/70', chip: 'bg-rose-500/15 text-rose-200 border-rose-400/50', dot: '#fb7185' },
chat: { ring: 'border-indigo-400/60', chip: 'bg-indigo-500/15 text-indigo-300 border-indigo-400/40', dot: '#818cf8' },
catalog: { ring: 'border-lime-400/60', chip: 'bg-lime-500/15 text-lime-300 border-lime-400/40', dot: '#a3e635' },
}
const EDGE_COLOR: Record<string, string> = {
@@ -425,9 +426,10 @@ export function DataFlowView() {
const b = anchors[edge.to]
if (!a || !b) return null
const d = edgePath(a, b)
const color = EDGE_COLOR[edge.kind] || '#64748b'
const running = edge.state === 'running'
const active = edge.active || running
const offline = Boolean((edge as any).offline)
const color = offline ? '#f43f5e' : (EDGE_COLOR[edge.kind] || '#64748b')
const running = edge.state === 'running' && !offline
const active = Boolean(edge.active) && !offline
const dur = 1.6 + (i % 5) * 0.3
return (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
@@ -435,9 +437,9 @@ export function DataFlowView() {
d={d}
fill="none"
stroke={color}
strokeWidth={running ? 2.6 : 1.4}
strokeOpacity={active ? 0.85 : 0.28}
strokeDasharray={edge.movement_id && !active ? '4 4' : undefined}
strokeWidth={running ? 2.6 : offline ? 1.8 : 1.4}
strokeOpacity={active ? 0.85 : offline ? 0.75 : 0.28}
strokeDasharray={offline || (edge.movement_id && !active) ? '4 4' : undefined}
/>
{active && (
<>
@@ -629,6 +631,7 @@ function NodeCard({
const kind = NODE_KIND[node.kind] || NODE_KIND.source
const pii = node.pii
const showPii = piiOverlay && pii?.has_pii
const offline = (node as any).online === false || node.level === 'err'
return (
<button
ref={setRef}
@@ -636,9 +639,9 @@ function NodeCard({
onClick={onClick}
className={cn(
'absolute z-10 flex w-[116px] -translate-x-1/2 -translate-y-1/2 flex-col items-start gap-0.5 rounded-lg border bg-[#0f1830]/90 px-2 py-1.5 text-left shadow-md backdrop-blur transition-all hover:scale-[1.03]',
kind.ring,
selected && 'ring-2 ring-emerald-400/70',
showPii && !pii?.all_masked && 'ring-2 ring-rose-400/60',
offline ? 'border-rose-500 ring-2 ring-rose-500/70' : kind.ring,
selected && !offline && 'ring-2 ring-emerald-400/70',
showPii && !pii?.all_masked && !offline && 'ring-2 ring-rose-400/60',
)}
style={{ left: `${node.x}%`, top: `${node.y}%` }}
>
@@ -652,8 +655,9 @@ function NodeCard({
</div>
<span className="block w-full truncate text-[7px] leading-tight text-blue-100/70">{node.sub}</span>
{node.metric && (
<span className={cn('inline-block max-w-full truncate rounded border px-0.5 py-px font-mono text-[6px] leading-tight', kind.chip)}>
{node.metric}
<span className={cn('inline-block max-w-full truncate rounded border px-0.5 py-px font-mono text-[6px] leading-tight',
offline ? 'border-rose-400/50 bg-rose-500/20 text-rose-200' : kind.chip)}>
{offline ? `offline · ${node.metric}` : node.metric}
</span>
)}
{showPii && (
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ExternalLink,
GitBranch,
Layers,
Loader2,
RefreshCw,
RotateCcw,
FileBox,
HardDrive,
AlertTriangle,
} from 'lucide-react'
import { cn } from '../../lib/utils'
type NessieTable = { schema: string; table: string; key: string; trino_fqn: string }
type Snapshot = { snapshot_id: string; committed_at?: string | null; operation?: string | null }
type Manifest = {
path: string
length?: number
added_snapshot_id?: string | null
added_data_files_count?: number
existing_data_files_count?: number
}
type DataFile = { path: string; format?: string; record_count?: number; size_bytes?: number; is_s3?: boolean }
type Commit = { hash?: string; message?: string; author?: string; commitTime?: string }
type S3Obj = { key: string; size?: number; last_modified?: string | null; format?: string }
function fmtBytes(n?: number | null) {
if (n == null) return '—'
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} KB`
return `${n} B`
}
export function IcebergNessieView() {
const [loading, setLoading] = useState(true)
const [health, setHealth] = useState<any>(null)
const [tables, setTables] = useState<NessieTable[]>([])
const [commits, setCommits] = useState<Commit[]>([])
const [sel, setSel] = useState<NessieTable | null>(null)
const [structure, setStructure] = useState<any>(null)
const [snapId, setSnapId] = useState<string | null>(null)
const [s3objs, setS3objs] = useState<S3Obj[]>([])
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setErr(null)
try {
const [h, c, hist, s3] = await Promise.all([
fetch('/api/nessie/health').then((r) => r.json()),
fetch('/api/nessie/contents').then((r) => r.json()),
fetch('/api/nessie/history?max_records=30').then((r) => r.json()),
fetch('/api/nessie/s3/parquet?limit=80').then((r) => r.json()),
])
setHealth(h)
setTables(c.tables || [])
setCommits(hist.commits || [])
setS3objs(s3.objects || [])
if (!sel && (c.tables || []).length) setSel(c.tables[0])
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setLoading(false)
}
}, [sel])
const loadStructure = useCallback(async (t: NessieTable, snapshot?: string | null) => {
setBusy(true)
try {
const q = snapshot ? `?snapshot_id=${encodeURIComponent(snapshot)}` : ''
const data = await fetch(`/api/nessie/tables/${t.schema}/${t.table}/structure${q}`).then((r) => r.json())
setStructure(data)
setSnapId(data.selected_snapshot || null)
} catch (e: any) {
setStructure({ ok: false, error: String(e?.message || e) })
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void load()
}, [])
useEffect(() => {
if (sel) void loadStructure(sel, null)
}, [sel?.key])
const restore = async () => {
if (!sel || !snapId) return
if (!confirm(`Restore ${sel.schema}.${sel.table} to snapshot ${snapId}?\n\nOnly works if old data files still exist. Nessie cannot undelete wiped S3 objects.`)) return
setBusy(true)
try {
const res = await fetch(`/api/nessie/tables/${sel.schema}/${sel.table}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ snapshot_id: snapId }),
}).then((r) => r.json())
setToast(res.ok ? `Restored to ${snapId}` : (res.hint || res.error || 'Restore failed'))
if (res.as_of_sql) setToast((t) => `${t || ''}\n${res.as_of_sql}`)
await loadStructure(sel, snapId)
} finally {
setBusy(false)
}
}
const uiUrl = health?.nessie_ui || 'http://10.0.21.50:19120'
const snapshots: Snapshot[] = structure?.snapshots || []
const manifests: Manifest[] = structure?.manifests || []
const files: DataFile[] = structure?.files || []
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2">
<div className="flex shrink-0 items-center gap-2 border-b border-border pb-2">
<Layers className="h-4 w-4 text-docker" />
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-foreground">Iceberg · Nessie catalog</div>
<div className="truncate text-[10px] text-foreground-muted">
Catalog versioning (Nessie) + manifest data files (Iceberg via Trino)
{health?.nessie_ok ? ' · Nessie up' : ' · Nessie down'}
{health?.trino_ok ? ' · Trino up' : ' · Trino down'}
</div>
</div>
<a
href={uiUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-docker hover:bg-docker/10"
>
Nessie UI <ExternalLink className="h-3 w-3" />
</a>
<button
type="button"
onClick={() => void load()}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay"
>
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
{err && (
<div className="rounded border border-rose-500/40 bg-rose-500/10 px-2 py-1 text-[10px] text-rose-200">{err}</div>
)}
{toast && (
<div className="whitespace-pre-wrap rounded border border-amber-400/30 bg-amber-500/10 px-2 py-1 text-[10px] text-amber-100">{toast}</div>
)}
{loading ? (
<div className="flex flex-1 items-center justify-center text-[10px] text-foreground-muted">
<Loader2 className="mr-1 h-3 w-3 animate-spin" /> loading Nessie / Iceberg
</div>
) : (
<div className="grid min-h-0 flex-1 gap-2 lg:grid-cols-[220px_1fr_1fr]">
{/* Catalog */}
<div className="flex min-h-0 flex-col rounded border border-border bg-surface/40">
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
Tables on main
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{tables.length === 0 && (
<div className="p-2 text-[10px] text-foreground-faint">No ICEBERG_TABLE entries in Nessie yet.</div>
)}
{tables.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setSel(t)}
className={cn(
'mb-0.5 w-full rounded px-2 py-1.5 text-left text-[11px]',
sel?.key === t.key ? 'bg-docker/15 text-docker' : 'hover:bg-surface-overlay text-foreground-muted',
)}
>
<div className="font-medium text-foreground">{t.table}</div>
<div className="text-[9px] opacity-70">{t.schema}</div>
</button>
))}
</div>
<div className="border-t border-border px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-semibold uppercase text-foreground-muted">
<GitBranch className="h-3 w-3" /> Nessie commits
</div>
<div className="max-h-28 overflow-auto space-y-1">
{commits.map((c, i) => (
<div key={c.hash || i} className="rounded bg-surface-overlay/60 px-1.5 py-1 text-[9px]">
<div className="truncate text-foreground">{c.message || '(no message)'}</div>
<div className="font-mono text-foreground-faint">{(c.hash || '').slice(0, 10)} · {c.commitTime || '—'}</div>
</div>
))}
</div>
</div>
</div>
{/* Timeline + structure */}
<div className="flex min-h-0 flex-col rounded border border-border bg-surface/40">
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
Timeline · {sel ? `${sel.schema}.${sel.table}` : '—'}
</div>
{busy && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
<button
type="button"
disabled={!snapId || busy}
onClick={() => void restore()}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] hover:bg-rose-500/10 disabled:opacity-40"
title="Rollback table pointer to selected snapshot"
>
<RotateCcw className="h-3 w-3" /> Restore
</button>
</div>
<div className="max-h-36 shrink-0 overflow-auto border-b border-border p-1">
{snapshots.length === 0 && (
<div className="p-2 text-[10px] text-foreground-faint">No snapshots (table missing in Trino iceberg catalog?)</div>
)}
{snapshots.map((s) => (
<button
key={s.snapshot_id}
type="button"
onClick={() => {
setSnapId(s.snapshot_id)
if (sel) void loadStructure(sel, s.snapshot_id)
}}
className={cn(
'mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px]',
snapId === s.snapshot_id ? 'bg-emerald-500/15 text-emerald-200' : 'hover:bg-surface-overlay',
)}
>
<span className="font-mono text-[9px] opacity-70">{s.snapshot_id.slice(0, 12)}</span>
<span className="flex-1 truncate">{s.operation || '—'}</span>
<span className="text-foreground-faint">{s.committed_at || ''}</span>
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
<div className="mb-2 flex items-start gap-1 rounded border border-amber-400/20 bg-amber-500/5 px-2 py-1 text-[9px] text-amber-100/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{structure?.restore_warning ||
'Restore only re-points metadata. Hard-deleted S3/disk files cannot be recovered via Nessie.'}
</div>
<div className="mb-1 text-[10px] font-semibold text-foreground-muted">Manifests ({manifests.length})</div>
{manifests.slice(0, 40).map((m, i) => (
<div key={i} className="mb-1 rounded bg-surface-overlay/50 px-2 py-1 font-mono text-[9px] text-foreground-muted">
<div className="truncate text-foreground">{m.path}</div>
<div>
+{m.added_data_files_count ?? 0} / exist {m.existing_data_files_count ?? 0} · {fmtBytes(m.length)}
</div>
</div>
))}
{structure?.as_of_sql && (
<pre className="mt-2 overflow-auto rounded border border-border bg-black/30 p-2 text-[9px] text-emerald-200/90">
{structure.as_of_sql}
</pre>
)}
{structure?.error && <div className="text-[10px] text-rose-300">{structure.error}</div>}
</div>
</div>
{/* Files */}
<div className="flex min-h-0 flex-col gap-2">
<div className="flex min-h-0 flex-1 flex-col rounded border border-border bg-surface/40">
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
<span className="inline-flex items-center gap-1"><FileBox className="h-3 w-3" /> Data files ({files.length})</span>
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{files.map((f, i) => (
<div key={i} className="mb-1 rounded px-2 py-1 text-[10px] hover:bg-surface-overlay">
<div className="truncate font-mono text-[9px] text-foreground">{f.path}</div>
<div className="text-foreground-muted">
{f.format || '?'} · {f.record_count?.toLocaleString?.() ?? f.record_count} rows · {fmtBytes(f.size_bytes)}
{f.is_s3 ? ' · S3' : ' · local'}
</div>
</div>
))}
{!files.length && <div className="p-2 text-[10px] text-foreground-faint">No data files for this table view.</div>}
</div>
</div>
<div className="flex max-h-48 flex-col rounded border border-border bg-surface/40">
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
<span className="inline-flex items-center gap-1"><HardDrive className="h-3 w-3" /> Raw S3 parquet/orc</span>
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{s3objs.map((o) => (
<div key={o.key} className="truncate px-2 py-0.5 font-mono text-[9px] text-foreground-muted">
{o.key} <span className="text-foreground-faint">· {fmtBytes(o.size)}</span>
</div>
))}
{!s3objs.length && (
<div className="p-2 text-[10px] text-foreground-faint">
No objects under lake/,iceberg/ (or S3 creds missing). Use Object Storage for full bucket browse.
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}