diff --git a/README.md b/README.md index 8810b96..0405313 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Variables disponibles: bun run dev ``` -Abre `http://localhost:5173` (Vite proxy → backend en puerto 9470). +Abre `http://localhost:9420` (Vite dev con hot reload, proxea API al backend en puerto 9470). ### Produccion @@ -204,4 +204,4 @@ Copyright (C) 2026 Jorge Gonzalez D. (RGJorge) Este proyecto esta licenciado bajo **GNU Affero General Public License v3.0** (AGPL-3.0). Ver el archivo [LICENSE](LICENSE) para los terminos completos. -Para uso comercial con codigo cerrado, contactar al autor para una licencia comercial. +Para uso comercial con codigo cerrado, contactar para una licencia comercial: alteonx.servicios@gmail.com diff --git a/mejoras.md b/mejoras.md new file mode 100644 index 0000000..737f815 --- /dev/null +++ b/mejoras.md @@ -0,0 +1,146 @@ +# Mejoras Frontend - Flowteon + +## Documentacion @xyflow/react 12 + +| Recurso | URL | +|---------|-----| +| **Docs principal** | https://reactflow.dev/learn | +| **API Reference** | https://reactflow.dev/api-reference | +| **Ejemplos** | https://reactflow.dev/examples | +| **UI Components** | https://reactflow.dev/ui | +| **Playground** | https://xyflow.com/labs/react-flow-playground | +| **GitHub** | https://github.com/xyflow/xyflow | +| **Migracion a v12** | https://reactflow.dev/learn/troubleshooting/migrate-to-v12 | +| **npm** | https://www.npmjs.com/package/@xyflow/react | + +### Secciones clave de la documentacion + +- **Learn > Core Concepts** — terms, building flows, interactivity, viewport, components +- **Learn > Customization** — custom nodes, handles, edges, labels, theming +- **Learn > Layouting** — overview, sub-flows +- **Learn > Advanced** — hooks, accessibility, testing, TypeScript, performance, state management +- **Reference > Hooks** — useNodes, useEdges, useReactFlow, etc. +- **Reference > Components** — Background, Controls, Handles, MiniMap, etc. +- **Reference > Utilities** — addEdge, applyNodeChanges, path functions + +--- + +## Ejemplos relevantes para Flowteon + +### Nodos + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Custom Nodes | https://reactflow.dev/examples/nodes/custom-node | Ya usamos ServiceNode y GroupNode custom | +| Node Toolbar | https://reactflow.dev/examples/nodes/node-toolbar | Agregar toolbar con acciones (restart, stop, logs) al seleccionar un container | +| Node Resizer | https://reactflow.dev/examples/nodes/node-resizer | Permitir redimensionar grupos manualmente | +| Updating Nodes | https://reactflow.dev/examples/nodes/update-node | Referencia para actualizar stats en tiempo real | +| Stress Test | https://reactflow.dev/examples/nodes/stress | Benchmark con muchos containers | +| Intersections | https://reactflow.dev/examples/nodes/intersections | Detectar overlaps entre nodos | + +### Edges + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Animating Edges | https://reactflow.dev/examples/edges/animating-edges | Mejorar animaciones de particulas en edges | +| Custom Edges | https://reactflow.dev/examples/edges/custom-edges | Ya usamos OffsetEdge custom | +| Edge Label Renderer | https://reactflow.dev/examples/edges/edge-label-renderer | Mostrar tipo de conexion (db, cache, proxy) como label en el edge | +| Floating Edges | https://reactflow.dev/examples/edges/floating-edges | Handles que se mueven con el edge en vez de posicion fija | +| Simple Floating Edges | https://reactflow.dev/examples/edges/simple-floating-edges | Alternativa mas simple — edges al handle mas cercano | +| Edge Markers | https://reactflow.dev/examples/edges/markers | Agregar flechas direccionales a las conexiones | +| Edge Toolbar | https://reactflow.dev/examples/edges/edge-toolbar | Mostrar info de conexion al hacer hover/click en un edge | + +### Interaccion + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Context Menu | https://reactflow.dev/examples/interaction/context-menu | Click derecho en container → restart, stop, logs, inspect | +| Contextual Zoom | https://reactflow.dev/examples/interaction/contextual-zoom | Mostrar mas detalle (ports, stats) solo cuando hay zoom suficiente | +| Save and Restore | https://reactflow.dev/examples/interaction/save-and-restore | Ya tenemos posiciones persistidas, pero podriamos guardar/cargar layouts completos | +| Validation | https://reactflow.dev/examples/interaction/validation | Validar conexiones manuales si agregamos esa funcionalidad | +| Touch Device | https://reactflow.dev/examples/interaction/touch-device | Soporte para tablets/touch | + +### Layout + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Dagre Tree | https://reactflow.dev/examples/layout/dagre | Ya tenemos dagre en package.json pero no lo usamos — alternativa a nuestro layout manual | +| Elkjs Tree | https://reactflow.dev/examples/layout/elkjs | Layout mas potente que dagre, reduce cruces de edges | +| Horizontal Flow | https://reactflow.dev/examples/layout/horizontal | Layout horizontal (izquierda a derecha) como alternativa | +| Node Collisions | https://reactflow.dev/examples/layout/node-collisions | Resolver overlaps automaticamente al mover nodos | + +### Subflows y Agrupacion + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Sub Flows | https://reactflow.dev/examples/grouping/sub-flows | Referencia para nuestros GroupNode — grafos anidados | + +### Estilos + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Dark Mode | https://reactflow.dev/examples/styling/dark-mode | Toggle dark/light mode | +| Tailwind | https://reactflow.dev/examples/styling/tailwind | Ya usamos Tailwind — referencia de estilos | +| Turbo Flow | https://reactflow.dev/examples/styling/turbo-flow | Nodos con bordes gradient animados — inspiracion visual | + +### Misc + +| Ejemplo | URL | Aplicacion en Flowteon | +|---------|-----|------------------------| +| Download Image | https://reactflow.dev/examples/misc/download-image | Exportar el diagrama como PNG para documentacion | + +--- + +## Mejoras identificadas + +### Criticas + +- [ ] **Error boundaries** — Un crash en un nodo tumba toda la app. Agregar React error boundary alrededor de ReactFlow +- [ ] **Empty state** — Si no hay containers, mostrar mensaje "No containers found" en vez de canvas vacio +- [ ] **Reconnection feedback** — Mostrar "Reconnecting..." visible cuando se pierde el WebSocket + +### UX + +- [ ] **Context menu** — Click derecho en container: restart, stop, view logs, copy ID +- [ ] **Node toolbar** — Al seleccionar un nodo, mostrar acciones rapidas arriba del nodo +- [ ] **Contextual zoom** — Mostrar ports y stats detallados solo con zoom > 0.8, ocultar en zoom out +- [ ] **Edge labels** — Mostrar tipo de conexion (Database, Cache, Proxy) como label en cada edge +- [ ] **Edge markers** — Flechas direccionales para indicar flujo de datos +- [ ] **Download image** — Boton para exportar diagrama como PNG +- [ ] **Search/filter** — Buscar containers por nombre dentro del canvas + +### Performance + +- [ ] **Virtualizacion de logs** — LogPanel renderiza 2000 lineas de golpe. Usar virtualizacion (react-window o similar) +- [ ] **Reconnexion exponencial** — Backoff exponencial en vez de intervalo fijo 3s +- [ ] **Validacion de mensajes WS** — Validar estructura de mensajes con Zod antes de procesar + +### Layout + +- [ ] **Auto-layout con dagre/elkjs** — Usar la dependencia dagre que ya tenemos o migrar a elkjs para layout automatico +- [ ] **Collision detection** — Resolver overlaps automaticamente al mover nodos +- [ ] **Layout horizontal** — Opcion de layout izquierda-derecha ademas del actual + +### Visual + +- [ ] **Transiciones de layout** — Animar nodos cuando aparecen/desaparecen +- [ ] **Turbo-style glow** — Bordes con gradient animado para containers running +- [ ] **Dark/light mode** — Toggle de tema (actualmente solo dark) + +### Accesibilidad + +- [ ] **ARIA labels** — Agregar labels a botones de solo icono +- [ ] **Keyboard navigation** — Navegar entre nodos con teclado +- [ ] **Touch support** — Handles mas grandes para dispositivos tactiles + +--- + +## Componentes UI de React Flow + +React Flow lanzo "React Flow UI" (antes "React Flow Components") — componentes pre-hechos instalables via shadcn CLI: + +- **Database Schema Node** — Visualizar tablas y relaciones +- **Zoom Slider** — Control de zoom mas visual que los botones default +- **Debug Components** — Inspeccionar propiedades de nodos y estado del flow + +Docs: https://reactflow.dev/ui diff --git a/src/client/App.tsx b/src/client/App.tsx index 9821731..cbd7bad 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -4,32 +4,27 @@ import { 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 { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout"; import { ParticleEngine } from "./engine/particles"; import { ParticleOverlay } from "./components/ParticleOverlay"; import { LogPanel } from "./panels/LogPanel"; -import { FlowPanel } from "./panels/FlowPanel"; +import { LoginScreen } from "./components/LoginScreen"; +import { OffsetEdge } from "./components/OffsetEdge"; +import { HeaderBar } from "./components/HeaderBar"; +import { EdgeLegend } from "./components/EdgeLegend"; 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 }; @@ -45,145 +40,10 @@ 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) { @@ -222,23 +82,26 @@ function Dashboard({ token }: { token: string }) { 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 + const NODE_W = NODE_WIDTH; + const NODE_H = NODE_HEIGHT; + const G_PAD = GROUP_PADDING; + const MIN_X = G_PAD; + const MIN_Y = GROUP_HEADER + G_PAD; + + // Fit view when log panel opens/closes 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 + // Load saved positions useEffect(() => { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -248,7 +111,7 @@ function Dashboard({ token }: { token: string }) { .catch(() => {}); }, [token]); - // Save positions to server (debounced) + // Save positions (debounced) const saveTimer = useRef>(undefined); const savePositions = useCallback((nodes: Node[]) => { clearTimeout(saveTimer.current); @@ -268,14 +131,6 @@ function Dashboard({ token }: { token: string }) { }, 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); @@ -288,7 +143,7 @@ function Dashboard({ token }: { token: string }) { let changed = false; let nodes = [...prev]; - // 1. Clamp Y only (prevent going above header), allow X freely + // Clamp Y only nodes = nodes.map((n) => { if (!n.parentId) return n; const clampedY = Math.max(MIN_Y, n.position.y); @@ -299,13 +154,13 @@ function Dashboard({ token }: { token: string }) { return n; }); - // 2. For each group, keep leftmost child at MIN_X — shift group + children to match + // Keep leftmost child at MIN_X 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 + const shift = minChildX - MIN_X; changed = true; nodes = nodes.map((n) => { if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } }; @@ -315,7 +170,7 @@ function Dashboard({ token }: { token: string }) { } } - // 3. Resize groups to fit children (grows AND shrinks) + // Resize groups to fit children nodes = nodes.map((n) => { if (!n.id.startsWith("group-")) return n; const kids = nodes.filter((c) => c.parentId === n.id); @@ -359,17 +214,6 @@ function Dashboard({ token }: { token: string }) { }); }; - // 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] @@ -394,13 +238,11 @@ function Dashboard({ token }: { token: string }) { 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); @@ -416,7 +258,6 @@ function Dashboard({ token }: { token: string }) { 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") { @@ -428,7 +269,6 @@ function Dashboard({ token }: { token: string }) { 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; @@ -440,7 +280,6 @@ function Dashboard({ token }: { token: string }) { 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) => { @@ -448,7 +287,6 @@ function Dashboard({ token }: { token: string }) { 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); @@ -467,7 +305,6 @@ function Dashboard({ token }: { token: string }) { 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") { @@ -476,7 +313,6 @@ function Dashboard({ token }: { token: string }) { } } - // 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; @@ -492,18 +328,16 @@ function Dashboard({ token }: { token: string }) { } }, [filteredServices, filteredConnections, statsVersion]); - // Recompute edges + handles whenever nodes move + // Recompute edges + handles when 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 } }; }) @@ -515,13 +349,9 @@ function Dashboard({ token }: { token: string }) { 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" - : ""; + latest.action === "start" ? "flash-start" : + latest.action === "die" || latest.action === "stop" ? "flash-stop" : + latest.action === "restart" ? "flash-restart" : ""; if (!flashClass) return; @@ -540,12 +370,10 @@ function Dashboard({ token }: { token: string }) { }, 1200); }, [events]); - // Service name → UID map for flow path resolution + // Flow path resolution const serviceNameToUid = useMemo(() => { const map = new Map(); - for (const s of services) { - map.set(s.name, s.uid); - } + for (const s of services) map.set(s.name, s.uid); return map; }, [services]); @@ -556,11 +384,9 @@ function Dashboard({ token }: { token: string }) { 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); @@ -568,28 +394,23 @@ function Dashboard({ token }: { token: string }) { }); }, [onParticleSpawn, resolveFlowPath, engine]); - // Handle particle node hits — flash node with particle color + // Particle node hits 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 + 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 + n.id === hit.nodeId ? { ...n, data: { ...n.data, particleGlow: "" } } : n ) ); nodeHitTimers.current.delete(hit.nodeId); @@ -598,24 +419,19 @@ function Dashboard({ token }: { token: string }) { } }, [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; - } + 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 + // Edge/node highlighting const connectedNodeIds = useMemo(() => { if (!selectedNode) return null; const ids = new Set([selectedNode]); @@ -632,11 +448,7 @@ function Dashboard({ token }: { token: string }) { const isConnected = e.source === selectedNode || e.target === selectedNode; return { ...e, - style: { - ...e.style, - opacity: isConnected ? 1 : 0.08, - strokeWidth: isConnected ? 2.5 : 1, - }, + style: { ...e.style, opacity: isConnected ? 1 : 0.08, strokeWidth: isConnected ? 2.5 : 1 }, }; }); }, [edges, selectedNode]); @@ -657,125 +469,20 @@ function Dashboard({ token }: { token: string }) { 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 */}
@@ -808,24 +515,7 @@ function Dashboard({ token }: { token: string }) { - - {/* 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} -
- ))} -
- + { diff --git a/src/client/components/EdgeLegend.tsx b/src/client/components/EdgeLegend.tsx new file mode 100644 index 0000000..3725add --- /dev/null +++ b/src/client/components/EdgeLegend.tsx @@ -0,0 +1,23 @@ +import { Database, Zap, Radio, Globe } from "lucide-react"; + +const LEGEND_ITEMS = [ + { icon: Database, color: "#336791", label: "Database" }, + { icon: Zap, color: "#F59E0B", label: "Cache" }, + { icon: Radio, color: "#A855F7", label: "Broker" }, + { icon: Globe, color: "#22C55E", label: "Proxy" }, +] as const; + +export function EdgeLegend() { + return ( +
+ Conexiones + {LEGEND_ITEMS.map(({ icon: Icon, color, label }) => ( +
+
+ + {label} +
+ ))} +
+ ); +} diff --git a/src/client/components/HeaderBar.tsx b/src/client/components/HeaderBar.tsx new file mode 100644 index 0000000..470dc7d --- /dev/null +++ b/src/client/components/HeaderBar.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from "react"; +import { Wifi, WifiOff, ChevronDown, Check, LogOut, Cpu, MemoryStick } from "lucide-react"; +import type { Service, Flow, FlowSettings } from "../../shared/types"; +import type { ParticleEngine } from "../engine/particles"; +import { FlowPanel } from "../panels/FlowPanel"; + +interface HeaderBarProps { + services: Service[]; + filteredServices: Service[]; + connected: boolean; + token: string; + projects: string[]; + hiddenProjects: Set; + onToggleProject: (project: string) => void; + totalStats: { cpu: number; mem: number }; + flows: Flow[]; + flowSettings: FlowSettings; + engine: ParticleEngine; + onSimulate: (flow: Flow) => void; +} + +export function HeaderBar({ + services, + filteredServices, + connected, + token, + projects, + hiddenProjects, + onToggleProject, + totalStats, + flows, + flowSettings, + engine, + onSimulate, +}: HeaderBarProps) { + const [filterOpen, setFilterOpen] = useState(false); + const filterRef = useRef(null); + + const runningCount = filteredServices.filter((s) => s.state === "running").length; + + 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); + }, []); + + return ( +
+
+
+ Flowteon + + Flowteon + + + AlteonX + +
+ + v0.0.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 && ( + + )} +
+
+ ); +} diff --git a/src/client/components/LoginScreen.tsx b/src/client/components/LoginScreen.tsx new file mode 100644 index 0000000..6443e10 --- /dev/null +++ b/src/client/components/LoginScreen.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { Lock, Eye, EyeOff, Terminal } from "lucide-react"; + +interface LoginScreenProps { + onAuth: (token: string) => void; +} + +export function LoginScreen({ onAuth }: LoginScreenProps) { + 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([ + "$ flowteon 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 */} + Flowteon +
+

Flowteon

+ 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 && ( + + )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/client/components/OffsetEdge.tsx b/src/client/components/OffsetEdge.tsx new file mode 100644 index 0000000..d11315b --- /dev/null +++ b/src/client/components/OffsetEdge.tsx @@ -0,0 +1,6 @@ +import { SmoothStepEdge, type EdgeProps } from "@xyflow/react"; + +export function OffsetEdge(props: EdgeProps) { + const offset = (props.data as any)?.offset ?? 0; + return ; +} diff --git a/src/client/engine/layout.ts b/src/client/engine/layout.ts index 130aac1..3ec00c8 100644 --- a/src/client/engine/layout.ts +++ b/src/client/engine/layout.ts @@ -1,12 +1,12 @@ import type { Node, Edge } from "@xyflow/react"; import type { Service, Connection, Stats } from "../../shared/types"; -const NODE_WIDTH = 240; -const NODE_HEIGHT = 160; +export const NODE_WIDTH = 240; +export const NODE_HEIGHT = 160; const NODE_GAP_X = 36; const NODE_GAP_Y = 36; -const GROUP_PADDING = 28; -const GROUP_HEADER = 44; +export const GROUP_PADDING = 28; +export const GROUP_HEADER = 44; const GROUP_GAP = 50; function getComposeKey(file: string): string { diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index 67add8f..b79575b 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -1,11 +1,11 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { Service, Connection, Stats, DockerEvent, LogLine, Flow, FlowSettings, WSMessage } from "../../shared/types"; -function arraysEqual(a: T[], b: T[]): boolean { +function arraysEqual(a: Service[], b: Service[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { - if ((a[i] as any).uid !== (b[i] as any).uid) return false; - if ((a[i] as any).state !== (b[i] as any).state) return false; + if (a[i].uid !== b[i].uid) return false; + if (a[i].state !== b[i].state) return false; } return true; } @@ -53,24 +53,38 @@ export function useDocker(token = "") { } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const params = token ? `?token=${encodeURIComponent(token)}` : ""; - const wsUrl = `${protocol}//${window.location.host}/ws${params}`; + const wsUrl = `${protocol}//${window.location.host}/ws`; const ws = new WebSocket(wsUrl); wsRef.current = ws; - ws.onopen = () => setConnected(true); + ws.onopen = () => { + if (token) { + ws.send(JSON.stringify({ type: "auth", token })); + } else { + setConnected(true); + } + }; ws.onclose = () => { setConnected(false); - reconnectTimer.current = setTimeout(connect, 3000); + reconnectTimer.current = setTimeout(connect, 3_000); }; ws.onerror = () => ws.close(); ws.onmessage = (e) => { try { - const msg: WSMessage = JSON.parse(e.data); + const msg = JSON.parse(e.data); - switch (msg.type) { + if (msg.type === "auth_ok") { + setConnected(true); + return; + } + if (msg.type === "auth_error") { + ws.close(); + return; + } + + switch (msg.type as WSMessage["type"]) { case "services": setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data); break; @@ -112,7 +126,9 @@ export function useDocker(token = "") { for (const cb of particleSpawnCallbacks.current) cb(msg.data); break; } - } catch {} + } catch (err) { + console.error("Failed to parse WS message:", err); + } }; }, [token]); diff --git a/src/client/index.html b/src/client/index.html index 8edb701..caf5aff 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -3,7 +3,7 @@ - DockerFlow AlteonX + Flowteon diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 0c47bc9..92c4e6a 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -32,6 +32,10 @@ interface ServiceNodeData { project: string; stats: Stats | null; flash?: string; + id?: string; + particleGlow?: string; + activeHandles?: string[]; + highlighted?: boolean; [key: string]: unknown; } @@ -101,9 +105,9 @@ 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 particleGlow = d.particleGlow || ""; + const activeHandles = new Set(d.activeHandles || []); + const highlighted = d.highlighted; const hdot = (id: string) => { if (activeHandles.has(id)) { return highlighted @@ -118,7 +122,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { return (
`${p.host}:${p.container}`).join(", ") || "none"}`} + title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${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 ${particleGlow ? "" : s.ring} transition-all duration-300 ${flashClass}`} diff --git a/src/server/docker.ts b/src/server/docker.ts index b63108d..c6b91c0 100644 --- a/src/server/docker.ts +++ b/src/server/docker.ts @@ -1,7 +1,16 @@ import Docker from "dockerode"; +import fs from "fs"; import type { Service, Connection, LogLine } from "../shared/types"; -const docker = new Docker({ socketPath: "/var/run/docker.sock" }); +const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock"; + +if (!fs.existsSync(DOCKER_SOCKET)) { + console.error(`\n Error: Docker socket not found at ${DOCKER_SOCKET}`); + console.error(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`); + process.exit(1); +} + +const docker = new Docker({ socketPath: DOCKER_SOCKET }); export { docker }; @@ -191,6 +200,10 @@ export function streamContainerLogs( let stream: NodeJS.ReadableStream | null = null; let destroyed = false; + const destroyStream = (s: unknown) => { + if (s && typeof (s as any).destroy === "function") (s as any).destroy(); + }; + container.logs({ stdout: true, stderr: true, @@ -198,16 +211,19 @@ export function streamContainerLogs( since: Math.floor(Date.now() / 1000), timestamps: true, }).then((s) => { + stream = s as unknown as NodeJS.ReadableStream; + if (destroyed) { - if (s && typeof (s as any).destroy === "function") (s as any).destroy(); + destroyStream(stream); + stream = null; return; } - stream = s as unknown as NodeJS.ReadableStream; // Docker multiplexed stream parsing for follow mode let buffer = Buffer.alloc(0); stream.on("data", (chunk: Buffer) => { + if (destroyed) return; buffer = Buffer.concat([buffer, chunk]); while (buffer.length >= 8) { @@ -232,13 +248,16 @@ export function streamContainerLogs( }); } }); - }).catch(() => {}); + }).catch((err) => { + console.error(`Failed to stream logs for ${id}:`, err); + }); return { destroy() { destroyed = true; - if (stream && typeof (stream as any).destroy === "function") { - (stream as any).destroy(); + if (stream) { + destroyStream(stream); + stream = null; } }, }; diff --git a/src/server/index.ts b/src/server/index.ts index 39a4355..20dc198 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; +import { cors } from "hono/cors"; import path from "path"; import fs from "fs"; import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker"; @@ -9,6 +10,9 @@ import type { WSMessage } from "../shared/types"; const app = new Hono(); +// ── CORS ── +app.use("/api/*", cors()); + // ── CLI args ── const args = process.argv.slice(2); const ALL = args.includes("--all"); @@ -23,6 +27,8 @@ const PROJECTS = projectsFlag const PORT = parseInt(process.env.PORT || "9470"); const AUTH_TOKEN = process.env.AUTH_TOKEN || ""; const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1"; +const POLL_INTERVAL_MS = 5000; +const WS_RECONNECT_MS = 3000; // ── Auth middleware ── if (AUTH_TOKEN) { @@ -60,7 +66,10 @@ app.get("/api/flows", (c) => { app.get("/api/logs/:id", async (c) => { const id = c.req.param("id"); - const tail = parseInt(c.req.query("tail") || "200"); + if (!/^[a-f0-9]{12,64}$/.test(id)) { + return c.json({ error: "Invalid container ID" }, 400); + } + const tail = Math.min(Math.max(parseInt(c.req.query("tail") || "200") || 200, 1), 5000); try { const lines = await getContainerLogs(id, tail); return c.json(lines); @@ -78,7 +87,9 @@ app.get("/api/positions", (c) => { const data = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8")); return c.json(data); } - } catch {} + } catch (err) { + console.error("Failed to read positions file:", err); + } return c.json({}); }); @@ -98,11 +109,17 @@ app.get("/*", serveStatic({ root: "./dist", path: "index.html" })); // ── WebSocket ── const clients = new Set(); +const authenticatedClients = new Set(); const logStreams = new Map void }>(); +function isAuthenticated(ws: WebSocket): boolean { + return !AUTH_TOKEN || authenticatedClients.has(ws); +} + function broadcast(msg: WSMessage) { const data = JSON.stringify(msg); for (const ws of clients) { + if (!isAuthenticated(ws)) continue; try { ws.send(data); } catch {} @@ -112,8 +129,12 @@ function broadcast(msg: WSMessage) { function cleanupLogStream(ws: WebSocket) { const stream = logStreams.get(ws); if (stream) { - stream.destroy(); logStreams.delete(ws); + try { + stream.destroy(); + } catch (err) { + console.error("Failed to destroy log stream:", err); + } } } @@ -150,7 +171,7 @@ setInterval(async () => { } catch (err) { console.error("Poll error:", err); } -}, 5000); +}, POLL_INTERVAL_MS); // ── Start ── const server = Bun.serve({ @@ -159,12 +180,8 @@ const server = Bun.serve({ fetch(req, server) { const url = new URL(req.url); - // WebSocket upgrade + // WebSocket upgrade (auth handled via first message) if (url.pathname === "/ws") { - const token = url.searchParams.get("token") || ""; - if (AUTH_TOKEN && token !== AUTH_TOKEN) { - return new Response("Unauthorized", { status: 401 }); - } if (server.upgrade(req)) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } @@ -175,24 +192,47 @@ const server = Bun.serve({ open(ws) { 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 {} + + if (!AUTH_TOKEN) { + // No auth required — send data immediately + 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; cleanupLogStream(native); clients.delete(native); + authenticatedClients.delete(native); }, message(ws, message) { try { const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer)); const native = ws as unknown as WebSocket; + // Handle authentication via first message + if (msg.type === "auth") { + if (msg.token === AUTH_TOKEN) { + authenticatedClients.add(native); + native.send(JSON.stringify({ type: "auth_ok" })); + // Send initial data after auth + const flowsData = { flows: getFlows(), settings: getSettings() }; + if (flowsData.flows.length > 0) { + native.send(JSON.stringify({ type: "flows", data: flowsData })); + } + } else { + native.send(JSON.stringify({ type: "auth_error" })); + native.close(); + } + return; + } + + // Reject messages from unauthenticated clients + if (!isAuthenticated(native)) return; + if (msg.type === "subscribe_logs" && msg.container) { - // Clean up any existing stream first cleanupLogStream(native); const stream = streamContainerLogs(msg.container, (line) => { @@ -212,13 +252,15 @@ const server = Bun.serve({ }); } } - } catch {} + } catch (err) { + console.error("Failed to handle WS message:", err); + } }, }, }); const mode = ALL ? "all projects" : `project(s): ${PROJECTS.join(", ")}`; -console.log(`\n Alteonx DockerFlow`); +console.log(`\n Flowteon`); console.log(` → http://${HOST}:${PORT}`); console.log(` → Mode: ${mode}`); console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`); diff --git a/src/server/watcher.ts b/src/server/watcher.ts index 6212108..5ac21ee 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -44,28 +44,41 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) { return; } + let buffer = ""; + stream.on("data", (chunk: Buffer) => { - try { - const event = JSON.parse(chunk.toString()); - if (event.Type !== "container") return; + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // keep incomplete last line in buffer - const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status" - if (!["start", "stop", "die", "restart", "health_status"].includes(action)) return; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; - const svcName = - event.Actor?.Attributes?.["com.docker.compose.service"] || - event.Actor?.Attributes?.name || - "unknown"; - const svcProject = - event.Actor?.Attributes?.["com.docker.compose.project"] || - "standalone"; - onEvent({ - type: "docker", - action, - service: `${svcProject}/${svcName}`, - time: event.time || Date.now() / 1000, - }); - } catch {} + try { + const event = JSON.parse(trimmed); + if (event.Type !== "container") continue; + + const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status" + if (!["start", "stop", "die", "restart", "health_status"].includes(action)) continue; + + const svcName = + event.Actor?.Attributes?.["com.docker.compose.service"] || + event.Actor?.Attributes?.name || + "unknown"; + const svcProject = + event.Actor?.Attributes?.["com.docker.compose.project"] || + "standalone"; + onEvent({ + type: "docker", + action, + service: `${svcProject}/${svcName}`, + time: event.time || Date.now() / 1000, + }); + } catch { + // Ignore malformed lines + } + } }); }); } diff --git a/vite.config.ts b/vite.config.ts index a4b50d7..37f9315 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ emptyOutDir: true, }, server: { - port: 5174, + port: 9420, + host: "0.0.0.0", proxy: { "/api": "http://localhost:9470", "/ws": {