This commit is contained in:
RGJorge
2026-04-30 04:05:03 +00:00
parent 0aa33957c3
commit cb25260627
15 changed files with 689 additions and 418 deletions
+2 -2
View File
@@ -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
+146
View File
@@ -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
+45 -355
View File
@@ -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 <SmoothStepEdge {...props} pathOptions={{ offset, borderRadius: 8 }} />;
}
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<string[]>([]);
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 (
<div className={`h-screen w-screen bg-slate-950 flex items-center justify-center transition-opacity duration-700 ${connected ? "opacity-0" : "opacity-100"}`}>
<div className="flex flex-col items-center gap-6 w-80">
{/* Logo + Title */}
<img
src="/alteonx-logo.png"
alt="Alteonx"
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<div className="text-center">
<h1 className="text-2xl font-bold text-white tracking-wide">DockerFlow</h1>
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
</div>
{/* Form */}
<form onSubmit={submit} className={`flex flex-col gap-3 w-full transition-opacity duration-300 ${connecting ? "opacity-50 pointer-events-none" : ""}`}>
<div className="relative">
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => { 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}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{error && <span className="text-red-400 text-xs font-mono">{error}</span>}
<button
type="submit"
disabled={connecting || !token}
className={`w-full flex items-center justify-center gap-2 text-sm font-medium py-2.5 rounded-lg transition-all duration-300 ${
connecting
? "bg-slate-800 text-slate-500 cursor-wait"
: "bg-cyan-600 hover:bg-cyan-500 text-white hover:shadow-lg hover:shadow-cyan-500/20"
}`}
>
<Terminal size={14} />
{connecting ? "Connecting..." : "Connect"}
</button>
</form>
{/* Terminal log */}
{logLines.length > 0 && (
<div className="w-full bg-slate-900/80 border border-slate-800 rounded-lg p-3 font-mono text-[11px] space-y-0.5 max-h-32 overflow-y-auto">
{logLines.map((line, i) => (
<div
key={i}
className={`${
line.includes("ERROR") ? "text-red-400" :
line.includes("accepted") || line.includes("established") ? "text-emerald-400" :
line.startsWith("$") ? "text-cyan-400" : "text-slate-400"
} animate-[fadeIn_0.15s_ease-out]`}
>
{line}
{i === logLines.length - 1 && !connected && (
<span className="inline-block w-1.5 h-3 bg-cyan-400 ml-1 animate-pulse" />
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function App() {
const [authToken, setAuthToken] = useState<string | null>(null);
const [needsAuth, setNeedsAuth] = useState<boolean | null>(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<Record<string, { x: number; y: number }>>({});
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
const [filterOpen, setFilterOpen] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [logPanelService, setLogPanelService] = useState<Service | null>(null);
const reactFlowRef = useRef<any>(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<string, string> = {};
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<ReturnType<typeof setTimeout>>(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<Node>[]) => {
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<string, string>();
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<Map<string, ReturnType<typeof setTimeout>>>(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<string>([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 (
<div className="h-screen w-screen bg-slate-950 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<img
src="/alteonx-logo.png"
alt="Alteonx"
className="w-7 h-7"
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<span className="text-base font-bold text-white tracking-wide">
DockerFlow
</span>
<span className="text-xs text-cyan-400 tracking-widest uppercase font-semibold">
AlteonX
</span>
</div>
<span className="text-xs text-slate-600 font-mono bg-slate-800 px-2 py-0.5 rounded">
v0.1
</span>
{/* Total resource usage */}
{totalStats.cpu > 0 && (
<div className="flex items-center gap-3 ml-2 text-xs font-mono bg-slate-800/80 border border-slate-700/50 px-3 py-1 rounded-md">
<div className="flex items-center gap-1.5">
<Cpu size={12} className="text-cyan-500" />
<span className="text-cyan-400">{totalStats.cpu.toFixed(1)}%</span>
</div>
<div className="w-px h-3 bg-slate-700" />
<div className="flex items-center gap-1.5">
<MemoryStick size={12} className="text-violet-500" />
<span className="text-violet-400">
{totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`}
</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-5">
{/* Flow panel */}
<FlowPanel
flows={flows}
settings={flowSettings}
engine={engine}
services={services}
onSimulate={handleSimulate}
/>
{/* Project filter dropdown */}
{projects.length > 1 && (
<div className="relative" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 hover:bg-slate-700/80 px-3 py-1.5 rounded-md transition-colors"
>
Projects
<span className="text-cyan-400 font-medium">
{projects.length - hiddenProjects.size}/{projects.length}
</span>
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[200px] z-[9999]">
{projects.map((p) => {
const active = !hiddenProjects.has(p);
const count = services.filter((s) => s.project === p).length;
return (
<button
key={p}
onClick={() => toggleProject(p)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{active && <Check size={12} className="text-white" />}
</div>
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
<span className="text-slate-500 ml-auto">{count}</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Stats */}
<span className="text-sm text-slate-500">
<span className="text-emerald-400 font-medium">{runningCount}</span>
<span className="text-slate-600">/{filteredServices.length}</span>
<span className="text-slate-600 ml-1">containers</span>
</span>
{/* Connection status */}
<div className="flex items-center gap-2">
{connected ? (
<Wifi size={15} className="text-emerald-500" />
) : (
<WifiOff size={15} className="text-red-500" />
)}
<span className={`text-xs ${connected ? "text-emerald-500" : "text-red-500"}`}>
{connected ? "Live" : "Offline"}
</span>
</div>
{/* Logout (only if auth is active) */}
{token && (
<button
onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }}
className="text-slate-600 hover:text-slate-400 transition-colors"
title="Logout"
>
<LogOut size={16} />
</button>
)}
</div>
</div>
<HeaderBar
services={services}
filteredServices={filteredServices}
connected={connected}
token={token}
projects={projects}
hiddenProjects={hiddenProjects}
onToggleProject={toggleProject}
totalStats={totalStats}
flows={flows}
flowSettings={flowSettings}
engine={engine}
onSimulate={handleSimulate}
/>
{/* Canvas */}
<div className="flex-1 min-h-0">
@@ -808,24 +515,7 @@ function Dashboard({ token }: { token: string }) {
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
<Background color="#1e293b" gap={24} size={1} />
<Controls position="bottom-left" />
{/* Edge legend */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-slate-900/90 border border-slate-800 rounded-lg px-5 py-2.5 z-10">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Conexiones</span>
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-5 h-0.5 rounded-full" style={{ backgroundColor: color }} />
<Icon size={13} style={{ color }} />
<span className="text-xs" style={{ color }}>{label}</span>
</div>
))}
</div>
<EdgeLegend />
<MiniMap
position="bottom-right"
nodeColor={(n) => {
+23
View File
@@ -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 (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-slate-900/90 border border-slate-800 rounded-lg px-5 py-2.5 z-10">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Conexiones</span>
{LEGEND_ITEMS.map(({ icon: Icon, color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-5 h-0.5 rounded-full" style={{ backgroundColor: color }} />
<Icon size={13} style={{ color }} />
<span className="text-xs" style={{ color }}>{label}</span>
</div>
))}
</div>
);
}
+171
View File
@@ -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<string>;
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<HTMLDivElement>(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 (
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<img
src="/alteonx-logo.png"
alt="Flowteon"
className="w-7 h-7"
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<span className="text-base font-bold text-white tracking-wide">
Flowteon
</span>
<span className="text-xs text-cyan-400 tracking-widest uppercase font-semibold">
AlteonX
</span>
</div>
<span className="text-xs text-slate-600 font-mono bg-slate-800 px-2 py-0.5 rounded">
v0.0.1
</span>
{/* Total resource usage */}
{totalStats.cpu > 0 && (
<div className="flex items-center gap-3 ml-2 text-xs font-mono bg-slate-800/80 border border-slate-700/50 px-3 py-1 rounded-md">
<div className="flex items-center gap-1.5">
<Cpu size={12} className="text-cyan-500" />
<span className="text-cyan-400">{totalStats.cpu.toFixed(1)}%</span>
</div>
<div className="w-px h-3 bg-slate-700" />
<div className="flex items-center gap-1.5">
<MemoryStick size={12} className="text-violet-500" />
<span className="text-violet-400">
{totalStats.mem >= 1024 ? `${(totalStats.mem / 1024).toFixed(1)} GB` : `${totalStats.mem.toFixed(0)} MB`}
</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-5">
{/* Flow panel */}
<FlowPanel
flows={flows}
settings={flowSettings}
engine={engine}
services={services}
onSimulate={onSimulate}
/>
{/* Project filter dropdown */}
{projects.length > 1 && (
<div className="relative" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 hover:bg-slate-700/80 px-3 py-1.5 rounded-md transition-colors"
>
Projects
<span className="text-cyan-400 font-medium">
{projects.length - hiddenProjects.size}/{projects.length}
</span>
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[200px] z-[9999]">
{projects.map((p) => {
const active = !hiddenProjects.has(p);
const count = services.filter((s) => s.project === p).length;
return (
<button
key={p}
onClick={() => onToggleProject(p)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{active && <Check size={12} className="text-white" />}
</div>
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
<span className="text-slate-500 ml-auto">{count}</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Stats */}
<span className="text-sm text-slate-500">
<span className="text-emerald-400 font-medium">{runningCount}</span>
<span className="text-slate-600">/{filteredServices.length}</span>
<span className="text-slate-600 ml-1">containers</span>
</span>
{/* Connection status */}
<div className="flex items-center gap-2">
{connected ? (
<Wifi size={15} className="text-emerald-500" />
) : (
<WifiOff size={15} className="text-red-500" />
)}
<span className={`text-xs ${connected ? "text-emerald-500" : "text-red-500"}`}>
{connected ? "Live" : "Offline"}
</span>
</div>
{/* Logout (only if auth is active) */}
{token && (
<button
onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }}
className="text-slate-600 hover:text-slate-400 transition-colors"
title="Logout"
>
<LogOut size={16} />
</button>
)}
</div>
</div>
);
}
+140
View File
@@ -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<string[]>([]);
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 (
<div className={`h-screen w-screen bg-slate-950 flex items-center justify-center transition-opacity duration-700 ${connected ? "opacity-0" : "opacity-100"}`}>
<div className="flex flex-col items-center gap-6 w-80">
{/* Logo + Title */}
<img
src="/alteonx-logo.png"
alt="Flowteon"
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<div className="text-center">
<h1 className="text-2xl font-bold text-white tracking-wide">Flowteon</h1>
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
</div>
{/* Form */}
<form onSubmit={submit} className={`flex flex-col gap-3 w-full transition-opacity duration-300 ${connecting ? "opacity-50 pointer-events-none" : ""}`}>
<div className="relative">
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => { 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}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{error && <span className="text-red-400 text-xs font-mono">{error}</span>}
<button
type="submit"
disabled={connecting || !token}
className={`w-full flex items-center justify-center gap-2 text-sm font-medium py-2.5 rounded-lg transition-all duration-300 ${
connecting
? "bg-slate-800 text-slate-500 cursor-wait"
: "bg-cyan-600 hover:bg-cyan-500 text-white hover:shadow-lg hover:shadow-cyan-500/20"
}`}
>
<Terminal size={14} />
{connecting ? "Connecting..." : "Connect"}
</button>
</form>
{/* Terminal log */}
{logLines.length > 0 && (
<div className="w-full bg-slate-900/80 border border-slate-800 rounded-lg p-3 font-mono text-[11px] space-y-0.5 max-h-32 overflow-y-auto">
{logLines.map((line, i) => (
<div
key={i}
className={`${
line.includes("ERROR") ? "text-red-400" :
line.includes("accepted") || line.includes("established") ? "text-emerald-400" :
line.startsWith("$") ? "text-cyan-400" : "text-slate-400"
} animate-[fadeIn_0.15s_ease-out]`}
>
{line}
{i === logLines.length - 1 && !connected && (
<span className="inline-block w-1.5 h-3 bg-cyan-400 ml-1 animate-pulse" />
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
+6
View File
@@ -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 <SmoothStepEdge {...props} pathOptions={{ offset, borderRadius: 8 }} />;
}
+4 -4
View File
@@ -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 {
+26 -10
View File
@@ -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<T extends { uid?: string; name?: string }>(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]);
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DockerFlow AlteonX</title>
<title>Flowteon</title>
<link rel="icon" type="image/png" href="/favicon.png" />
</head>
<body class="bg-slate-950 text-white">
+8 -4
View File
@@ -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<string>((d as any).activeHandles || []);
const highlighted = (d as any).highlighted;
const particleGlow = d.particleGlow || "";
const activeHandles = new Set<string>(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 (
<div
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${(d as any).id || ""}\nPorts: ${d.ports?.map((p) => `${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}`}
+25 -6
View File
@@ -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;
}
},
};
+58 -16
View File
@@ -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<WebSocket>();
const authenticatedClients = new Set<WebSocket>();
const logStreams = new Map<WebSocket, { destroy: () => 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`);
+32 -19
View File
@@ -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
}
}
});
});
}
+2 -1
View File
@@ -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": {