From cd7786ef6603e5e0012cfdfc2d034ef01a471952 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Sun, 22 Mar 2026 10:31:19 +0000 Subject: [PATCH] v0.0.2 --- flows.yaml | 49 +++++++ src/client/App.tsx | 81 +++++++++++- src/client/components/ParticleOverlay.tsx | 152 ++++++++++++++++++++++ src/client/engine/particles.ts | 118 +++++++++++++++++ src/client/hooks/useDocker.ts | 28 +++- src/client/nodes/ServiceNode.tsx | 9 +- src/client/panels/FlowPanel.tsx | 111 ++++++++++++++++ src/server/flows.ts | 53 ++++++++ src/server/index.ts | 24 +++- src/shared/types.ts | 22 +++- 10 files changed, 638 insertions(+), 9 deletions(-) create mode 100644 flows.yaml create mode 100644 src/client/components/ParticleOverlay.tsx create mode 100644 src/client/engine/particles.ts create mode 100644 src/client/panels/FlowPanel.tsx create mode 100644 src/server/flows.ts diff --git a/flows.yaml b/flows.yaml new file mode 100644 index 0000000..31a5757 --- /dev/null +++ b/flows.yaml @@ -0,0 +1,49 @@ +flows: + web_request: + name: "Request Web" + description: "Usuario accede al frontend via nginx" + color: "#3b82f6" + speed: 1.5 + path: [nginx, frontend] + + api_request: + name: "API Request" + description: "Request del frontend al backend con auth y DB" + color: "#22d3ee" + speed: 1.4 + path: [nginx, backend, db, backend, nginx] + + auth_flow: + name: "Autenticación" + description: "Login/registro pasando por auth service" + color: "#a855f7" + speed: 1.3 + path: [nginx, auth, ninja-redis, auth, nginx] + + cached_query: + name: "Consulta con Cache" + description: "Backend consulta Redis antes de ir a DB" + color: "#f59e0b" + speed: 1.5 + path: [nginx, backend, ninja-redis, backend, db, backend, nginx] + + background_job: + name: "Tarea en Background" + description: "Celery beat agenda tareas, worker las ejecuta" + color: "#ec4899" + speed: 1.8 + path: [celery-beat, ninja-redis, celery-worker, db] + + data_collection: + name: "Recolección de Datos" + description: "Collector guarda en DB e invalida cache en Redis" + color: "#10b981" + speed: 2.0 + path: [collector, db, collector, ninja-redis] + +settings: + particle_size: 2 + trail: true + trail_opacity: 0.3 + glow: true + max_particles: 50 diff --git a/src/client/App.tsx b/src/client/App.tsx index 4f4ffa7..22d89d0 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -19,8 +19,11 @@ import { ServiceNode } from "./nodes/ServiceNode"; import { GroupNode } from "./nodes/GroupNode"; import { useDocker } from "./hooks/useDocker"; import { buildLayout, computeEdges } from "./engine/layout"; +import { ParticleEngine } from "./engine/particles"; +import { ParticleOverlay } from "./components/ParticleOverlay"; import { LogPanel } from "./panels/LogPanel"; -import type { Service } from "../shared/types"; +import { FlowPanel } from "./panels/FlowPanel"; +import type { Service, Flow } from "../shared/types"; function OffsetEdge(props: EdgeProps) { const offset = (props.data as any)?.offset ?? 0; @@ -207,7 +210,13 @@ export default function App() { } function Dashboard({ token }: { token: string }) { - const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines } = useDocker(token); + const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn } = useDocker(token); + const engineRef = useRef(null); + if (!engineRef.current) { + engineRef.current = new ParticleEngine(); + } + const engine = engineRef.current; + engine.maxParticles = flowSettings.max_particles; const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const initialLayoutDone = useRef(false); @@ -531,6 +540,64 @@ function Dashboard({ token }: { token: string }) { }, 1200); }, [events]); + // Service name → UID map for flow path resolution + const serviceNameToUid = useMemo(() => { + const map = new Map(); + for (const s of services) { + map.set(s.name, s.uid); + } + return map; + }, [services]); + + const resolveFlowPath = useCallback((path: string[]): string[] => { + return path.map((name) => serviceNameToUid.get(name) || name); + }, [serviceNameToUid]); + + const handleSimulate = useCallback((flow: Flow) => { + const pathUids = resolveFlowPath(flow.path); + engine.spawn(flow.id, flow.color, flow.speed, pathUids); + // Also broadcast via WS so other clients see it + sendMessage({ type: "simulate_flow", flowId: flow.id }); + }, [resolveFlowPath, engine, sendMessage]); + + // Handle particle spawns from other clients via WS + useEffect(() => { + return onParticleSpawn((data) => { + const pathUids = resolveFlowPath(data.path); + engine.spawn(data.flowId, data.color, data.speed, pathUids); + }); + }, [onParticleSpawn, resolveFlowPath, engine]); + + // Handle particle node hits — flash node with particle color + const nodeHitTimers = useRef>>(new Map()); + const handleNodeHits = useCallback((hits: { nodeId: string; color: string }[]) => { + for (const hit of hits) { + // Clear existing timer for this node + const existing = nodeHitTimers.current.get(hit.nodeId); + if (existing) clearTimeout(existing); + + setNodes((prev) => + prev.map((n) => + n.id === hit.nodeId + ? { ...n, data: { ...n.data, particleGlow: hit.color } } + : n + ) + ); + + const timer = setTimeout(() => { + setNodes((prev) => + prev.map((n) => + n.id === hit.nodeId + ? { ...n, data: { ...n.data, particleGlow: "" } } + : n + ) + ); + nodeHitTimers.current.delete(hit.nodeId); + }, 500); + nodeHitTimers.current.set(hit.nodeId, timer); + } + }, [setNodes]); + const runningCount = filteredServices.filter((s) => s.state === "running").length; // Highlight edges connected to selected node, dim the rest @@ -598,6 +665,15 @@ function Dashboard({ token }: { token: string }) {
+ {/* Flow panel */} + + {/* Project filter dropdown */} {projects.length > 1 && (
@@ -696,6 +772,7 @@ function Dashboard({ token }: { token: string }) { maxZoom={2.5} proOptions={{ hideAttribution: true }} > + diff --git a/src/client/components/ParticleOverlay.tsx b/src/client/components/ParticleOverlay.tsx new file mode 100644 index 0000000..c5091f7 --- /dev/null +++ b/src/client/components/ParticleOverlay.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useCallback } from "react"; +import type { ParticleEngine } from "../engine/particles"; +import type { FlowSettings } from "../../shared/types"; + +interface ParticleOverlayProps { + engine: ParticleEngine; + settings: FlowSettings; + onNodeHits?: (hits: { nodeId: string; color: string }[]) => void; +} + +export function ParticleOverlay({ engine, settings, onNodeHits }: ParticleOverlayProps) { + const svgRef = useRef(null); + const rafRef = useRef(0); + const lastTimeRef = useRef(0); + // Cache edge path lookups to avoid querying DOM every frame + const pathCache = useRef>(new Map()); + + const findEdgePath = useCallback((edgeId: string): SVGPathElement | null => { + if (pathCache.current.has(edgeId)) return pathCache.current.get(edgeId)!; + const el = document.querySelector(`[data-testid="rf__edge-${edgeId}"]`); + const pathEl = (el?.querySelector(".react-flow__edge-path") as SVGPathElement) || null; + pathCache.current.set(edgeId, pathEl); + // Invalidate cache after a bit in case edges re-render + setTimeout(() => pathCache.current.delete(edgeId), 2000); + return pathEl; + }, []); + + const loop = useCallback((timestamp: number) => { + if (!lastTimeRef.current) lastTimeRef.current = timestamp; + const delta = Math.min(timestamp - lastTimeRef.current, 100); + lastTimeRef.current = timestamp; + + engine.tick(delta); + + // Notify node hits + const hits = engine.getNodeHits(); + if (hits.length > 0 && onNodeHits) { + onNodeHits(hits); + } + + const svg = svgRef.current; + if (!svg) { + rafRef.current = requestAnimationFrame(loop); + return; + } + + // Clear previous particles (keep ) + const defs = svg.firstChild; + while (svg.lastChild && svg.lastChild !== defs) { + svg.removeChild(svg.lastChild); + } + + const edgeParticles = engine.getEdgeParticles(); + if (edgeParticles.size === 0) { + rafRef.current = requestAnimationFrame(loop); + return; + } + + const size = settings.particle_size; + const svgCTM = svg.getScreenCTM(); + if (!svgCTM) { + rafRef.current = requestAnimationFrame(loop); + return; + } + const svgCTMInverse = svgCTM.inverse(); + + // Track which edges we already rendered particles for (avoid duplicates from forward+reverse) + const rendered = new Set(); + + for (const [edgeId, pList] of edgeParticles) { + const pathEl = findEdgePath(edgeId); + if (!pathEl) continue; + + const pathCTM = pathEl.getScreenCTM(); + if (!pathCTM) continue; + + const totalLength = pathEl.getTotalLength(); + + for (const p of pList) { + // Unique key to avoid rendering same particle twice (forward+reverse entries) + const particleKey = `${p.color}-${p.progress.toFixed(4)}-${p.reverse}`; + if (rendered.has(particleKey)) continue; + rendered.add(particleKey); + + // If reverse, traverse path backwards + const t = p.reverse ? (1 - p.progress) : p.progress; + const point = pathEl.getPointAtLength(t * totalLength); + + // Convert: path-local → screen → our SVG coords + const screenX = pathCTM.a * point.x + pathCTM.c * point.y + pathCTM.e; + const screenY = pathCTM.b * point.x + pathCTM.d * point.y + pathCTM.f; + const x = svgCTMInverse.a * screenX + svgCTMInverse.c * screenY + svgCTMInverse.e; + const y = svgCTMInverse.b * screenX + svgCTMInverse.d * screenY + svgCTMInverse.f; + + // Outer glow + if (settings.trail) { + const glow = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + glow.setAttribute("cx", String(x)); + glow.setAttribute("cy", String(y)); + glow.setAttribute("r", String(size * 2.5)); + glow.setAttribute("fill", p.color); + glow.setAttribute("opacity", String(settings.trail_opacity * 0.25)); + svg.appendChild(glow); + } + + // Main circle + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", String(x)); + circle.setAttribute("cy", String(y)); + circle.setAttribute("r", String(size)); + circle.setAttribute("fill", p.color); + if (settings.glow) { + circle.setAttribute("filter", "url(#particle-glow)"); + } + svg.appendChild(circle); + } + } + + rafRef.current = requestAnimationFrame(loop); + }, [engine, settings, onNodeHits, findEdgePath]); + + useEffect(() => { + rafRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(rafRef.current); + }, [loop]); + + return ( + + + + + + + + + + + + ); +} diff --git a/src/client/engine/particles.ts b/src/client/engine/particles.ts new file mode 100644 index 0000000..9728dfc --- /dev/null +++ b/src/client/engine/particles.ts @@ -0,0 +1,118 @@ +export interface Particle { + id: string; + flowId: string; + color: string; + path: string[]; // UIDs + currentStep: number; + progress: number; // 0-1 within current edge + speed: number; + paused: number; // remaining pause time in ms at node +} + +export interface NodeHit { + nodeId: string; + color: string; +} + +let idCounter = 0; + +const PAUSE_AT_NODE_MS = 400; + +export class ParticleEngine { + particles: Particle[] = []; + maxParticles = 50; + nodeHits: NodeHit[] = []; + + spawn(flowId: string, color: string, speed: number, pathUids: string[]): void { + if (pathUids.length < 2) return; + if (this.particles.length >= this.maxParticles) return; + + this.particles.push({ + id: `p-${++idCounter}`, + flowId, + color, + path: pathUids, + currentStep: 0, + progress: 0, + speed, + paused: 0, + }); + + // First node hit + this.nodeHits.push({ nodeId: pathUids[0]!, color }); + } + + tick(deltaMs: number): void { + this.nodeHits = []; + + for (const p of this.particles) { + // If paused at a node, count down + if (p.paused > 0) { + p.paused -= deltaMs; + if (p.paused > 0) continue; + // Resume: advance to next step + p.currentStep++; + p.progress = 0; + continue; + } + + p.progress += deltaMs / (p.speed * 1000); + + if (p.progress >= 1) { + // Arrived at next node — pause there + const arrivedAt = p.path[p.currentStep + 1]; + if (arrivedAt) { + this.nodeHits.push({ nodeId: arrivedAt, color: p.color }); + } + p.progress = 1; + p.paused = PAUSE_AT_NODE_MS; + } + } + + // Remove completed particles (past last edge and done pausing) + this.particles = this.particles.filter((p) => { + if (p.currentStep >= p.path.length - 1) return false; + return true; + }); + } + + getEdgeParticles(): Map { + const map = new Map(); + for (const p of this.particles) { + if (p.paused > 0) continue; // paused at node, don't render on edge + if (p.currentStep >= p.path.length - 1) continue; + + const from = p.path[p.currentStep]!; + const to = p.path[p.currentStep + 1]!; + + // Try forward edge first, then reverse + const forwardId = `${from}-${to}`; + const reverseId = `${to}-${from}`; + + // We'll try both — the renderer will check which exists in DOM + const edgeId = forwardId; + const reverseEdgeId = reverseId; + + if (!map.has(edgeId)) map.set(edgeId, []); + map.get(edgeId)!.push({ progress: p.progress, color: p.color, reverse: false }); + + // Also register reverse so renderer can pick whichever edge exists + if (!map.has(reverseEdgeId)) map.set(reverseEdgeId, []); + map.get(reverseEdgeId)!.push({ progress: p.progress, color: p.color, reverse: true }); + } + return map; + } + + getNodeHits(): NodeHit[] { + return this.nodeHits; + } + + clear(): void { + this.particles = []; + this.nodeHits = []; + } + + get count(): number { + return this.particles.length; + } +} diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index 5b8ed7d..67add8f 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types"; +import type { Service, Connection, Stats, DockerEvent, LogLine, Flow, FlowSettings, WSMessage } from "../../shared/types"; function arraysEqual(a: T[], b: T[]): boolean { if (a.length !== b.length) return false; @@ -17,6 +17,11 @@ export function useDocker(token = "") { const [statsVersion, setStatsVersion] = useState(0); const [events, setEvents] = useState([]); const [logLines, setLogLines] = useState([]); + const [flows, setFlows] = useState([]); + const [flowSettings, setFlowSettings] = useState({ + particle_size: 5, trail: true, trail_opacity: 0.3, glow: true, max_particles: 50, + }); + const particleSpawnCallbacks = useRef void>>(new Set()); const [connected, setConnected] = useState(false); const wsRef = useRef(null); const reconnectTimer = useRef>(undefined); @@ -29,9 +34,14 @@ export function useDocker(token = "") { Promise.all([ fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []), fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []), - ]).then(([svcs, conns]) => { + fetch("/api/flows", { headers }).then((r) => r.ok ? r.json() : null), + ]).then(([svcs, conns, flowData]) => { setServices((prev) => prev.length === 0 ? svcs : prev); setConnections((prev) => prev.length === 0 ? conns : prev); + if (flowData?.flows) { + setFlows((prev) => prev.length === 0 ? flowData.flows : prev); + setFlowSettings(flowData.settings); + } }).catch(() => {}); }, [token]); @@ -94,6 +104,13 @@ export function useDocker(token = "") { return next.length > 2000 ? next.slice(-1500) : next; }); break; + case "flows": + setFlows(msg.data.flows); + setFlowSettings(msg.data.settings); + break; + case "particle_spawn": + for (const cb of particleSpawnCallbacks.current) cb(msg.data); + break; } } catch {} }; @@ -134,5 +151,10 @@ export function useDocker(token = "") { const clearLogLines = useCallback(() => setLogLines([]), []); - return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines }; + const onParticleSpawn = useCallback((cb: (data: { flowId: string; color: string; speed: number; path: string[] }) => void) => { + particleSpawnCallbacks.current.add(cb); + return () => { particleSpawnCallbacks.current.delete(cb); }; + }, []); + + return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn }; } diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 41e892f..3596870 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -101,6 +101,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { const s = stateStyles[d.state] || stateStyles.exited; const { Icon, color: iconColor } = guessIcon(d.image, d.label); const flashClass = d.flash || ""; + const particleGlow = (d as any).particleGlow || ""; const activeHandles = new Set((d as any).activeHandles || []); const highlighted = (d as any).highlighted; const hdot = (id: string) => { @@ -119,9 +120,13 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
`${p.host}:${p.container}`).join(", ") || "none"}`} className={`relative rounded-xl border border-slate-700/80 ${s.bg} backdrop-blur-sm - shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring} - transition-all duration-500 ${flashClass} + shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring} + transition-all duration-300 ${flashClass} ${d.state === "running" ? "node-pulse-running" : ""}`} + style={particleGlow ? { + boxShadow: `0 0 20px ${particleGlow}60, 0 0 40px ${particleGlow}30, inset 0 0 15px ${particleGlow}15`, + borderColor: particleGlow, + } : undefined} > {/* Top handles — left offset, transform centered horizontally */} {offsets.map((o, i) => ( diff --git a/src/client/panels/FlowPanel.tsx b/src/client/panels/FlowPanel.tsx new file mode 100644 index 0000000..fa8ce4f --- /dev/null +++ b/src/client/panels/FlowPanel.tsx @@ -0,0 +1,111 @@ +import { useState, useRef, useEffect } from "react"; +import { Play, Trash2, ChevronDown, Zap } from "lucide-react"; +import type { Flow, FlowSettings } from "../../shared/types"; +import type { ParticleEngine } from "../engine/particles"; +import type { Service } from "../../shared/types"; + +interface FlowPanelProps { + flows: Flow[]; + settings: FlowSettings; + engine: ParticleEngine; + services: Service[]; + onSimulate: (flow: Flow) => void; +} + +export function FlowPanel({ flows, settings, engine, services, onSimulate }: FlowPanelProps) { + const [open, setOpen] = useState(false); + const [particleCount, setParticleCount] = useState(0); + const panelRef = useRef(null); + + // Update particle count periodically + useEffect(() => { + const interval = setInterval(() => { + setParticleCount(engine.count); + }, 200); + return () => clearInterval(interval); + }, [engine]); + + // Close on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as HTMLElement)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + if (flows.length === 0) return null; + + return ( +
+ + + {open && ( +
+
+ + Simulaciones de Flujo + +
+ + {flows.map((flow) => ( + + ))} + +
+ + +
+
+ )} +
+ ); +} diff --git a/src/server/flows.ts b/src/server/flows.ts new file mode 100644 index 0000000..679c007 --- /dev/null +++ b/src/server/flows.ts @@ -0,0 +1,53 @@ +import fs from "fs"; +import path from "path"; +import yaml from "yaml"; +import type { Flow, FlowSettings } from "../shared/types"; + +const DEFAULT_SETTINGS: FlowSettings = { + particle_size: 5, + trail: true, + trail_opacity: 0.3, + glow: true, + max_particles: 50, +}; + +let flows: Flow[] = []; +let settings: FlowSettings = { ...DEFAULT_SETTINGS }; + +export function loadFlows(): void { + const filePath = path.join(process.cwd(), "flows.yaml"); + if (!fs.existsSync(filePath)) { + flows = []; + settings = { ...DEFAULT_SETTINGS }; + return; + } + + try { + const raw = yaml.parse(fs.readFileSync(filePath, "utf-8")); + if (raw?.flows) { + flows = Object.entries(raw.flows).map(([id, def]: [string, any]) => ({ + id, + name: def.name || id, + description: def.description || "", + color: def.color || "#3b82f6", + speed: def.speed ?? 0.8, + path: def.path || [], + })); + } + if (raw?.settings) { + settings = { ...DEFAULT_SETTINGS, ...raw.settings }; + } + } catch (err) { + console.error("Failed to parse flows.yaml:", err); + flows = []; + settings = { ...DEFAULT_SETTINGS }; + } +} + +export function getFlows(): Flow[] { + return flows; +} + +export function getSettings(): FlowSettings { + return settings; +} diff --git a/src/server/index.ts b/src/server/index.ts index 737212b..39a4355 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,6 +4,7 @@ import path from "path"; import fs from "fs"; import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker"; import { pollStats, watchDockerEvents } from "./watcher"; +import { loadFlows, getFlows, getSettings } from "./flows"; import type { WSMessage } from "../shared/types"; const app = new Hono(); @@ -50,6 +51,13 @@ app.get("/api/connections", async (c) => { app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS })); +// ── Flows ── +loadFlows(); + +app.get("/api/flows", (c) => { + return c.json({ flows: getFlows(), settings: getSettings() }); +}); + app.get("/api/logs/:id", async (c) => { const id = c.req.param("id"); const tail = parseInt(c.req.query("tail") || "200"); @@ -165,7 +173,13 @@ const server = Bun.serve({ }, websocket: { open(ws) { - clients.add(ws as unknown as WebSocket); + const native = ws as unknown as WebSocket; + clients.add(native); + // Send flows to new client + const flowsData = { flows: getFlows(), settings: getSettings() }; + if (flowsData.flows.length > 0) { + try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {} + } }, close(ws) { const native = ws as unknown as WebSocket; @@ -189,6 +203,14 @@ const server = Bun.serve({ logStreams.set(native, stream); } else if (msg.type === "unsubscribe_logs") { cleanupLogStream(native); + } else if (msg.type === "simulate_flow" && msg.flowId) { + const flow = getFlows().find((f) => f.id === msg.flowId); + if (flow) { + broadcast({ + type: "particle_spawn", + data: { flowId: flow.id, color: flow.color, speed: flow.speed, path: flow.path }, + }); + } } } catch {} }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 06b11e5..29e685c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,23 @@ export interface LogLine { stream: "stdout" | "stderr"; } +export interface Flow { + id: string; + name: string; + description?: string; + color: string; + speed: number; + path: string[]; +} + +export interface FlowSettings { + particle_size: number; + trail: boolean; + trail_opacity: number; + glow: boolean; + max_particles: number; +} + export type WSMessage = | { type: "services"; data: Service[] } | { type: "connections"; data: Connection[] } @@ -47,4 +64,7 @@ export type WSMessage = | { type: "docker_event"; data: DockerEvent } | { type: "subscribe_logs"; container: string } | { type: "unsubscribe_logs" } - | { type: "log_line"; data: LogLine }; + | { type: "log_line"; data: LogLine } + | { type: "flows"; data: { flows: Flow[]; settings: FlowSettings } } + | { type: "simulate_flow"; flowId: string } + | { type: "particle_spawn"; data: { flowId: string; color: string; speed: number; path: string[] } };