2026-06-27 02:09:38 +02:00
import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
2026-06-27 11:36:36 +02:00
import { GitBranch , Lock , LockOpen , Play , RefreshCw , ShieldAlert , ShieldCheck , Loader2 , Bot } from 'lucide-react'
2026-06-27 02:09:38 +02:00
import type { DataflowEdge , DataflowGraph , DataflowNode } from '../../types'
2026-06-27 11:36:36 +02:00
import { fetchDataflow , runDataflowMovement , toggleEtlAgent , fetchAgentOpsStatus , setPiiMask } from '../../lib/api'
2026-06-27 02:09:38 +02:00
import { Badge } from '../ui/Badge'
import { cn } from '../../lib/utils'
/* ── styling maps ───────────────────────────────────────────────── */
const NODE_KIND : Record < string , { ring : string ; chip : string ; dot : string }> = {
generator : { ring : 'border-amber-400/60' , chip : 'bg-amber-500/15 text-amber-300 border-amber-400/40' , dot : '#f59e0b' },
hadoop : { ring : 'border-orange-400/60' , chip : 'bg-orange-500/15 text-orange-300 border-orange-400/40' , dot : '#fb923c' },
source : { ring : 'border-emerald-400/60' , chip : 'bg-emerald-500/15 text-emerald-300 border-emerald-400/40' , dot : '#34d399' },
stream : { ring : 'border-cyan-400/60' , chip : 'bg-cyan-500/15 text-cyan-300 border-cyan-400/40' , dot : '#22d3ee' },
sink : { ring : 'border-sky-400/60' , chip : 'bg-sky-500/15 text-sky-300 border-sky-400/40' , dot : '#38bdf8' },
lakehouse : { ring : 'border-blue-400/60' , chip : 'bg-blue-500/15 text-blue-300 border-blue-400/40' , dot : '#60a5fa' },
engine : { ring : 'border-violet-400/60' , chip : 'bg-violet-500/15 text-violet-300 border-violet-400/40' , dot : '#a78bfa' },
2026-06-27 02:43:22 +02:00
governance : { ring : 'border-fuchsia-400/60' , chip : 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-400/40' , dot : '#d946ef' },
2026-06-27 02:09:38 +02:00
}
const EDGE_COLOR : Record < string , string > = {
generate : '#f59e0b' ,
cdc : '#22d3ee' ,
archive : '#38bdf8' ,
movement : '#a78bfa' ,
mask : '#fb7185' ,
query : '#818cf8' ,
2026-06-27 02:43:22 +02:00
catalog : '#d946ef' ,
2026-06-27 02:09:38 +02:00
}
const EDGE_LEGEND : { kind : string ; label : string }[] = [
{ kind : 'generate' , label : 'Generate' },
{ kind : 'cdc' , label : 'CDC capture' },
{ kind : 'archive' , label : 'Archive' },
{ kind : 'movement' , label : 'ETL movement' },
{ kind : 'mask' , label : 'PII masking' },
{ kind : 'query' , label : 'Query' },
2026-06-27 02:43:22 +02:00
{ kind : 'catalog' , label : 'Catalog (OpenMetadata)' },
2026-06-27 02:09:38 +02:00
]
type Anchor = { x : number ; y : number ; w : number ; h : number }
function edgePath ( a : Anchor , b : Anchor ) : string {
const x1 = a . x
const y1 = a . y
const x2 = b . x
const y2 = b . y
const dx = x2 - x1
if ( Math . abs ( dx ) < 8 ) {
const midY = ( y1 + y2 ) / 2
return `M ${ x1 } ${ y1 } C ${ x1 } ${ midY } , ${ x2 } ${ midY } , ${ x2 } ${ y2 } `
}
const mx = x1 + dx * 0.5
return `M ${ x1 } ${ y1 } C ${ mx } ${ y1 } , ${ mx } ${ y2 } , ${ x2 } ${ y2 } `
}
function fmtDur ( s? : number | null ) : string {
if ( s == null ) return ''
if ( s < 60 ) return ` ${ s . toFixed ( 0 ) } s`
return ` ${ ( s / 60 ). toFixed ( 1 ) } m`
}
/* ── component ──────────────────────────────────────────────────── */
export function DataFlowView() {
const [ graph , setGraph ] = useState < DataflowGraph | null >( null )
const [ loading , setLoading ] = useState ( true )
const [ refreshing , setRefreshing ] = useState ( false )
const [ piiOverlay , setPiiOverlay ] = useState ( true )
const [ selected , setSelected ] = useState < string | null >( null )
const [ triggering , setTriggering ] = useState < string | null >( null )
const [ etlEnabled , setEtlEnabled ] = useState < boolean | null >( null )
const canvasRef = useRef < HTMLDivElement >( null )
const nodeRefs = useRef < Record < string , HTMLButtonElement | null >>({})
const [ anchors , setAnchors ] = useState < Record < string , Anchor >>({})
const [ size , setSize ] = useState ({ w : 800 , h : 460 })
const setNodeRef = useCallback (( id : string ) => ( el : HTMLButtonElement | null ) => {
nodeRefs . current [ id ] = el
}, [])
const load = useCallback ( async ( refresh = false ) => {
const g = await fetchDataflow ( refresh )
if ( g ) setGraph ( g )
setLoading ( false )
}, [])
useEffect (() => {
load ()
fetchAgentOpsStatus (). then (( s ) => {
const etl = ( s as { etl ?: { enabled? : boolean } }) ? . etl
if ( etl ) setEtlEnabled ( !! etl . enabled )
})
const iv = setInterval (() => load (), 6000 )
return () => clearInterval ( iv )
}, [ load ])
const measure = useCallback (() => {
const canvas = canvasRef . current
if ( ! canvas ) return
const rect = canvas . getBoundingClientRect ()
if ( rect . width < 10 || rect . height < 10 ) return
setSize ({ w : rect.width , h : rect.height })
const next : Record < string , Anchor > = {}
for ( const [ id , el ] of Object . entries ( nodeRefs . current )) {
if ( ! el ) continue
const r = el . getBoundingClientRect ()
next [ id ] = {
x : ( r . left + r . right ) / 2 - rect . left ,
y : ( r . top + r . bottom ) / 2 - rect . top ,
w : r.width ,
h : r.height ,
}
}
setAnchors ( next )
}, [])
useLayoutEffect (() => {
measure ()
const canvas = canvasRef . current
if ( ! canvas ) return
const ro = new ResizeObserver (() => measure ())
ro . observe ( canvas )
window . addEventListener ( 'resize' , measure )
return () => {
ro . disconnect ()
window . removeEventListener ( 'resize' , measure )
}
}, [ measure ])
useLayoutEffect (() => {
measure ()
}, [ measure , graph ])
const nodes = graph ? . nodes ?? []
const edges = graph ? . edges ?? []
const onTrigger = useCallback ( async ( movementId : string ) => {
setTriggering ( movementId )
try {
await runDataflowMovement ( movementId )
setTimeout (() => load ( true ), 800 )
} finally {
setTimeout (() => setTriggering ( null ), 1500 )
}
}, [ load ])
const onToggleEtl = useCallback ( async () => {
const next = ! etlEnabled
setEtlEnabled ( next )
await toggleEtlAgent ( next )
}, [ etlEnabled ])
const onRefresh = useCallback ( async () => {
setRefreshing ( true )
await load ( true )
setTimeout (() => setRefreshing ( false ), 400 )
}, [ load ])
2026-06-27 11:36:36 +02:00
const [ maskBusy , setMaskBusy ] = useState < string | null >( null )
const onToggleMask = useCallback ( async ( key : string , column : string , masked : boolean ) => {
setMaskBusy ( ` ${ key } . ${ column } ` )
try {
await setPiiMask ( key , column , masked )
await load ( true )
} finally {
setMaskBusy ( null )
}
}, [ load ])
2026-06-27 02:09:38 +02:00
const piiSummary = graph ? . pii_summary ?? {}
const movementEdges = useMemo (
() => edges . filter (( e ) => e . movement_id ),
[ edges ],
)
const triggerable = useMemo (() => {
const seen = new Set < string >()
const out : DataflowEdge [] = []
for ( const e of movementEdges ) {
if ( e . movement_id && ! seen . has ( e . movement_id )) {
seen . add ( e . movement_id )
out . push ( e )
}
}
return out
}, [ movementEdges ])
const selNode = nodes . find (( n ) => n . id === selected ) || null
return (
< section className = "panel flex h-full min-h-0 flex-1 flex-col overflow-hidden" >
< header
className = "flex shrink-0 flex-col gap-1 border-b border-border px-3 py-2"
style = {{ background : 'var(--topo-header-bg)' }}
>
< div className = "flex items-center justify-between gap-2" >
< div className = "flex min-w-0 items-center gap-2" >
< span className = "flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-docker text-white shadow-docker" >
< GitBranch className = "h-3 w-3" />
</ span >
< div className = "min-w-0" >
< h2 className = "truncate text-xs font-semibold text-foreground" > Data Flow · live lineage </ h2 >
< p className = "truncate text-[9px] text-foreground-muted" >
Generators → sources → CDC / Kafka → lakehouse · click a node for PII detail · trigger movements below
</ p >
</ div >
</ div >
< div className = "flex shrink-0 flex-wrap items-center justify-end gap-1" >
< Badge variant = { graph ? . cdc ? . connected ? 'success' : 'warning' }>
{ graph ? . cdc ? . connected ? 'CDC live' : 'CDC offline' }
</ Badge >
< Badge >{ graph ? . cdc ? . window_total ?? 0 } chg / 15 m </ Badge >
{( piiSummary . unmasked_columns ?? 0 ) > 0 ? (
< Badge variant = "warning" >{ piiSummary . unmasked_columns } PII unmasked </ Badge >
) : (
< Badge variant = "accent" >{ piiSummary . masked_columns ?? 0 } PII masked </ Badge >
)}
< button
type = "button"
onClick = {() => setPiiOverlay (( v ) => ! v )}
className = { cn (
'rounded border px-1.5 py-0.5 text-[9px] font-medium transition-colors' ,
piiOverlay
? 'border-rose-400/50 bg-rose-500/20 text-rose-200'
: 'border-border bg-transparent text-foreground-muted hover:text-foreground' ,
)}
>
PII overlay
</ button >
< button
type = "button"
onClick = { onToggleEtl }
className = { cn (
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] font-medium transition-colors' ,
etlEnabled
? 'border-emerald-400/50 bg-emerald-500/20 text-emerald-200'
: 'border-border bg-transparent text-foreground-muted hover:text-foreground' ,
)}
>
< Bot className = "h-3 w-3" /> ETL agent { etlEnabled == null ? '' : etlEnabled ? 'on' : 'off' }
</ button >
< button
type = "button"
onClick = { onRefresh }
className = "inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:text-foreground"
>
< RefreshCw className = { cn ( 'h-3 w-3' , refreshing && 'animate-spin' )} /> Refresh
</ button >
</ div >
</ div >
< div className = "flex flex-wrap gap-x-3 gap-y-0.5" >
{ EDGE_LEGEND . map (( l ) => (
< span key = { l . kind } className = "inline-flex items-center gap-1 text-[8px] text-foreground-muted" >
< span className = "h-1.5 w-1.5 rounded-full" style = {{ background : EDGE_COLOR [ l . kind ] }} />
{ l . label }
</ span >
))}
</ div >
</ header >
{ /* canvas */ }
< div ref = { canvasRef } className = "topo-canvas relative flex min-h-0 flex-1" >
{ loading && (
< div className = "absolute inset-0 z-20 flex items-center justify-center text-[10px] text-foreground-muted" >
< Loader2 className = "mr-1 h-3 w-3 animate-spin" /> loading flow …
</ div >
)}
< svg
className = "pointer-events-none absolute inset-0 z-0 h-full w-full"
viewBox = { `0 0 ${ size . w } ${ size . h } ` }
aria - hidden
>
{ edges . map (( edge , i ) => {
const a = anchors [ edge . from ]
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 dur = 1.6 + ( i % 5 ) * 0.3
return (
< g key = { ` ${ edge . from } - ${ edge . to } - ${ edge . kind } ` }>
< path
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 }
/>
{ active && (
<>
< circle r = { running ? 3 : 2.2 } fill = { color } opacity = "0.95" >
< animateMotion dur = { ` ${ running ? dur * 0.6 : dur } s` } repeatCount = "indefinite" path = { d } />
</ circle >
< circle r = "1.3" fill = "#ffffff" opacity = "0.85" >
< animateMotion dur = { ` ${ running ? dur * 0.6 : dur } s` } repeatCount = "indefinite" path = { d } begin = { ` ${ dur * 0.45 } s` } />
</ circle >
</>
)}
</ g >
)
})}
</ svg >
{ /* nodes */ }
{ nodes . map (( node ) => (
< NodeCard
key = { node . id }
node = { node }
piiOverlay = { piiOverlay }
selected = { selected === node . id }
setRef = { setNodeRef ( node . id )}
onClick = {() => setSelected (( s ) => ( s === node . id ? null : node . id ))}
/>
))}
{ /* node inspector */ }
{ selNode && (
< div className = "absolute right-2 top-2 z-30 w-52 rounded-lg border border-border bg-surface/95 p-2 shadow-lg backdrop-blur" >
< div className = "mb-1 flex items-center justify-between" >
< span className = "text-[10px] font-semibold text-foreground" >{ selNode . label }</ span >
< button onClick = {() => setSelected ( null )} className = "text-[10px] text-foreground-muted hover:text-foreground" > ✕ </ button >
</ div >
< p className = "text-[8px] text-foreground-muted" >{ selNode . sub }</ p >
{ selNode . metric && (
< p className = "mt-1 font-mono text-[8px] text-emerald-300" >{ selNode . metric }</ p >
)}
2026-06-27 02:43:22 +02:00
{ selNode . url && (
< a
href = { selNode . url }
target = "_blank"
rel = "noreferrer"
className = "mt-1 inline-block rounded border border-fuchsia-400/40 bg-fuchsia-500/15 px-1.5 py-0.5 text-[8px] font-medium text-fuchsia-200 hover:bg-fuchsia-500/25"
>
Open in OpenMetadata ↗
</ a >
)}
2026-06-27 02:09:38 +02:00
{ selNode . pii ? . has_pii ? (
< div className = "mt-1.5 border-t border-border pt-1.5" >
2026-06-27 11:36:36 +02:00
< div className = "mb-1 flex items-center justify-between gap-1 text-[9px] font-medium text-rose-300" >
< span className = "flex items-center gap-1" >
{ selNode . pii . all_masked ? < ShieldCheck className = "h-3 w-3 text-emerald-300" /> : < ShieldAlert className = "h-3 w-3" />}
{ selNode . pii . pii_count } PII column { selNode . pii . pii_count === 1 ? '' : 's' }
</ span >
{ ! selNode . pii . masked_layer && (
< span className = "flex gap-1" >
< button
type = "button"
onClick = {() => onToggleMask ( selNode . pii ! . key , '*' , true )}
className = "rounded border border-emerald-400/40 px-1 text-[7px] text-emerald-300 hover:bg-emerald-500/15"
> mask all </ button >
< button
type = "button"
onClick = {() => onToggleMask ( selNode . pii ! . key , '*' , false )}
className = "rounded border border-rose-400/40 px-1 text-[7px] text-rose-300 hover:bg-rose-500/15"
> unmask all </ button >
</ span >
)}
2026-06-27 02:09:38 +02:00
</ div >
2026-06-27 11:36:36 +02:00
< p className = "mb-1 text-[7px] leading-tight text-foreground-faint" >
Masked columns are hidden from the assistant — it refuses to reveal them .
</ p >
2026-06-27 02:09:38 +02:00
< div className = "flex flex-col gap-0.5" >
2026-06-27 11:36:36 +02:00
{ selNode . pii . columns . map (( c ) => {
const locked = c . policy_locked
const busy = maskBusy === ` ${ selNode . pii ! . key } . ${ c . name } `
return (
< button
key = { c . name }
type = "button"
disabled = { locked || busy }
onClick = {() => onToggleMask ( selNode . pii ! . key , c . name , ! c . masked )}
title = { locked ? 'Physically masked in the curated layer' : c . masked ? 'Masked — click to allow the assistant to read it' : 'Visible to the assistant — click to mask' }
className = { cn (
'flex items-center justify-between gap-1 rounded px-1 py-0.5 text-[8px] transition-colors' ,
locked ? 'cursor-default opacity-70' : 'hover:bg-surface-overlay' ,
)}
>
< span className = "truncate font-mono text-foreground-muted" >{ c . name }</ span >
< span className = "flex items-center gap-1" >
< span className = "text-blue-300" >{ c . category }</ span >
< span className = { cn ( 'inline-flex items-center gap-0.5' , c . masked ? 'text-emerald-300' : 'text-rose-300' )}>
{ busy ? < Loader2 className = "h-2.5 w-2.5 animate-spin" />
: c . masked ? < Lock className = "h-2.5 w-2.5" /> : < LockOpen className = "h-2.5 w-2.5" />}
{ c . masked ? 'masked' : 'visible' }
</ span >
2026-06-27 02:09:38 +02:00
</ span >
2026-06-27 11:36:36 +02:00
</ button >
)
})}
2026-06-27 02:09:38 +02:00
</ div >
</ div >
) : (
< p className = "mt-1 text-[8px] text-foreground-muted" > No PII classified .</ p >
)}
</ div >
)}
</ div >
{ /* movement control strip */ }
< div className = "shrink-0 border-t border-border bg-surface/60 px-3 py-1.5" >
< div className = "mb-1 flex items-center gap-1 text-[8px] font-semibold uppercase tracking-wide text-foreground-muted" >
< Play className = "h-2.5 w-2.5" /> Data movements
</ div >
< div className = "flex flex-wrap gap-1.5" >
{ triggerable . map (( e ) => {
const running = e . state === 'running'
const busy = triggering === e . movement_id || running
return (
< button
key = { e . movement_id }
type = "button"
disabled = { busy }
onClick = {() => e . movement_id && onTrigger ( e . movement_id )}
className = { cn (
'inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[9px] font-medium transition-colors disabled:opacity-70' ,
running
? 'border-violet-400/60 bg-violet-500/20 text-violet-200'
: 'border-border bg-background/40 text-foreground hover:border-emerald-400/50 hover:bg-emerald-500/10' ,
)}
>
{ busy ? < Loader2 className = "h-3 w-3 animate-spin" /> : < Play className = "h-3 w-3" />}
< span >{ e . movement_id }</ span >
{ e . state && e . state !== 'running' && (
< span className = { cn ( 'font-mono' , e . state === 'success' ? 'text-emerald-300' : 'text-rose-300' )}>
· { e . state }{ e . last_rows != null ? ` ${ e . last_rows . toLocaleString () } r` : '' }{ e . last_duration_s ? ` ${ fmtDur ( e . last_duration_s ) } ` : '' }
</ span >
)}
{ running && < span className = "font-mono text-violet-200" > · running … </ span >}
</ button >
)
})}
</ div >
</ div >
</ section >
)
}
function NodeCard ({
node , piiOverlay , selected , setRef , onClick ,
} : {
node : DataflowNode
piiOverlay : boolean
selected : boolean
setRef : ( el : HTMLButtonElement | null ) => void
onClick : () => void
}) {
const kind = NODE_KIND [ node . kind ] || NODE_KIND . source
const pii = node . pii
const showPii = piiOverlay && pii ? . has_pii
return (
< button
ref = { setRef }
type = "button"
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' ,
)}
style = {{ left : ` ${ node . x } %` , top : ` ${ node . y } %` }}
>
< div className = "flex w-full items-center justify-between gap-1" >
< span className = "truncate text-[9px] font-semibold leading-tight text-white" >{ node . label }</ span >
{ showPii && (
pii ? . all_masked
? < ShieldCheck className = "h-3 w-3 shrink-0 text-emerald-300" />
: < ShieldAlert className = "h-3 w-3 shrink-0 text-rose-300" />
)}
</ 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 >
)}
{ showPii && (
< span className = "inline-block max-w-full truncate rounded border border-rose-400/40 bg-rose-500/15 px-0.5 py-px text-[6px] font-medium leading-tight text-rose-200" >
{ pii ? . pii_count } PII · { pii ? . categories . slice ( 0 , 2 ). join ( ',' )}
</ span >
)}
</ button >
)
}