import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { ReactFlow, Background, Controls, MiniMap, SmoothStepEdge, useNodesState, useEdgesState, type Node, type Edge, type EdgeProps, type NodeChange, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { Wifi, WifiOff, ChevronDown, Check, Lock, LogOut, Eye, EyeOff, Terminal, Database, Zap, Radio, Globe, Cpu, MemoryStick } from "lucide-react"; 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 { FlowPanel } from "./panels/FlowPanel"; import type { Service, Flow } from "../shared/types"; function OffsetEdge(props: EdgeProps) { const offset = (props.data as any)?.offset ?? 0; return ; } const nodeTypes = { service: ServiceNode, group: GroupNode }; const edgeTypes = { offsetSmooth: OffsetEdge }; function loadFilter(): Set { try { const raw = localStorage.getItem("df:filter"); if (raw) return new Set(JSON.parse(raw)); } catch {} return new Set(); } function getToken(): string { return localStorage.getItem("df:token") || ""; } function LoginScreen({ onAuth }: { onAuth: (token: string) => void }) { const [token, setToken] = useState(""); const [error, setError] = useState(""); const [showToken, setShowToken] = useState(false); const [connecting, setConnecting] = useState(false); const [connected, setConnected] = useState(false); const [logLines, setLogLines] = useState([]); const hackerLog = (lines: string[], onDone: () => void) => { lines.forEach((line, i) => { setTimeout(() => { setLogLines((prev) => [...prev, line]); if (i === lines.length - 1) setTimeout(onDone, 400); }, i * 180); }); }; const submit = async (e: React.FormEvent) => { e.preventDefault(); if (connecting) return; setConnecting(true); setError(""); setLogLines([]); hackerLog([ "$ dockerflow connect --auth", "> Establishing secure connection...", "> Validating AUTH_TOKEN...", ], async () => { try { const res = await fetch("/api/health", { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { hackerLog([ "> Token accepted", "> Loading Docker socket...", "> Connection established!", ], () => { localStorage.setItem("df:token", token); setConnected(true); setTimeout(() => onAuth(token), 800); }); } else { hackerLog(["> ERROR: Invalid token", "> Connection refused"], () => { setError("Token invalido"); setConnecting(false); }); } } catch { hackerLog(["> ERROR: Connection failed"], () => { setError("No se pudo conectar"); setConnecting(false); }); } }); }; return (
{/* Logo + Title */} Alteonx

DockerFlow

AlteonX
{/* Form */}
{ setToken(e.target.value); setError(""); }} placeholder="AUTH_TOKEN" className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-sm text-white font-mono placeholder:text-slate-600 focus:outline-none focus:border-cyan-500 transition-colors" autoFocus disabled={connecting} />
{error && {error}}
{/* Terminal log */} {logLines.length > 0 && (
{logLines.map((line, i) => (
{line} {i === logLines.length - 1 && !connected && ( )}
))}
)}
); } export default function App() { const [authToken, setAuthToken] = useState(null); const [needsAuth, setNeedsAuth] = useState(null); // Check if auth is required useEffect(() => { fetch("/api/health").then((r) => { if (r.ok) { setNeedsAuth(false); setAuthToken(""); } else if (r.status === 401) { const saved = getToken(); if (saved) { fetch("/api/health", { headers: { Authorization: `Bearer ${saved}` } }).then((r2) => { if (r2.ok) { setAuthToken(saved); setNeedsAuth(false); } else { localStorage.removeItem("df:token"); setNeedsAuth(true); } }); } else { setNeedsAuth(true); } } }).catch(() => setNeedsAuth(false)); }, []); if (needsAuth === null) return
; if (needsAuth) return { setAuthToken(t); setNeedsAuth(false); }} />; return ; } function Dashboard({ token }: { token: string }) { 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); const savedPositions = useRef>({}); const [hiddenProjects, setHiddenProjects] = useState>(loadFilter); const [filterOpen, setFilterOpen] = useState(false); const filterRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [logPanelService, setLogPanelService] = useState(null); const reactFlowRef = useRef(null); // Fit view when log panel opens/closes so graph adjusts to available space useEffect(() => { if (reactFlowRef.current) { // Small delay to let the DOM resize first setTimeout(() => { reactFlowRef.current?.fitView({ padding: 0.3, duration: 300 }); }, 50); } }, [logPanelService]); // Load saved positions from server on mount useEffect(() => { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; fetch("/api/positions", { headers }) .then((r) => r.json()) .then((data) => { savedPositions.current = data || {}; }) .catch(() => {}); }, [token]); // Save positions to server (debounced) const saveTimer = useRef>(undefined); const savePositions = useCallback((nodes: Node[]) => { clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { const positions: Record = {}; for (const n of nodes) { positions[n.id] = { x: n.position.x, y: n.position.y }; } savedPositions.current = positions; const headers: Record = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; fetch("/api/positions", { method: "PUT", headers, body: JSON.stringify(positions), }).catch(() => {}); }, 500); }, [token]); // Resize groups and clamp child positions const NODE_W = 240; const NODE_H = 160; const G_PAD = 28; const G_HEADER = 44; const MIN_X = G_PAD; const MIN_Y = G_HEADER + G_PAD; const handleNodesChange = useCallback((changes: NodeChange[]) => { onNodesChange(changes); const hasPositionChange = changes.some((c) => c.type === "position"); if (!hasPositionChange) return; const isDragEnd = changes.some((c) => c.type === "position" && (c as any).dragging === false); setNodes((prev) => { let changed = false; let nodes = [...prev]; // 1. Clamp Y only (prevent going above header), allow X freely nodes = nodes.map((n) => { if (!n.parentId) return n; const clampedY = Math.max(MIN_Y, n.position.y); if (clampedY !== n.position.y) { changed = true; return { ...n, position: { x: n.position.x, y: clampedY } }; } return n; }); // 2. For each group, keep leftmost child at MIN_X — shift group + children to match const groupIds = [...new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!))]; for (const gid of groupIds) { const kids = nodes.filter((n) => n.parentId === gid); const minChildX = Math.min(...kids.map((k) => k.position.x)); if (minChildX !== MIN_X) { const shift = minChildX - MIN_X; // positive = children too far right, negative = too far left changed = true; nodes = nodes.map((n) => { if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } }; if (n.parentId === gid) return { ...n, position: { x: n.position.x - shift, y: n.position.y } }; return n; }); } } // 3. Resize groups to fit children (grows AND shrinks) nodes = nodes.map((n) => { if (!n.id.startsWith("group-")) return n; const kids = nodes.filter((c) => c.parentId === n.id); if (kids.length === 0) return n; let maxRight = 0; let maxBottom = 0; for (const k of kids) { maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); } const minW = NODE_W + G_PAD * 3; const newW = Math.max(maxRight, minW); const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); const curW = (n.style?.width as number) || 0; const curH = (n.style?.height as number) || 0; if (newW !== curW || newH !== curH) { changed = true; return { ...n, style: { ...n.style, width: newW, height: newH } }; } return n; }); if (isDragEnd) savePositions(nodes); return changed ? nodes : prev; }); }, [onNodesChange, setNodes, savePositions]); const projects = useMemo(() => [...new Set(services.map((s) => s.project))].sort(), [services]); const toggleProject = (p: string) => { setHiddenProjects((prev) => { const next = new Set(prev); if (next.has(p)) next.delete(p); else next.add(p); localStorage.setItem("df:filter", JSON.stringify([...next])); return next; }); }; // Close dropdown on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (filterRef.current && !filterRef.current.contains(e.target as HTMLElement)) { setFilterOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const filteredServices = useMemo( () => services.filter((s) => !hiddenProjects.has(s.project)), [services, hiddenProjects] ); const filteredConnections = useMemo( () => { const uids = new Set(filteredServices.map((s) => s.uid)); return connections.filter((c) => uids.has(c.from) && uids.has(c.to)); }, [connections, filteredServices] ); // Build layout when data changes useEffect(() => { if (filteredServices.length === 0) { setNodes([]); setEdges([]); return; } const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats); if (!initialLayoutDone.current) { // Apply saved positions to all nodes (groups + services) let positioned = newNodes.map((n) => { const saved = savedPositions.current[n.id]; if (saved) return { ...n, position: saved }; return n; }); // Recalculate group sizes based on actual child positions positioned = positioned.map((n) => { if (n.type !== "group") return n; const kids = positioned.filter((c) => c.parentId === n.id); if (kids.length === 0) return n; let maxRight = 0; let maxBottom = 0; for (const k of kids) { maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); } const minW = NODE_W + G_PAD * 3; const newW = Math.max(maxRight, minW); const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); return { ...n, style: { ...n.style, width: newW, height: newH } }; }); // Compute edges + activeHandles based on positioned nodes const { edges, activeHandles } = computeEdges(positioned, filteredConnections); for (const n of positioned) { if (n.type === "service") { (n.data as any).activeHandles = activeHandles.get(n.id) || []; } } setNodes(positioned); setEdges(edges); initialLayoutDone.current = true; } else { setNodes((prev) => { // Keep existing nodes, update data only const updated = prev.map((n) => { const u = newNodes.find((nn) => nn.id === n.id); if (!u) return null; return { ...n, data: u.data }; }).filter(Boolean) as Node[]; const existingIds = new Set(updated.map((n) => n.id)); const brand = newNodes.filter((n) => !existingIds.has(n.id)); if (brand.length === 0) return updated; // Apply saved positions to brand-new nodes (e.g. re-enabled project filter) const hasSaved = brand.some((n) => savedPositions.current[n.id]); if (hasSaved) { let positioned = brand.map((n) => { const saved = savedPositions.current[n.id]; if (saved) return { ...n, position: saved }; return n; }); // Recalculate group sizes for restored nodes positioned = positioned.map((n) => { if (n.type !== "group") return n; const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id); if (kids.length === 0) return n; let maxRight = 0; let maxBottom = 0; for (const k of kids) { maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); } const minW = NODE_W + G_PAD * 3; const newW = Math.max(maxRight, minW); const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); return { ...n, style: { ...n.style, width: newW, height: newH } }; }); return [...updated, ...positioned]; } // Find rightmost edge of existing groups to place new ones after let maxRightX = 0; for (const n of updated) { if (n.type === "group") { const w = (n.style?.width as number) || NODE_W + G_PAD * 3; maxRightX = Math.max(maxRightX, n.position.x + w); } } // Offset new groups so they appear to the right const newGroups = brand.filter((n) => n.type === "group"); const offsetX = maxRightX > 0 ? maxRightX + 50 - (newGroups[0]?.position.x || 0) : 0; const positioned = brand.map((n) => { if (n.type === "group" && offsetX > 0) { return { ...n, position: { x: n.position.x + offsetX, y: n.position.y } }; } return n; }); return [...updated, ...positioned]; }); } }, [filteredServices, filteredConnections, statsVersion]); // Recompute edges + handles whenever nodes move useEffect(() => { if (nodes.length === 0 || filteredConnections.length === 0) return; const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections); setEdges(newEdges); // Update activeHandles on nodes setNodes((prev) => prev.map((n) => { if (n.type !== "service") return n; const handles = activeHandles.get(n.id) || []; const current = (n.data as any).activeHandles || []; // Skip if unchanged if (handles.length === current.length && handles.every((h: string, i: number) => h === current[i])) return n; return { ...n, data: { ...n.data, activeHandles: handles } }; }) ); }, [nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","), filteredConnections]); // Flash nodes on Docker events useEffect(() => { if (events.length === 0) return; const latest = events[events.length - 1]!; const flashClass = latest.action === "start" ? "flash-start" : latest.action === "die" || latest.action === "stop" ? "flash-stop" : latest.action === "restart" ? "flash-restart" : ""; if (!flashClass) return; setNodes((prev) => prev.map((n) => n.id === latest.service ? { ...n, data: { ...n.data, flash: flashClass } } : n ) ); setTimeout(() => { setNodes((prev) => prev.map((n) => n.id === latest.service ? { ...n, data: { ...n.data, flash: "" } } : n ) ); }, 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; // Total resource consumption const totalStats = useMemo(() => { let cpu = 0; let mem = 0; for (const svc of filteredServices) { const s = stats.get(svc.uid); if (s) { cpu += s.cpu; mem += s.mem_mb; } } return { cpu, mem }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredServices, statsVersion]); // Highlight edges connected to selected node, dim the rest const connectedNodeIds = useMemo(() => { if (!selectedNode) return null; const ids = new Set([selectedNode]); for (const e of edges) { if (e.source === selectedNode) ids.add(e.target); if (e.target === selectedNode) ids.add(e.source); } return ids; }, [selectedNode, edges]); const styledEdges = useMemo(() => { if (!selectedNode) return edges; return edges.map((e) => { const isConnected = e.source === selectedNode || e.target === selectedNode; return { ...e, style: { ...e.style, opacity: isConnected ? 1 : 0.08, strokeWidth: isConnected ? 2.5 : 1, }, }; }); }, [edges, selectedNode]); const styledNodes = useMemo(() => { if (!connectedNodeIds) return nodes; return nodes.map((n) => { if (n.type !== "service") return n; const isConnected = connectedNodeIds.has(n.id); const isSelected = n.id === selectedNode; return { ...n, style: { ...n.style, opacity: isConnected ? 1 : 0.3 }, data: { ...n.data, highlighted: isSelected || isConnected }, }; }); }, [nodes, connectedNodeIds, selectedNode]); return (
{/* Header */}
Alteonx DockerFlow AlteonX
v0.1 {/* Total resource usage */} {totalStats.cpu > 0 && (
{totalStats.cpu.toFixed(1)}%
{totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`}
)}
{/* Flow panel */} {/* Project filter dropdown */} {projects.length > 1 && (
{filterOpen && (
{projects.map((p) => { const active = !hiddenProjects.has(p); const count = services.filter((s) => s.project === p).length; return ( ); })}
)}
)} {/* Stats */} {runningCount} /{filteredServices.length} containers {/* Connection status */}
{connected ? ( ) : ( )} {connected ? "Live" : "Offline"}
{/* Logout (only if auth is active) */} {token && ( )}
{/* Canvas */}
{ reactFlowRef.current = instance; }} nodes={styledNodes} edges={styledEdges} onNodesChange={handleNodesChange} onEdgesChange={onEdgesChange} onNodeClick={(_e, node) => { if (node.type === "service") { setSelectedNode(node.id); const svc = filteredServices.find((s) => s.uid === node.id); if (svc) setLogPanelService(svc); } else { setSelectedNode(null); } }} onNodeDragStop={() => setSelectedNode(null)} onPaneClick={() => { setSelectedNode(null); setLogPanelService(null); }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} fitView fitViewOptions={{ padding: 0.3 }} minZoom={0.2} maxZoom={2.5} panOnScroll={true} proOptions={{ hideAttribution: true }} > {/* Edge legend */}
Conexiones {[ { icon: Database, color: "#336791", label: "Database" }, { icon: Zap, color: "#F59E0B", label: "Cache" }, { icon: Radio, color: "#A855F7", label: "Broker" }, { icon: Globe, color: "#22C55E", label: "Proxy" }, ].map(({ icon: Icon, color, label }) => (
{label}
))}
{ const state = (n.data as any)?.state; if (state === "running") return "#22c55e"; if (state === "exited" || state === "dead") return "#ef4444"; return "#f59e0b"; }} style={{ background: "#0f172a" }} />
{logPanelService && ( { setLogPanelService(null); setSelectedNode(null); }} sendMessage={sendMessage} clearLogLines={clearLogLines} /> )}
); }