mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.8
This commit is contained in:
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user