ui: Data Generation view (per-DB tabs, light generate, run status) + topology pulse
This commit is contained in:
@@ -17,6 +17,7 @@ import { DataQualityView } from './components/features/DataQualityView'
|
|||||||
import { KnowledgeChatView } from './components/features/KnowledgeChatView'
|
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 { 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'
|
||||||
@@ -123,7 +124,10 @@ export default function App() {
|
|||||||
animations={cc.anims}
|
animations={cc.anims}
|
||||||
selectedNodeId={cc.selectedNodeId}
|
selectedNodeId={cc.selectedNodeId}
|
||||||
onNodeClick={cc.selectNode}
|
onNodeClick={cc.selectNode}
|
||||||
|
pulse={cc.genPulse}
|
||||||
/>
|
/>
|
||||||
|
) : cc.mainView === 'datagen' ? (
|
||||||
|
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
|
||||||
) : cc.mainView === 'presentation' ? (
|
) : cc.mainView === 'presentation' ? (
|
||||||
<PresentationView />
|
<PresentationView />
|
||||||
) : cc.mainView === 'dataquality' ? (
|
) : cc.mainView === 'dataquality' ? (
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Database, Boxes, Activity, Cpu, Network, Layers, Play, Loader2, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type SourceKey = 'all' | 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
||||||
|
|
||||||
|
type SourceMeta = {
|
||||||
|
key: SourceKey
|
||||||
|
label: string
|
||||||
|
icon: typeof Database
|
||||||
|
accent: string
|
||||||
|
target: string
|
||||||
|
cdc: boolean
|
||||||
|
desc: string
|
||||||
|
defaultRows: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCES: SourceMeta[] = [
|
||||||
|
{ key: 'all', label: 'All sources', icon: Layers, accent: 'text-violet-400', target: 'alle 5 databases', cdc: true, desc: 'Genereer tegelijk in alle databases.', defaultRows: 5000 },
|
||||||
|
{ key: 'postgres', label: 'PostgreSQL', icon: Database, accent: 'text-sky-400', target: 'sales_orders', cdc: true, desc: 'Sales orders. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
||||||
|
{ key: 'mysql', label: 'MySQL', icon: Database, accent: 'text-amber-400', target: 'employee_events', cdc: true, desc: 'HR employee events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
||||||
|
{ key: 'mongodb', label: 'MongoDB', icon: Boxes, accent: 'text-emerald-400', target: 'supplychain.events', cdc: true, desc: 'Supply chain events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
||||||
|
{ key: 'cassandra', label: 'Cassandra', icon: Activity, accent: 'text-cyan-400', target: 'device_metrics', cdc: false, desc: 'Telemetry metrics. Zichtbaar via Trino.', defaultRows: 5000 },
|
||||||
|
{ key: 'neo4j', label: 'Neo4j', icon: Network, accent: 'text-pink-400', target: 'Product/Supplier graph', cdc: false, desc: 'Graafdata (producten, leveranciers, relaties).', defaultRows: 2000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
type RunInfo = { run_id?: string; state?: string; start?: string; end?: string; conf?: { rows?: number } }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onPulse: () => void
|
||||||
|
onOpenPlatform: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNT_KEYS: SourceKey[] = ['postgres', 'mysql', 'mongodb', 'cassandra']
|
||||||
|
|
||||||
|
export function DataGenView({ onPulse, onOpenPlatform }: Props) {
|
||||||
|
const [active, setActive] = useState<SourceKey>('all')
|
||||||
|
const [rows, setRows] = useState<Record<SourceKey, number>>(
|
||||||
|
Object.fromEntries(SOURCES.map((s) => [s.key, s.defaultRows])) as Record<SourceKey, number>,
|
||||||
|
)
|
||||||
|
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||||
|
const [runs, setRuns] = useState<Record<string, RunInfo[]>>({})
|
||||||
|
const [counts, setCounts] = useState<Record<string, number | null>>({})
|
||||||
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
|
const pollRef = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
||||||
|
|
||||||
|
const meta = SOURCES.find((s) => s.key === active)!
|
||||||
|
|
||||||
|
const loadCounts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/pipeline/sync')
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok) setCounts(j.counts || {})
|
||||||
|
} catch { /* */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadRuns = useCallback(async (source: SourceKey) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/pipeline/runs/${source}?limit=5`)
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok) setRuns((prev) => ({ ...prev, [source]: j.runs || [] }))
|
||||||
|
return (j.runs || [])[0] as RunInfo | undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCounts()
|
||||||
|
SOURCES.forEach((s) => loadRuns(s.key))
|
||||||
|
return () => { Object.values(pollRef.current).forEach(clearInterval) }
|
||||||
|
}, [loadCounts, loadRuns])
|
||||||
|
|
||||||
|
const startPolling = useCallback((source: SourceKey) => {
|
||||||
|
if (pollRef.current[source]) clearInterval(pollRef.current[source])
|
||||||
|
pollRef.current[source] = setInterval(async () => {
|
||||||
|
const latest = await loadRuns(source)
|
||||||
|
if (latest && (latest.state === 'success' || latest.state === 'failed')) {
|
||||||
|
clearInterval(pollRef.current[source])
|
||||||
|
delete pollRef.current[source]
|
||||||
|
setBusy((b) => ({ ...b, [source]: false }))
|
||||||
|
loadCounts()
|
||||||
|
if (latest.state === 'success') setMsg(`${source}: klaar — nieuwe data gegenereerd`)
|
||||||
|
else setMsg(`${source}: run mislukt — check Airflow logs`)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}, [loadRuns, loadCounts])
|
||||||
|
|
||||||
|
const generate = useCallback(async (source: SourceKey) => {
|
||||||
|
setMsg(null)
|
||||||
|
setBusy((b) => ({ ...b, [source]: true }))
|
||||||
|
onPulse()
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/pipeline/generate/${source}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rows: rows[source] }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!j.ok) {
|
||||||
|
setMsg(`Fout: ${j.error || 'kon niet triggeren'}`)
|
||||||
|
setBusy((b) => ({ ...b, [source]: false }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMsg(`${source}: gestart (run ${String(j.run_id).slice(-8)})`)
|
||||||
|
startPolling(source)
|
||||||
|
} catch {
|
||||||
|
setMsg('API niet bereikbaar')
|
||||||
|
setBusy((b) => ({ ...b, [source]: false }))
|
||||||
|
}
|
||||||
|
}, [rows, onPulse, startPolling])
|
||||||
|
|
||||||
|
const stateBadge = (state?: string) => {
|
||||||
|
if (state === 'success') return <span className="inline-flex items-center gap-1 text-emerald-400"><CheckCircle2 className="h-3 w-3" /> success</span>
|
||||||
|
if (state === 'failed') return <span className="inline-flex items-center gap-1 text-danger"><XCircle className="h-3 w-3" /> failed</span>
|
||||||
|
if (state === 'running' || state === 'queued') return <span className="inline-flex items-center gap-1 text-amber-400"><Loader2 className="h-3 w-3 animate-spin" /> {state}</span>
|
||||||
|
return <span className="text-foreground-faint">{state || '—'}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = (runs[active] || [])[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<Cpu className="h-4 w-4 text-violet-400" /> Data Generation
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-foreground-muted">
|
||||||
|
Genereer per database nieuwe data en pulse de hele flow: bron -> Debezium -> Kafka -> S3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => { loadCounts(); SOURCES.forEach((s) => loadRuns(s.key)) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||||
|
<RefreshCw className="inline h-3 w-3" /> Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onOpenPlatform} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabActive)}>
|
||||||
|
Bekijk topology pulse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||||
|
{SOURCES.map((s) => {
|
||||||
|
const Icon = s.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(s.key)}
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-medium', active === s.key ? subTabActive : subTabIdle)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', s.accent)} />
|
||||||
|
{s.label}
|
||||||
|
{busy[s.key] && <Loader2 className="h-3 w-3 animate-spin text-amber-400" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="max-w-2xl space-y-4">
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/40 p-4">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<meta.icon className={cn('h-5 w-5', meta.accent)} />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{meta.label}</h3>
|
||||||
|
{meta.cdc ? (
|
||||||
|
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] text-emerald-400">CDC actief</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-foreground-muted">geen CDC-stream</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-[11px] text-foreground-muted">{meta.desc} Doel: <span className="font-mono">{meta.target}</span></p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
|
||||||
|
Aantal rijen
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={2000000}
|
||||||
|
value={rows[active]}
|
||||||
|
onChange={(e) => setRows((r) => ({ ...r, [active]: Number(e.target.value) }))}
|
||||||
|
className="w-40 rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy[active]}
|
||||||
|
onClick={() => generate(active)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-violet-500/90 px-4 py-2 text-[12px] font-semibold text-black hover:bg-violet-400 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy[active] ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
|
Genereer data
|
||||||
|
</button>
|
||||||
|
{active !== 'all' && COUNT_KEYS.includes(active) && (
|
||||||
|
<div className="text-[11px] text-foreground-muted">
|
||||||
|
Huidige rijen: <span className="font-mono text-foreground">{counts[active]?.toLocaleString() ?? '…'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg && <p className="mt-3 text-[11px] text-foreground-muted">{msg}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">Recente runs — {meta.label}</h4>
|
||||||
|
{latest ? (
|
||||||
|
<table className="w-full text-left text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
|
||||||
|
<th className="py-1 pr-2">State</th>
|
||||||
|
<th className="py-1 pr-2">Rows</th>
|
||||||
|
<th className="py-1 pr-2">Start</th>
|
||||||
|
<th className="py-1 pr-2">Eind</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(runs[active] || []).map((run) => (
|
||||||
|
<tr key={run.run_id} className="border-b border-border/40">
|
||||||
|
<td className="py-1 pr-2">{stateBadge(run.state)}</td>
|
||||||
|
<td className="py-1 pr-2 font-mono text-foreground-muted">{run.conf?.rows ?? '—'}</td>
|
||||||
|
<td className="py-1 pr-2 text-foreground-faint">{run.start?.slice(11, 19) || '—'}</td>
|
||||||
|
<td className="py-1 pr-2 text-foreground-faint">{run.end?.slice(11, 19) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-foreground-faint">Nog geen runs.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">Live tellingen (Trino)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{COUNT_KEYS.map((k) => (
|
||||||
|
<div key={k} className="rounded border border-border/60 px-2 py-1.5">
|
||||||
|
<div className="text-[9px] uppercase text-foreground-faint">{k}</div>
|
||||||
|
<div className="font-mono text-[12px] text-foreground">{counts[k]?.toLocaleString() ?? '…'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -253,9 +253,10 @@ type Props = {
|
|||||||
animations: Record<string, AgentAnim>
|
animations: Record<string, AgentAnim>
|
||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
onNodeClick: (nodeId: string) => void
|
onNodeClick: (nodeId: string) => void
|
||||||
|
pulse?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
|
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick, pulse = false }: Props) {
|
||||||
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
|
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||||
@@ -292,7 +293,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
|
|||||||
[animations],
|
[animations],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesLive = pipelineActive || anyBusy
|
const edgesLive = pulse || pipelineActive || anyBusy
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/search/health").then(r => r.json()).then(j => {
|
fetch("/api/search/health").then(r => r.json()).then(j => {
|
||||||
@@ -343,7 +344,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
|
|||||||
}, [measureAnchors, metrics, llmLabel])
|
}, [measureAnchors, metrics, llmLabel])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<section className={cn('panel flex h-full min-h-0 flex-1 flex-col overflow-hidden', pulse && 'topo-pulsing')}>
|
||||||
<header
|
<header
|
||||||
className="flex shrink-0 flex-col gap-0.5 border-b border-border px-2 py-1"
|
className="flex shrink-0 flex-col gap-0.5 border-b border-border px-2 py-1"
|
||||||
style={{ background: 'var(--topo-header-bg)' }}
|
style={{ background: 'var(--topo-header-bg)' }}
|
||||||
@@ -402,7 +403,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
|
|||||||
const longArc = colGap > 1 || (Math.abs(b.y - a.y) > 48 && Math.abs(toX - fromX) > 120)
|
const longArc = colGap > 1 || (Math.abs(b.y - a.y) > 48 && Math.abs(toX - fromX) > 120)
|
||||||
const d = flowPath(fromX, a.y, toX, b.y, { backward, longArc })
|
const d = flowPath(fromX, a.y, toX, b.y, { backward, longArc })
|
||||||
const live = edgesLive
|
const live = edgesLive
|
||||||
const dur = 1.8 + (i % 5) * 0.35
|
const dur = (pulse ? 0.85 : 1.8) + (i % 5) * (pulse ? 0.12 : 0.35)
|
||||||
return (
|
return (
|
||||||
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
|
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
|
||||||
<path d={d} className="topo-edge-glow" />
|
<path d={d} className="topo-edge-glow" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare } from 'lucide-react'
|
import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu } 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' | 'approvals'
|
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workload: WorkloadData | null
|
workload: WorkloadData | null
|
||||||
@@ -24,6 +24,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: '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 },
|
||||||
|
|||||||
@@ -68,7 +68,14 @@ 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'>('platform')
|
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen'>('platform')
|
||||||
|
const [genPulse, setGenPulse] = useState(false)
|
||||||
|
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const pulseFlow = useCallback(() => {
|
||||||
|
setGenPulse(true)
|
||||||
|
if (genPulseTimer.current) clearTimeout(genPulseTimer.current)
|
||||||
|
genPulseTimer.current = setTimeout(() => setGenPulse(false), 60000)
|
||||||
|
}, [])
|
||||||
const [approvalHighlight, setApprovalHighlight] = useState(false)
|
const [approvalHighlight, setApprovalHighlight] = useState(false)
|
||||||
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null)
|
const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null)
|
||||||
const [chatExpanded, setChatExpanded] = useState(false)
|
const [chatExpanded, setChatExpanded] = useState(false)
|
||||||
@@ -336,6 +343,8 @@ export function useCommandCenter() {
|
|||||||
nodeBusy,
|
nodeBusy,
|
||||||
mainView,
|
mainView,
|
||||||
setMainView,
|
setMainView,
|
||||||
|
genPulse,
|
||||||
|
pulseFlow,
|
||||||
approvalHighlight,
|
approvalHighlight,
|
||||||
setApprovalHighlight,
|
setApprovalHighlight,
|
||||||
workbenchMode,
|
workbenchMode,
|
||||||
|
|||||||
@@ -107,6 +107,21 @@
|
|||||||
.topo-edge-query.topo-edge-live { stroke: #818cf8; filter: drop-shadow(0 0 3px rgba(129, 140, 248, 0.45)); }
|
.topo-edge-query.topo-edge-live { stroke: #818cf8; filter: drop-shadow(0 0 3px rgba(129, 140, 248, 0.45)); }
|
||||||
.topo-edge-serve.topo-edge-live { stroke: #34d399; filter: drop-shadow(0 0 3px rgba(52, 211, 153, 0.45)); }
|
.topo-edge-serve.topo-edge-live { stroke: #34d399; filter: drop-shadow(0 0 3px rgba(52, 211, 153, 0.45)); }
|
||||||
|
|
||||||
|
/* Whole-flow pulse triggered by a data-generation run */
|
||||||
|
.topo-pulsing .topo-edge-live {
|
||||||
|
stroke-width: 3;
|
||||||
|
animation: flow-dash 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
.topo-pulsing .topo-edge-glow {
|
||||||
|
stroke: rgba(124, 231, 135, 0.35);
|
||||||
|
stroke-width: 6;
|
||||||
|
animation: topo-pulse-glow 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes topo-pulse-glow {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.topo-edge-active {
|
.topo-edge-active {
|
||||||
stroke: url(#topo-flow-gradient);
|
stroke: url(#topo-flow-gradient);
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
|
|||||||
Reference in New Issue
Block a user