feat(governance): close the 6 data-disease gaps — DQ monitoring, ownership, access posture, lineage & observability

Adds native Command Center features (no new containers) integrated as sub-tabs
in the existing Data Explorer and Data Quality views:

- Continuous Data Quality (dq_monitor.py): live completeness/uniqueness/validity/
  freshness scorecards via Trino with rolling trends → DataQuality "Live Monitoring".
- Ownership & stewardship (catalog_governance.py): owner/steward/tier matrix,
  orphan detection, business glossary; local store best-effort synced to
  OpenMetadata (owner PATCH) → Data Explorer "Ownership".
- Access & policy posture: per-dataset compliance combining PII masking, ownership,
  live DQ and observability alerts vs data contracts → Data Explorer "Access & Policies".
- Lineage (lineage.py): staged source→CDC→Spark→S3→Iceberg→Trino→serving graph with
  live row counts and column-level PII/masking tracing → Data Explorer "Lineage".
- Observability (observability.py): volume/freshness/schema-drift monitoring with
  alerts → Data Explorer "Observability".
- Shared lake_meta.py dataset registry + bounded Trino client; fast native row-count
  and PK-indexed freshness so monitors stay cheap on 25-54M-row tables.
- LLM context (lab_context.py) enriched with DQ scores, ownership and active alerts.
This commit is contained in:
mo
2026-06-29 17:43:37 +00:00
parent d066def8b4
commit f36c8906bc
15 changed files with 2479 additions and 4 deletions
@@ -16,12 +16,18 @@ import {
HardDrive,
ShieldCheck,
Radio,
GitBranch,
Lock,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { TrinoFederationView, type SubTab } from './TrinoFederationView'
import { LiveDashboard } from './LiveDashboard'
import { LineageView } from './LineageView'
import { GovernanceOwnershipView } from './GovernanceOwnershipView'
import { GovernanceAccessView } from './GovernanceAccessView'
import { ObservabilityView } from './ObservabilityView'
type ExplorerTab = 'business' | 'live' | SubTab
type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability'
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' },
@@ -29,6 +35,10 @@ const TABS: { id: ExplorerTab; label: string; icon: typeof Users; hint: string;
{ 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: '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' },
{ id: 'access', label: 'Access & Policies', icon: Lock, hint: 'Governance posture: PII masking, ownership, live DQ & alerts vs each data contract' },
{ id: 'observability', label: 'Observability', icon: Activity, hint: 'Volume, freshness & schema-drift monitoring with live alerts across every table' },
]
type Bucket = { key: string; count: number; value?: number }
@@ -295,6 +305,12 @@ export function DataExplorerView() {
{/* Trino federation / lake / dictionary tabs */}
{(view === 'federated' || view === 'lake' || view === 'dictionary') && <TrinoFederationView embedded activeTab={view} />}
{/* Governance / lineage / observability sub-tabs */}
{view === 'lineage' && <div className="flex min-h-0 flex-1 flex-col"><LineageView /></div>}
{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>}
{/* ───────── BUSINESS OVERVIEW ───────── */}
{view === 'business' && (
<>
@@ -11,9 +11,11 @@ import {
Table2,
Upload,
XCircle,
Gauge,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
import { DqMonitoringPanel } from './DqMonitoringPanel'
type Dimension = {
id: string
@@ -167,13 +169,13 @@ type ReportSummary = {
columns: number
}
type Tab = 'assess' | 'docling' | 'reports'
type Tab = 'assess' | 'docling' | 'reports' | 'monitoring'
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() {
const [tab, setTab] = useState<Tab>('assess')
const [tab, setTab] = useState<Tab>('monitoring')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
@@ -252,6 +254,7 @@ export function DataQualityView() {
}
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'monitoring', label: 'Live Monitoring', icon: Gauge },
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
@@ -312,6 +315,8 @@ export function DataQualityView() {
</div>
)}
{tab === 'monitoring' && <DqMonitoringPanel />}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, RefreshCw, Loader2, Play, Gauge, AlertTriangle, Database } from 'lucide-react'
import { cn } from '../../lib/utils'
type Trend = { t: string; score: number }
type Card = {
key: string; label: string; engine: string; color: string; table: string; domain?: string
score: number | null; score_color: string; dimensions: Record<string, number>
volume: number | null; volume_delta: number | null; freshness_age_min: number | null
issues: string[]; columns?: number; worst_columns?: { name: string; completeness: number; nulls: number }[]
trend: Trend[]; pending?: boolean; error?: string
}
type Resp = {
ok: boolean; enabled: boolean; running: boolean; cycles: number; sample: number
platform_score: number | null; dimension_averages: Record<string, number>; cards: Card[]
feed: { ts: string; text: string; level: string }[]
}
const DIMS = ['completeness', 'uniqueness', 'validity', 'freshness']
function ScoreRing({ score, color }: { score: number | null; color: string }) {
const r = 26
const c = 2 * Math.PI * r
const pct = score == null ? 0 : score / 100
return (
<div className="relative h-16 w-16 shrink-0">
<svg viewBox="0 0 64 64" className="h-16 w-16 -rotate-90">
<circle cx="32" cy="32" r={r} fill="none" stroke="currentColor" strokeWidth="6" className="text-surface-overlay" />
<circle cx="32" cy="32" r={r} fill="none" stroke={color} strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${pct * c} ${c}`} />
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[13px] font-bold text-foreground">{score == null ? '—' : Math.round(score)}</span>
</div>
</div>
)
}
function TrendLine({ data, color }: { data: Trend[]; color: string }) {
const pts = data.slice(-30)
if (pts.length < 2) return <div className="h-8" />
const w = 200
const h = 32
const step = w / (pts.length - 1)
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(h - (p.score / 100) * (h - 4) - 2).toFixed(1)}`).join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-8 w-full" preserveAspectRatio="none">
<path d={line} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
)
}
export function DqMonitoringPanel() {
const [data, setData] = useState<Resp | null>(null)
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const load = useCallback(async () => {
try {
const r = await fetch('/api/dq/scorecards')
if (r.ok) setData(await r.json())
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, 10000); return () => clearInterval(t) }, [load])
const runNow = async () => {
setRunning(true)
try {
const r = await fetch('/api/dq/run', { method: 'POST' })
if (r.ok) { const j = await r.json(); if (j.scorecards) setData(j.scorecards) }
} catch { /* */ } finally {
setRunning(false)
}
}
const dimColor = (v: number) => (v >= 90 ? '#34d399' : v >= 75 ? '#fbbf24' : v >= 50 ? '#fb923c' : '#f87171')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
{/* header */}
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4 xl:grid-cols-6">
<div className="panel col-span-2 flex items-center gap-3 px-3 py-2.5">
<ScoreRing score={data?.platform_score ?? null} color={data?.platform_score != null && data.platform_score >= 80 ? '#34d399' : '#fbbf24'} />
<div>
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Platform DQ score</p>
<p className="text-2xl font-bold leading-tight text-foreground">{data?.platform_score ?? '—'}</p>
<p className="text-[9px] text-foreground-faint">{data?.cycles ?? 0} cycles · live via Trino</p>
</div>
</div>
{DIMS.map((dim) => {
const v = data?.dimension_averages?.[dim]
return (
<div key={dim} className="panel flex flex-col justify-center px-3 py-2.5">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{dim}</p>
<p className="text-lg font-bold leading-tight" style={{ color: v != null ? dimColor(v) : undefined }}>{v ?? '—'}</p>
</div>
)
})}
</div>
<div className="flex shrink-0 items-center gap-2 px-1">
<span className="text-[10px] text-foreground-faint">
Continuous quality checks on live tables (completeness · uniqueness/dedup · validity · freshness){data?.running && ' · running…'}
</span>
<div className="ml-auto flex items-center gap-2">
<button type="button" onClick={runNow} disabled={running || data?.running} className="inline-flex items-center gap-1.5 rounded-md border border-docker/40 bg-docker/10 px-2.5 py-1 text-[10px] font-medium text-docker hover:bg-docker/20 disabled:opacity-60">
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />} Run now
</button>
<button type="button" onClick={load} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
</div>
{/* scorecards */}
<div className="grid gap-2 pb-2 lg:grid-cols-2">
{(data?.cards || []).map((c) => (
<div key={c.key} className="panel p-3">
<div className="flex items-start gap-3">
<ScoreRing score={c.score} color={c.score_color} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Database className="h-3.5 w-3.5" style={{ color: c.color }} />
<span className="text-[12px] font-semibold text-foreground">{c.label}</span>
<span className="ml-auto text-[8px] text-foreground-faint">{c.engine}</span>
</div>
<p className="truncate font-mono text-[8px] text-foreground-faint">{c.table}</p>
{c.pending ? (
<p className="mt-2 text-[10px] text-foreground-faint">Awaiting first cycle</p>
) : c.error ? (
<p className="mt-2 flex items-center gap-1 text-[10px] text-rose-400"><AlertTriangle className="h-3 w-3" /> {c.error}</p>
) : (
<div className="mt-1.5 space-y-1">
{DIMS.filter((d) => c.dimensions[d] != null).map((d) => (
<div key={d} className="flex items-center gap-2 text-[9px]">
<span className="w-20 shrink-0 capitalize text-foreground-muted">{d}</span>
<div className="relative h-2 flex-1 overflow-hidden rounded bg-surface-overlay">
<div className="h-full rounded" style={{ width: `${c.dimensions[d]}%`, backgroundColor: dimColor(c.dimensions[d]) }} />
</div>
<span className="w-8 shrink-0 text-right font-mono text-foreground">{c.dimensions[d]}</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[9px] text-foreground-muted">
{c.volume != null && <span className="flex items-center gap-1"><Gauge className="h-3 w-3" /> {c.volume.toLocaleString()} rows</span>}
{c.volume_delta != null && c.volume_delta !== 0 && (
<span className={c.volume_delta > 0 ? 'text-emerald-400' : 'text-rose-400'}>{c.volume_delta > 0 ? '+' : ''}{c.volume_delta.toLocaleString()}</span>
)}
{c.freshness_age_min != null && <span><Activity className="mr-1 inline h-3 w-3" />{c.freshness_age_min.toFixed(0)}m</span>}
</div>
<div className="w-1/3"><TrendLine data={c.trend} color={c.score_color} /></div>
</div>
{c.issues && c.issues.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{c.issues.map((iss, i) => (
<span key={i} className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[8px] text-amber-300">{iss}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from 'react'
import { ShieldCheck, RefreshCw, Loader2, Lock, Unlock, Check, X, FileCheck2, AlertTriangle } from 'lucide-react'
import { cn } from '../../lib/utils'
type Check = { name: string; ok: boolean; value: unknown; target: unknown }
type Pii = { pii_count: number; all_masked: boolean; masked: number; unmasked: number }
type Alert = { type: string; severity: string; message: string }
type Posture = {
key: string; label: string; engine: string; color: string; table: string
owner?: string | null; steward?: string | null; tier?: string | null
pii: Pii; dq_score?: number | null; issues: string[]; alerts: Alert[]
contract: Record<string, number>; checks: Check[]; compliant: boolean
}
type Resp = { ok: boolean; datasets: Posture[]; summary: { total: number; compliant: number; non_compliant: number } }
export function GovernanceAccessView() {
const [data, setData] = useState<Resp | null>(null)
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await fetch('/api/governance/posture')
if (r.ok) setData(await r.json())
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, 12000); return () => clearInterval(t) }, [load])
const sev = (s: string) => (s === 'critical' ? 'text-rose-400' : s === 'warning' ? 'text-amber-400' : 'text-sky-400')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={FileCheck2} label="Compliant datasets" value={data ? `${data.summary.compliant}/${data.summary.total}` : '—'}
accent={data && data.summary.non_compliant === 0 ? '#34d399' : '#fbbf24'} />
<Kpi icon={AlertTriangle} label="Non-compliant" value={data ? String(data.summary.non_compliant) : '—'}
accent={data && data.summary.non_compliant ? '#f87171' : '#34d399'} />
<Kpi icon={Lock} label="Masking policy" value="Enforced" accent="#60a5fa" />
<Kpi icon={ShieldCheck} label="Contracts" value="Active" accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center px-1">
<span className="text-[10px] text-foreground-faint">Governance posture = ownership + PII masking + live DQ + observability alerts vs each data contract</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
<div className="grid gap-2 pb-2 lg:grid-cols-2">
{(data?.datasets || []).map((d) => (
<div key={d.key} className={cn('panel p-3', !d.compliant && 'ring-1 ring-rose-500/30')}>
<div className="mb-2 flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-[12px] font-semibold text-foreground">{d.label}</span>
<span className={cn('ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium',
d.compliant ? 'bg-emerald-500/15 text-emerald-300' : 'bg-rose-500/15 text-rose-300')}>
{d.compliant ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />} {d.compliant ? 'Compliant' : 'Action needed'}
</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{d.checks.map((c) => (
<div key={c.name} className="flex items-center gap-1.5 rounded bg-surface-overlay px-2 py-1 text-[10px]">
{c.ok ? <Check className="h-3 w-3 shrink-0 text-emerald-400" /> : <X className="h-3 w-3 shrink-0 text-rose-400" />}
<span className="flex-1 truncate text-foreground-muted">{c.name}</span>
<span className={cn('font-mono', c.ok ? 'text-foreground' : 'text-rose-300')}>{String(c.value)}</span>
</div>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[9px] text-foreground-muted">
<span>Owner: <span className="text-foreground">{d.owner || '—'}</span></span>
<span>· Tier: {d.tier || '—'}</span>
<span className="flex items-center gap-1">·
{d.pii.unmasked === 0
? <><Lock className="h-3 w-3 text-emerald-400" /> {d.pii.masked}/{d.pii.pii_count} PII masked</>
: <><Unlock className="h-3 w-3 text-amber-400" /> {d.pii.unmasked} PII visible</>}
</span>
</div>
{d.alerts.length > 0 && (
<div className="mt-2 space-y-1 border-t border-border/50 pt-2">
{d.alerts.map((a, i) => (
<p key={i} className={cn('flex items-center gap-1 text-[9px]', sev(a.severity))}>
<AlertTriangle className="h-3 w-3" /> {a.message}
</p>
))}
</div>
)}
<p className="mt-2 text-[8px] text-foreground-faint">
Contract: DQ {d.contract.min_score} · completeness {d.contract.min_completeness}% · freshness {d.contract.min_freshness_min}m · crit alerts {d.contract.max_critical_alerts}
</p>
</div>
))}
</div>
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof ShieldCheck; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useState } from 'react'
import { UserCircle, RefreshCw, Loader2, AlertTriangle, BookOpen, ShieldCheck, Check, X } from 'lucide-react'
import { cn } from '../../lib/utils'
type Pii = { pii_count: number; all_masked: boolean; masked: number; unmasked: number }
type DsRow = {
key: string; label: string; engine: string; color: string; table: string
domain?: string; owner?: string | null; steward?: string | null; team?: string | null
tier?: string | null; classification?: string | null; updated_at?: string | null
orphan: boolean; pii: Pii
}
type Summary = { total: number; orphans: number; stewarded: number; owned: number }
type GUser = { id: string | null; name: string; display: string; type: string }
type Term = { name: string; description: string; domain?: string; related?: string[] }
const TIERS = ['Tier1', 'Tier2', 'Tier3']
const CLASSES = ['Public', 'Internal', 'Confidential', 'Restricted']
export function GovernanceOwnershipView() {
const [rows, setRows] = useState<DsRow[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [users, setUsers] = useState<GUser[]>([])
const [glossary, setGlossary] = useState<Term[]>([])
const [glossarySource, setGlossarySource] = useState('')
const [loading, setLoading] = useState(true)
const [omConnected, setOmConnected] = useState(false)
const [editKey, setEditKey] = useState<string | null>(null)
const [form, setForm] = useState<{ owner: string; steward: string; team: string; tier: string; classification: string }>(
{ owner: '', steward: '', team: '', tier: '', classification: '' },
)
const [saving, setSaving] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, u, g] = await Promise.all([
fetch('/api/governance/datasets').then((r) => r.json()),
fetch('/api/governance/users').then((r) => r.json()),
fetch('/api/governance/glossary').then((r) => r.json()),
])
setRows(d.datasets || [])
setSummary(d.summary || null)
setOmConnected(!!d.om_connected)
setUsers(u.users || [])
setGlossary(g.terms || [])
setGlossarySource(g.source || '')
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const openEdit = (r: DsRow) => {
setEditKey(r.key)
setForm({ owner: r.owner || '', steward: r.steward || '', team: r.team || '', tier: r.tier || '', classification: r.classification || '' })
}
const save = async () => {
if (!editKey) return
setSaving(true)
try {
await fetch('/api/governance/assign', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: editKey, ...form }),
})
setEditKey(null)
await load()
} catch { /* */ } finally {
setSaving(false)
}
}
const people = users.filter((u) => u.type === 'user')
const teams = users.filter((u) => u.type === 'team')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
{/* KPIs */}
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={UserCircle} label="Owned" value={summary ? `${summary.owned}/${summary.total}` : '—'} accent="#34d399" />
<Kpi icon={AlertTriangle} label="Orphan datasets" value={summary ? String(summary.orphans) : '—'} accent={summary && summary.orphans ? '#f87171' : '#34d399'} />
<Kpi icon={ShieldCheck} label="Stewarded" value={summary ? String(summary.stewarded) : '—'} accent="#60a5fa" />
<Kpi icon={BookOpen} label="Glossary terms" value={String(glossary.length)} accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center gap-2 px-1">
<span className="text-[10px] text-foreground-faint">
OpenMetadata {omConnected ? 'connected' : 'offline'} · assignments stored locally{omConnected ? ' + synced to OM' : ''}
</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
{/* ownership matrix */}
<div className="panel min-h-0 shrink-0 overflow-x-auto p-0">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-border text-left text-foreground-faint">
<th className="px-3 py-2 font-semibold">Dataset</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Steward</th>
<th className="px-3 py-2 font-semibold">Team</th>
<th className="px-3 py-2 font-semibold">Tier</th>
<th className="px-3 py-2 font-semibold">PII</th>
<th className="px-3 py-2 font-semibold" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key} className={cn('border-b border-border/50', r.orphan && 'bg-rose-500/5')}>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: r.color }} />
<span className="font-medium text-foreground">{r.label}</span>
</div>
<span className="font-mono text-[8px] text-foreground-faint">{r.table}</span>
</td>
<td className="px-3 py-2">
{r.owner ? <span className="text-foreground">{r.owner}</span>
: <span className="flex items-center gap-1 text-rose-400"><AlertTriangle className="h-3 w-3" /> unassigned</span>}
</td>
<td className="px-3 py-2 text-foreground-muted">{r.steward || '—'}</td>
<td className="px-3 py-2 text-foreground-muted">{r.team || '—'}</td>
<td className="px-3 py-2">
{r.tier ? <span className="rounded bg-surface-overlay px-1.5 py-0.5 text-foreground-muted">{r.tier}</span> : '—'}
</td>
<td className="px-3 py-2">
{r.pii.pii_count > 0 ? (
<span className={cn('rounded px-1.5 py-0.5', r.pii.unmasked === 0 ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300')}>
{r.pii.masked}/{r.pii.pii_count} masked
</span>
) : <span className="text-foreground-faint">none</span>}
</td>
<td className="px-3 py-2 text-right">
<button type="button" onClick={() => openEdit(r)} className="rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:bg-surface-overlay">
Assign
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* glossary */}
<h2 className="mt-1 shrink-0 px-1 text-[10px] font-semibold uppercase tracking-widest text-foreground-muted">
Business glossary {glossarySource && <span className="text-foreground-faint">· {glossarySource}</span>}
</h2>
<div className="grid shrink-0 gap-2 pb-2 md:grid-cols-2 lg:grid-cols-3">
{glossary.map((t) => (
<div key={t.name} className="panel p-2.5">
<div className="flex items-center gap-1.5">
<BookOpen className="h-3.5 w-3.5 text-docker" />
<span className="text-[11px] font-semibold text-foreground">{t.name}</span>
{t.domain && <span className="ml-auto rounded bg-surface-overlay px-1.5 py-0.5 text-[8px] text-foreground-faint">{t.domain}</span>}
</div>
<p className="mt-1 text-[10px] text-foreground-muted">{t.description}</p>
{t.related && t.related.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{t.related.map((rl) => <span key={rl} className="rounded bg-surface-overlay px-1.5 py-0.5 font-mono text-[8px] text-foreground-faint">{rl}</span>)}
</div>
)}
</div>
))}
</div>
{/* assign modal */}
{editKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEditKey(null)}>
<div className="panel w-full max-w-md p-4" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-foreground">Assign ownership {rows.find((r) => r.key === editKey)?.label}</h3>
<button type="button" onClick={() => setEditKey(null)} className="text-foreground-muted hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-2.5">
<Field label="Owner">
<select value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> unassigned </option>
{people.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<Field label="Steward">
<select value={form.steward} onChange={(e) => setForm({ ...form, steward: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> none </option>
{people.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<Field label="Team">
<select value={form.team} onChange={(e) => setForm({ ...form, team: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> none </option>
{teams.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Tier">
<select value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""></option>
{TIERS.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Classification">
<select value={form.classification} onChange={(e) => setForm({ ...form, classification: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""></option>
{CLASSES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => setEditKey(null)} className="rounded border border-border px-3 py-1.5 text-[11px] text-foreground-muted hover:bg-surface-overlay">Cancel</button>
<button type="button" onClick={save} disabled={saving} className="inline-flex items-center gap-1.5 rounded bg-docker px-3 py-1.5 text-[11px] font-medium text-white hover:bg-docker/90 disabled:opacity-60">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Save
</button>
</div>
</div>
</div>
)}
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof UserCircle; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">{label}</label>
{children}
</div>
)
}
+309
View File
@@ -0,0 +1,309 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { GitBranch, RefreshCw, Loader2, Database, Cpu, HardDrive, Layers, Network, Sparkles, Lock, ShieldCheck } from 'lucide-react'
import { cn } from '../../lib/utils'
type LCol = { name: string; category?: string; masked: boolean; pii?: boolean }
type LNode = {
id: string
label: string
type: string
stage: number
meta: {
engine?: string
table?: string
rows?: number | null
note?: string
columns?: LCol[]
masked_layer?: boolean
domain?: string
topic?: string
path?: string
om?: { upstream?: number; downstream?: number }
}
}
type LEdge = { id: string; source: string; target: string; label: string; kind: string; active: boolean }
type Graph = {
ok: boolean
generated_at: string
active: { generator: boolean; archive: boolean }
stages: string[]
nodes: LNode[]
edges: LEdge[]
column_links: { source: string; target: string; column: string; masked: boolean }[]
om_connected: boolean
}
type DsOpt = { key: string; label: string; engine: string; color: string; table: string }
const TYPE_ICON: Record<string, typeof Database> = {
source: Database,
stream: Network,
compute: Cpu,
storage: HardDrive,
lakehouse: Layers,
engine: Network,
serving: Sparkles,
}
const KIND_COLOR: Record<string, string> = {
cdc: '#f472b6',
batch: '#fbbf24',
transform: '#a78bfa',
serve: '#38bdf8',
}
function fmtRows(n?: number | null) {
if (n == null) return null
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
export function LineageView() {
const [graph, setGraph] = useState<Graph | null>(null)
const [datasets, setDatasets] = useState<DsOpt[]>([])
const [focus, setFocus] = useState<string>('')
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [coords, setCoords] = useState<Map<string, { x: number; y: number; w: number; h: number }>>(new Map())
const load = useCallback(async () => {
setLoading(true)
try {
const url = focus ? `/api/lineage/graph?dataset=${focus}` : '/api/lineage/graph'
const r = await fetch(url)
if (r.ok) setGraph(await r.json())
} catch { /* */ } finally {
setLoading(false)
}
}, [focus])
useEffect(() => {
fetch('/api/lineage/datasets').then((r) => r.json()).then((d) => setDatasets(d.datasets || [])).catch(() => {})
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
const t = setInterval(load, 15000)
return () => clearInterval(t)
}, [load])
// measure node positions for edge drawing
useLayoutEffect(() => {
if (!wrapRef.current || !graph) return
const measure = () => {
const wrap = wrapRef.current
if (!wrap) return
const base = wrap.getBoundingClientRect()
const next = new Map<string, { x: number; y: number; w: number; h: number }>()
nodeRefs.current.forEach((el, id) => {
const r = el.getBoundingClientRect()
next.set(id, { x: r.left - base.left + wrap.scrollLeft, y: r.top - base.top + wrap.scrollTop, w: r.width, h: r.height })
})
setCoords(next)
}
measure()
const ro = new ResizeObserver(measure)
if (wrapRef.current) ro.observe(wrapRef.current)
nodeRefs.current.forEach((el) => ro.observe(el))
return () => ro.disconnect()
}, [graph])
const stages = graph?.stages || []
const byStage: Record<number, LNode[]> = {}
;(graph?.nodes || []).forEach((n) => { (byStage[n.stage] = byStage[n.stage] || []).push(n) })
const selNode = graph?.nodes.find((n) => n.id === selected) || null
const connectedEdges = new Set(
(graph?.edges || []).filter((e) => !selected || e.source === selected || e.target === selected).map((e) => e.id),
)
return (
<div className="flex h-full min-h-0 flex-col gap-2">
{/* controls */}
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Trace dataset</span>
<button
type="button"
onClick={() => setFocus('')}
className={cn('rounded-full border px-2.5 py-1 text-[10px] font-medium', focus === '' ? 'border-docker bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:bg-surface-overlay')}
>
Full platform
</button>
{datasets.map((d) => (
<button
key={d.key}
type="button"
onClick={() => { setFocus(d.key); setSelected(null) }}
className={cn('rounded-full border px-2.5 py-1 text-[10px] font-medium', focus === d.key ? 'border-docker bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:bg-surface-overlay')}
>
{d.label}
</button>
))}
<div className="ml-auto flex items-center gap-2">
{graph?.active && (
<span className="flex items-center gap-1 text-[10px] text-foreground-muted">
<span className={cn('h-2 w-2 rounded-full', graph.active.generator ? 'animate-pulse bg-emerald-400' : 'bg-foreground-faint/40')} /> CDC
<span className={cn('ml-1 h-2 w-2 rounded-full', graph.active.archive ? 'animate-pulse bg-amber-400' : 'bg-foreground-faint/40')} /> ETL
</span>
)}
<button type="button" onClick={load} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-2">
{/* graph */}
<div ref={wrapRef} className="panel scrollbar-thin relative min-h-0 flex-1 overflow-auto p-4">
{/* edges overlay */}
<svg className="pointer-events-none absolute inset-0 h-full w-full" style={{ minWidth: '100%', minHeight: '100%' }}>
<defs>
<marker id="lin-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#64748b" />
</marker>
</defs>
{(graph?.edges || []).map((e) => {
const a = coords.get(e.source)
const b = coords.get(e.target)
if (!a || !b) return null
const x1 = a.x + a.w
const y1 = a.y + a.h / 2
const x2 = b.x
const y2 = b.y + b.h / 2
const dx = Math.max(40, Math.abs(x2 - x1) / 2)
const path = `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`
const color = KIND_COLOR[e.kind] || '#64748b'
const dim = selected && !connectedEdges.has(e.id)
return (
<g key={e.id} opacity={dim ? 0.12 : 1}>
<path d={path} fill="none" stroke={color} strokeWidth={e.active ? 2.5 : 1.5}
strokeDasharray={e.active ? '6 5' : undefined} markerEnd="url(#lin-arrow)">
{e.active && (
<animate attributeName="stroke-dashoffset" from="22" to="0" dur="0.8s" repeatCount="indefinite" />
)}
</path>
</g>
)
})}
</svg>
{/* stage columns */}
<div className="relative flex gap-6" style={{ minWidth: 'max-content' }}>
{stages.map((label, si) => (
<div key={si} className="flex w-[150px] shrink-0 flex-col gap-3">
<div className="text-center text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">{label}</div>
{(byStage[si] || []).map((n) => {
const Icon = TYPE_ICON[n.type] || Database
const isSel = selected === n.id
const rows = fmtRows(n.meta.rows)
return (
<div
key={n.id}
ref={(el) => { if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }}
onClick={() => setSelected(isSel ? null : n.id)}
className={cn(
'relative z-10 cursor-pointer rounded-lg border bg-surface-raised p-2 transition-all',
isSel ? 'border-docker ring-1 ring-docker/40' : 'border-border hover:border-docker/50',
)}
>
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-docker" />
<span className="truncate text-[10px] font-semibold text-foreground" title={n.label}>
{n.label.split('\n')[0]}
</span>
{n.meta.masked_layer && <Lock className="ml-auto h-3 w-3 shrink-0 text-emerald-400" />}
</div>
{n.label.includes('\n') && (
<p className="mt-0.5 truncate font-mono text-[8px] text-foreground-muted" title={n.label.split('\n')[1]}>
{n.label.split('\n')[1]}
</p>
)}
<div className="mt-1 flex items-center gap-1.5 text-[8px] text-foreground-faint">
{rows && <span className="rounded bg-surface-overlay px-1 py-0.5 font-mono text-foreground-muted">{rows} rows</span>}
{n.meta.columns && n.meta.columns.length > 0 && (
<span className="rounded bg-rose-500/15 px-1 py-0.5 text-rose-300">{n.meta.columns.length} PII</span>
)}
</div>
</div>
)
})}
</div>
))}
</div>
</div>
{/* detail */}
<div className="panel scrollbar-thin w-[260px] shrink-0 overflow-y-auto p-3">
{!selNode ? (
<div className="flex h-full flex-col items-center justify-center text-center text-foreground-faint">
<GitBranch className="mb-2 h-7 w-7" />
<p className="text-[11px]">Click any node to inspect its schema, row count and column-level PII lineage.</p>
{graph && (
<p className="mt-3 text-[9px]">
{graph.om_connected ? 'OpenMetadata lineage layered in.' : 'OpenMetadata not connected.'}
</p>
)}
</div>
) : (
<div className="space-y-2">
<h3 className="text-[12px] font-semibold text-foreground">{selNode.label.split('\n')[0]}</h3>
{selNode.meta.table && <p className="break-all font-mono text-[9px] text-docker">{selNode.meta.table}</p>}
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
{selNode.meta.engine && <Info label="Engine" value={selNode.meta.engine} />}
{selNode.meta.rows != null && <Info label="Rows" value={fmtRows(selNode.meta.rows) || '—'} />}
{selNode.meta.domain && <Info label="Domain" value={selNode.meta.domain} />}
{selNode.meta.topic && <Info label="Topic" value={selNode.meta.topic} />}
{selNode.meta.om && (selNode.meta.om.upstream != null) && (
<Info label="OM lineage" value={`${selNode.meta.om.upstream}${selNode.meta.om.downstream}`} />
)}
</div>
{selNode.meta.note && <p className="text-[10px] text-foreground-muted">{selNode.meta.note}</p>}
{selNode.meta.columns && selNode.meta.columns.length > 0 && (
<div>
<p className="mb-1 mt-2 flex items-center gap-1 text-[10px] font-semibold text-foreground">
<ShieldCheck className="h-3 w-3 text-emerald-400" /> PII columns
</p>
<div className="space-y-1">
{selNode.meta.columns.map((c) => (
<div key={c.name} className="flex items-center gap-1.5 rounded bg-surface-overlay px-1.5 py-1 text-[9px]">
<span className="flex-1 truncate font-mono text-foreground-muted" title={c.name}>{c.name}</span>
{c.category && <span className="text-foreground-faint">{c.category}</span>}
{c.masked
? <span className="flex items-center gap-0.5 text-emerald-400"><Lock className="h-2.5 w-2.5" /> masked</span>
: <span className="text-amber-400">visible</span>}
</div>
))}
</div>
</div>
)}
{graph?.column_links && graph.column_links.length > 0 && (selNode.id === 'src_orders' || selNode.id === 'iceberg_curated') && (
<div>
<p className="mb-1 mt-2 text-[10px] font-semibold text-foreground">Column lineage curated</p>
<div className="space-y-1">
{graph.column_links.map((l) => (
<div key={l.column} className="flex items-center gap-1 text-[9px]">
<span className="flex-1 truncate font-mono text-foreground-muted">{l.column}</span>
<span className="text-foreground-faint"></span>
{l.masked ? <Lock className="h-2.5 w-2.5 text-emerald-400" /> : <span className="text-amber-400">visible</span>}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div className="rounded bg-surface-overlay px-1.5 py-1">
<p className="text-[8px] uppercase tracking-wider text-foreground-faint">{label}</p>
<p className="truncate text-[10px] text-foreground" title={value}>{value}</p>
</div>
)
}
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, RefreshCw, Loader2, AlertTriangle, Clock, Database, TrendingUp, TrendingDown } from 'lucide-react'
import { cn } from '../../lib/utils'
type Point = { t: string; rows: number; delta: number }
type DsMetric = {
key: string; label: string; engine: string; color: string; table: string
rows: number | null; delta: number | null; freshness_age_min: number | null
columns: number | null; stalled_cycles: number; error: string | null; ts: string | null
series: Point[]
}
type Alert = { id: string; dataset: string; type: string; severity: string; message: string; count: number; ts: string; last_ts: string }
type Metrics = {
ok: boolean; enabled: boolean; cycles: number; freshness_min: number
alert_counts: { critical: number; warning: number; info: number; total: number }
datasets: DsMetric[]
}
function Spark({ data, color }: { data: Point[]; color: string }) {
const pts = data.slice(-40)
if (pts.length < 2) return <div className="h-10 w-full" />
const w = 240
const h = 40
const vals = pts.map((p) => p.rows)
const max = Math.max(...vals)
const min = Math.min(...vals)
const range = max - min || 1
const step = w / (pts.length - 1)
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(h - ((p.rows - min) / range) * (h - 6) - 3).toFixed(1)}`).join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-10 w-full" preserveAspectRatio="none">
<path d={`${line} L${w},${h} L0,${h} Z`} fill={color} fillOpacity="0.12" />
<path d={line} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
)
}
export function ObservabilityView() {
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [alerts, setAlerts] = useState<Alert[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const [m, a] = await Promise.all([
fetch('/api/observability/metrics').then((r) => r.json()),
fetch('/api/observability/alerts').then((r) => r.json()),
])
setMetrics(m)
setAlerts(a.active || [])
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, 8000); return () => clearInterval(t) }, [load])
const ac = metrics?.alert_counts
const sevBorder = (s: string) => (s === 'critical' ? 'border-rose-500/40 bg-rose-500/5' : s === 'warning' ? 'border-amber-500/40 bg-amber-500/5' : 'border-sky-500/40 bg-sky-500/5')
const sevText = (s: string) => (s === 'critical' ? 'text-rose-400' : s === 'warning' ? 'text-amber-400' : 'text-sky-400')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={AlertTriangle} label="Critical" value={String(ac?.critical ?? 0)} accent={ac?.critical ? '#f87171' : '#34d399'} />
<Kpi icon={AlertTriangle} label="Warnings" value={String(ac?.warning ?? 0)} accent={ac?.warning ? '#fbbf24' : '#34d399'} />
<Kpi icon={Activity} label="Sweeps" value={String(metrics?.cycles ?? 0)} accent="#60a5fa" />
<Kpi icon={Clock} label="Freshness SLA" value={`${metrics?.freshness_min ?? '—'}m`} accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center px-1">
<span className="text-[10px] text-foreground-faint">Tracking volume, freshness &amp; schema drift across every business table</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
{/* active alerts */}
{alerts.length > 0 && (
<div className="shrink-0 space-y-1.5">
{alerts.map((a) => (
<div key={a.id} className={cn('flex items-center gap-2 rounded-md border px-3 py-2 text-[10px]', sevBorder(a.severity))}>
<AlertTriangle className={cn('h-3.5 w-3.5 shrink-0', sevText(a.severity))} />
<span className="font-semibold text-foreground">{a.dataset}</span>
<span className="text-foreground-muted">{a.message}</span>
<span className={cn('ml-auto rounded px-1.5 py-0.5 text-[8px] uppercase', sevText(a.severity))}>{a.type}</span>
{a.count > 1 && <span className="rounded bg-surface-overlay px-1.5 py-0.5 text-[8px] text-foreground-faint">×{a.count}</span>}
</div>
))}
</div>
)}
{/* per-dataset volume + freshness */}
<div className="grid gap-2 pb-2 lg:grid-cols-2 xl:grid-cols-3">
{(metrics?.datasets || []).map((d) => {
const stale = d.freshness_age_min != null && d.freshness_age_min > (metrics?.freshness_min ?? 30)
return (
<div key={d.key} className="panel p-3">
<div className="mb-1 flex items-center gap-1.5">
<Database className="h-3.5 w-3.5" style={{ color: d.color }} />
<span className="text-[11px] font-semibold text-foreground">{d.label}</span>
{d.delta != null && d.delta !== 0 && (
<span className={cn('ml-auto flex items-center gap-0.5 text-[9px]', d.delta > 0 ? 'text-emerald-400' : 'text-rose-400')}>
{d.delta > 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{d.delta > 0 ? '+' : ''}{d.delta.toLocaleString()}
</span>
)}
</div>
<Spark data={d.series} color={d.color} />
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[9px] text-foreground-muted">
<span>Rows: <span className="font-mono text-foreground">{d.rows?.toLocaleString() ?? '—'}</span></span>
<span className={cn('flex items-center gap-1', stale && 'text-amber-400')}>
<Clock className="h-3 w-3" /> {d.freshness_age_min != null ? `${d.freshness_age_min.toFixed(0)}m old` : 'n/a'}
</span>
{d.columns != null && <span>{d.columns} cols</span>}
{d.stalled_cycles > 0 && <span className="text-amber-400">stalled ×{d.stalled_cycles}</span>}
{d.error && <span className="text-rose-400">err</span>}
</div>
</div>
)
})}
</div>
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof Activity; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}