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
|
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
|
### 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.
|
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,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
SmoothStepEdge,
|
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type EdgeProps,
|
|
||||||
type NodeChange,
|
type NodeChange,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
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 { ServiceNode } from "./nodes/ServiceNode";
|
||||||
import { GroupNode } from "./nodes/GroupNode";
|
import { GroupNode } from "./nodes/GroupNode";
|
||||||
import { useDocker } from "./hooks/useDocker";
|
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 { ParticleEngine } from "./engine/particles";
|
||||||
import { ParticleOverlay } from "./components/ParticleOverlay";
|
import { ParticleOverlay } from "./components/ParticleOverlay";
|
||||||
import { LogPanel } from "./panels/LogPanel";
|
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";
|
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 nodeTypes = { service: ServiceNode, group: GroupNode };
|
||||||
const edgeTypes = { offsetSmooth: OffsetEdge };
|
const edgeTypes = { offsetSmooth: OffsetEdge };
|
||||||
|
|
||||||
@@ -45,145 +40,10 @@ function getToken(): string {
|
|||||||
return localStorage.getItem("df:token") || "";
|
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() {
|
export default function App() {
|
||||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||||
const [needsAuth, setNeedsAuth] = useState<boolean | null>(null);
|
const [needsAuth, setNeedsAuth] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// Check if auth is required
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/health").then((r) => {
|
fetch("/api/health").then((r) => {
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -222,23 +82,26 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const initialLayoutDone = useRef(false);
|
const initialLayoutDone = useRef(false);
|
||||||
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
|
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
|
||||||
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
|
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 [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||||
const [logPanelService, setLogPanelService] = useState<Service | null>(null);
|
const [logPanelService, setLogPanelService] = useState<Service | null>(null);
|
||||||
const reactFlowRef = useRef<any>(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(() => {
|
useEffect(() => {
|
||||||
if (reactFlowRef.current) {
|
if (reactFlowRef.current) {
|
||||||
// Small delay to let the DOM resize first
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reactFlowRef.current?.fitView({ padding: 0.3, duration: 300 });
|
reactFlowRef.current?.fitView({ padding: 0.3, duration: 300 });
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}, [logPanelService]);
|
}, [logPanelService]);
|
||||||
|
|
||||||
// Load saved positions from server on mount
|
// Load saved positions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -248,7 +111,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
// Save positions to server (debounced)
|
// Save positions (debounced)
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const savePositions = useCallback((nodes: Node[]) => {
|
const savePositions = useCallback((nodes: Node[]) => {
|
||||||
clearTimeout(saveTimer.current);
|
clearTimeout(saveTimer.current);
|
||||||
@@ -268,14 +131,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}, [token]);
|
}, [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>[]) => {
|
const handleNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
||||||
onNodesChange(changes);
|
onNodesChange(changes);
|
||||||
|
|
||||||
@@ -288,7 +143,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
let nodes = [...prev];
|
let nodes = [...prev];
|
||||||
|
|
||||||
// 1. Clamp Y only (prevent going above header), allow X freely
|
// Clamp Y only
|
||||||
nodes = nodes.map((n) => {
|
nodes = nodes.map((n) => {
|
||||||
if (!n.parentId) return n;
|
if (!n.parentId) return n;
|
||||||
const clampedY = Math.max(MIN_Y, n.position.y);
|
const clampedY = Math.max(MIN_Y, n.position.y);
|
||||||
@@ -299,13 +154,13 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return n;
|
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!))];
|
const groupIds = [...new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!))];
|
||||||
for (const gid of groupIds) {
|
for (const gid of groupIds) {
|
||||||
const kids = nodes.filter((n) => n.parentId === gid);
|
const kids = nodes.filter((n) => n.parentId === gid);
|
||||||
const minChildX = Math.min(...kids.map((k) => k.position.x));
|
const minChildX = Math.min(...kids.map((k) => k.position.x));
|
||||||
if (minChildX !== MIN_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;
|
changed = true;
|
||||||
nodes = nodes.map((n) => {
|
nodes = nodes.map((n) => {
|
||||||
if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } };
|
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) => {
|
nodes = nodes.map((n) => {
|
||||||
if (!n.id.startsWith("group-")) return n;
|
if (!n.id.startsWith("group-")) return n;
|
||||||
const kids = nodes.filter((c) => c.parentId === n.id);
|
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(
|
const filteredServices = useMemo(
|
||||||
() => services.filter((s) => !hiddenProjects.has(s.project)),
|
() => services.filter((s) => !hiddenProjects.has(s.project)),
|
||||||
[services, hiddenProjects]
|
[services, hiddenProjects]
|
||||||
@@ -394,13 +238,11 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats);
|
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats);
|
||||||
|
|
||||||
if (!initialLayoutDone.current) {
|
if (!initialLayoutDone.current) {
|
||||||
// Apply saved positions to all nodes (groups + services)
|
|
||||||
let positioned = newNodes.map((n) => {
|
let positioned = newNodes.map((n) => {
|
||||||
const saved = savedPositions.current[n.id];
|
const saved = savedPositions.current[n.id];
|
||||||
if (saved) return { ...n, position: saved };
|
if (saved) return { ...n, position: saved };
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
// Recalculate group sizes based on actual child positions
|
|
||||||
positioned = positioned.map((n) => {
|
positioned = positioned.map((n) => {
|
||||||
if (n.type !== "group") return n;
|
if (n.type !== "group") return n;
|
||||||
const kids = positioned.filter((c) => c.parentId === n.id);
|
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);
|
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
|
||||||
return { ...n, style: { ...n.style, width: newW, height: newH } };
|
return { ...n, style: { ...n.style, width: newW, height: newH } };
|
||||||
});
|
});
|
||||||
// Compute edges + activeHandles based on positioned nodes
|
|
||||||
const { edges, activeHandles } = computeEdges(positioned, filteredConnections);
|
const { edges, activeHandles } = computeEdges(positioned, filteredConnections);
|
||||||
for (const n of positioned) {
|
for (const n of positioned) {
|
||||||
if (n.type === "service") {
|
if (n.type === "service") {
|
||||||
@@ -428,7 +269,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
initialLayoutDone.current = true;
|
initialLayoutDone.current = true;
|
||||||
} else {
|
} else {
|
||||||
setNodes((prev) => {
|
setNodes((prev) => {
|
||||||
// Keep existing nodes, update data only
|
|
||||||
const updated = prev.map((n) => {
|
const updated = prev.map((n) => {
|
||||||
const u = newNodes.find((nn) => nn.id === n.id);
|
const u = newNodes.find((nn) => nn.id === n.id);
|
||||||
if (!u) return null;
|
if (!u) return null;
|
||||||
@@ -440,7 +280,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
if (brand.length === 0) return updated;
|
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]);
|
const hasSaved = brand.some((n) => savedPositions.current[n.id]);
|
||||||
if (hasSaved) {
|
if (hasSaved) {
|
||||||
let positioned = brand.map((n) => {
|
let positioned = brand.map((n) => {
|
||||||
@@ -448,7 +287,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
if (saved) return { ...n, position: saved };
|
if (saved) return { ...n, position: saved };
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
// Recalculate group sizes for restored nodes
|
|
||||||
positioned = positioned.map((n) => {
|
positioned = positioned.map((n) => {
|
||||||
if (n.type !== "group") return n;
|
if (n.type !== "group") return n;
|
||||||
const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id);
|
const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id);
|
||||||
@@ -467,7 +305,6 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return [...updated, ...positioned];
|
return [...updated, ...positioned];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find rightmost edge of existing groups to place new ones after
|
|
||||||
let maxRightX = 0;
|
let maxRightX = 0;
|
||||||
for (const n of updated) {
|
for (const n of updated) {
|
||||||
if (n.type === "group") {
|
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 newGroups = brand.filter((n) => n.type === "group");
|
||||||
const offsetX = maxRightX > 0 ? maxRightX + 50 - (newGroups[0]?.position.x || 0) : 0;
|
const offsetX = maxRightX > 0 ? maxRightX + 50 - (newGroups[0]?.position.x || 0) : 0;
|
||||||
|
|
||||||
@@ -492,18 +328,16 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}
|
}
|
||||||
}, [filteredServices, filteredConnections, statsVersion]);
|
}, [filteredServices, filteredConnections, statsVersion]);
|
||||||
|
|
||||||
// Recompute edges + handles whenever nodes move
|
// Recompute edges + handles when nodes move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nodes.length === 0 || filteredConnections.length === 0) return;
|
if (nodes.length === 0 || filteredConnections.length === 0) return;
|
||||||
const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections);
|
const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections);
|
||||||
setEdges(newEdges);
|
setEdges(newEdges);
|
||||||
// Update activeHandles on nodes
|
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
prev.map((n) => {
|
prev.map((n) => {
|
||||||
if (n.type !== "service") return n;
|
if (n.type !== "service") return n;
|
||||||
const handles = activeHandles.get(n.id) || [];
|
const handles = activeHandles.get(n.id) || [];
|
||||||
const current = (n.data as any).activeHandles || [];
|
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;
|
if (handles.length === current.length && handles.every((h: string, i: number) => h === current[i])) return n;
|
||||||
return { ...n, data: { ...n.data, activeHandles: handles } };
|
return { ...n, data: { ...n.data, activeHandles: handles } };
|
||||||
})
|
})
|
||||||
@@ -515,13 +349,9 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
if (events.length === 0) return;
|
if (events.length === 0) return;
|
||||||
const latest = events[events.length - 1]!;
|
const latest = events[events.length - 1]!;
|
||||||
const flashClass =
|
const flashClass =
|
||||||
latest.action === "start"
|
latest.action === "start" ? "flash-start" :
|
||||||
? "flash-start"
|
latest.action === "die" || latest.action === "stop" ? "flash-stop" :
|
||||||
: latest.action === "die" || latest.action === "stop"
|
latest.action === "restart" ? "flash-restart" : "";
|
||||||
? "flash-stop"
|
|
||||||
: latest.action === "restart"
|
|
||||||
? "flash-restart"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (!flashClass) return;
|
if (!flashClass) return;
|
||||||
|
|
||||||
@@ -540,12 +370,10 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
// Service name → UID map for flow path resolution
|
// Flow path resolution
|
||||||
const serviceNameToUid = useMemo(() => {
|
const serviceNameToUid = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const s of services) {
|
for (const s of services) map.set(s.name, s.uid);
|
||||||
map.set(s.name, s.uid);
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}, [services]);
|
}, [services]);
|
||||||
|
|
||||||
@@ -556,11 +384,9 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const handleSimulate = useCallback((flow: Flow) => {
|
const handleSimulate = useCallback((flow: Flow) => {
|
||||||
const pathUids = resolveFlowPath(flow.path);
|
const pathUids = resolveFlowPath(flow.path);
|
||||||
engine.spawn(flow.id, flow.color, flow.speed, pathUids);
|
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 });
|
sendMessage({ type: "simulate_flow", flowId: flow.id });
|
||||||
}, [resolveFlowPath, engine, sendMessage]);
|
}, [resolveFlowPath, engine, sendMessage]);
|
||||||
|
|
||||||
// Handle particle spawns from other clients via WS
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onParticleSpawn((data) => {
|
return onParticleSpawn((data) => {
|
||||||
const pathUids = resolveFlowPath(data.path);
|
const pathUids = resolveFlowPath(data.path);
|
||||||
@@ -568,28 +394,23 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
});
|
});
|
||||||
}, [onParticleSpawn, resolveFlowPath, engine]);
|
}, [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 nodeHitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const handleNodeHits = useCallback((hits: { nodeId: string; color: string }[]) => {
|
const handleNodeHits = useCallback((hits: { nodeId: string; color: string }[]) => {
|
||||||
for (const hit of hits) {
|
for (const hit of hits) {
|
||||||
// Clear existing timer for this node
|
|
||||||
const existing = nodeHitTimers.current.get(hit.nodeId);
|
const existing = nodeHitTimers.current.get(hit.nodeId);
|
||||||
if (existing) clearTimeout(existing);
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
prev.map((n) =>
|
prev.map((n) =>
|
||||||
n.id === hit.nodeId
|
n.id === hit.nodeId ? { ...n, data: { ...n.data, particleGlow: hit.color } } : n
|
||||||
? { ...n, data: { ...n.data, particleGlow: hit.color } }
|
|
||||||
: n
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
prev.map((n) =>
|
prev.map((n) =>
|
||||||
n.id === hit.nodeId
|
n.id === hit.nodeId ? { ...n, data: { ...n.data, particleGlow: "" } } : n
|
||||||
? { ...n, data: { ...n.data, particleGlow: "" } }
|
|
||||||
: n
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
nodeHitTimers.current.delete(hit.nodeId);
|
nodeHitTimers.current.delete(hit.nodeId);
|
||||||
@@ -598,24 +419,19 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}
|
}
|
||||||
}, [setNodes]);
|
}, [setNodes]);
|
||||||
|
|
||||||
const runningCount = filteredServices.filter((s) => s.state === "running").length;
|
|
||||||
|
|
||||||
// Total resource consumption
|
// Total resource consumption
|
||||||
const totalStats = useMemo(() => {
|
const totalStats = useMemo(() => {
|
||||||
let cpu = 0;
|
let cpu = 0;
|
||||||
let mem = 0;
|
let mem = 0;
|
||||||
for (const svc of filteredServices) {
|
for (const svc of filteredServices) {
|
||||||
const s = stats.get(svc.uid);
|
const s = stats.get(svc.uid);
|
||||||
if (s) {
|
if (s) { cpu += s.cpu; mem += s.mem_mb; }
|
||||||
cpu += s.cpu;
|
|
||||||
mem += s.mem_mb;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { cpu, mem };
|
return { cpu, mem };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filteredServices, statsVersion]);
|
}, [filteredServices, statsVersion]);
|
||||||
|
|
||||||
// Highlight edges connected to selected node, dim the rest
|
// Edge/node highlighting
|
||||||
const connectedNodeIds = useMemo(() => {
|
const connectedNodeIds = useMemo(() => {
|
||||||
if (!selectedNode) return null;
|
if (!selectedNode) return null;
|
||||||
const ids = new Set<string>([selectedNode]);
|
const ids = new Set<string>([selectedNode]);
|
||||||
@@ -632,11 +448,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const isConnected = e.source === selectedNode || e.target === selectedNode;
|
const isConnected = e.source === selectedNode || e.target === selectedNode;
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
style: {
|
style: { ...e.style, opacity: isConnected ? 1 : 0.08, strokeWidth: isConnected ? 2.5 : 1 },
|
||||||
...e.style,
|
|
||||||
opacity: isConnected ? 1 : 0.08,
|
|
||||||
strokeWidth: isConnected ? 2.5 : 1,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [edges, selectedNode]);
|
}, [edges, selectedNode]);
|
||||||
@@ -657,125 +469,20 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-slate-950 flex flex-col">
|
<div className="h-screen w-screen bg-slate-950 flex flex-col">
|
||||||
{/* Header */}
|
<HeaderBar
|
||||||
<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]">
|
services={services}
|
||||||
<div className="flex items-center gap-4">
|
filteredServices={filteredServices}
|
||||||
<div className="flex items-center gap-2.5">
|
connected={connected}
|
||||||
<img
|
token={token}
|
||||||
src="/alteonx-logo.png"
|
projects={projects}
|
||||||
alt="Alteonx"
|
hiddenProjects={hiddenProjects}
|
||||||
className="w-7 h-7"
|
onToggleProject={toggleProject}
|
||||||
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
totalStats={totalStats}
|
||||||
/>
|
flows={flows}
|
||||||
<span className="text-base font-bold text-white tracking-wide">
|
flowSettings={flowSettings}
|
||||||
DockerFlow
|
engine={engine}
|
||||||
</span>
|
onSimulate={handleSimulate}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@@ -808,24 +515,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
|
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
|
||||||
<Background color="#1e293b" gap={24} size={1} />
|
<Background color="#1e293b" gap={24} size={1} />
|
||||||
<Controls position="bottom-left" />
|
<Controls position="bottom-left" />
|
||||||
|
<EdgeLegend />
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<MiniMap
|
<MiniMap
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
nodeColor={(n) => {
|
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 { Node, Edge } from "@xyflow/react";
|
||||||
import type { Service, Connection, Stats } from "../../shared/types";
|
import type { Service, Connection, Stats } from "../../shared/types";
|
||||||
|
|
||||||
const NODE_WIDTH = 240;
|
export const NODE_WIDTH = 240;
|
||||||
const NODE_HEIGHT = 160;
|
export const NODE_HEIGHT = 160;
|
||||||
const NODE_GAP_X = 36;
|
const NODE_GAP_X = 36;
|
||||||
const NODE_GAP_Y = 36;
|
const NODE_GAP_Y = 36;
|
||||||
const GROUP_PADDING = 28;
|
export const GROUP_PADDING = 28;
|
||||||
const GROUP_HEADER = 44;
|
export const GROUP_HEADER = 44;
|
||||||
const GROUP_GAP = 50;
|
const GROUP_GAP = 50;
|
||||||
|
|
||||||
function getComposeKey(file: string): string {
|
function getComposeKey(file: string): string {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { Service, Connection, Stats, DockerEvent, LogLine, Flow, FlowSettings, WSMessage } from "../../shared/types";
|
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;
|
if (a.length !== b.length) return false;
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
if ((a[i] as any).uid !== (b[i] as any).uid) return false;
|
if (a[i].uid !== b[i].uid) return false;
|
||||||
if ((a[i] as any).state !== (b[i] as any).state) return false;
|
if (a[i].state !== b[i].state) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -53,24 +53,38 @@ export function useDocker(token = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const params = token ? `?token=${encodeURIComponent(token)}` : "";
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws${params}`;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => setConnected(true);
|
ws.onopen = () => {
|
||||||
|
if (token) {
|
||||||
|
ws.send(JSON.stringify({ type: "auth", token }));
|
||||||
|
} else {
|
||||||
|
setConnected(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
reconnectTimer.current = setTimeout(connect, 3000);
|
reconnectTimer.current = setTimeout(connect, 3_000);
|
||||||
};
|
};
|
||||||
ws.onerror = () => ws.close();
|
ws.onerror = () => ws.close();
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
try {
|
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":
|
case "services":
|
||||||
setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data);
|
setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data);
|
||||||
break;
|
break;
|
||||||
@@ -112,7 +126,9 @@ export function useDocker(token = "") {
|
|||||||
for (const cb of particleSpawnCallbacks.current) cb(msg.data);
|
for (const cb of particleSpawnCallbacks.current) cb(msg.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error("Failed to parse WS message:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-950 text-white">
|
<body class="bg-slate-950 text-white">
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ interface ServiceNodeData {
|
|||||||
project: string;
|
project: string;
|
||||||
stats: Stats | null;
|
stats: Stats | null;
|
||||||
flash?: string;
|
flash?: string;
|
||||||
|
id?: string;
|
||||||
|
particleGlow?: string;
|
||||||
|
activeHandles?: string[];
|
||||||
|
highlighted?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +105,9 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
|||||||
const s = stateStyles[d.state] || stateStyles.exited;
|
const s = stateStyles[d.state] || stateStyles.exited;
|
||||||
const { Icon, color: iconColor } = guessIcon(d.image, d.label);
|
const { Icon, color: iconColor } = guessIcon(d.image, d.label);
|
||||||
const flashClass = d.flash || "";
|
const flashClass = d.flash || "";
|
||||||
const particleGlow = (d as any).particleGlow || "";
|
const particleGlow = d.particleGlow || "";
|
||||||
const activeHandles = new Set<string>((d as any).activeHandles || []);
|
const activeHandles = new Set<string>(d.activeHandles || []);
|
||||||
const highlighted = (d as any).highlighted;
|
const highlighted = d.highlighted;
|
||||||
const hdot = (id: string) => {
|
const hdot = (id: string) => {
|
||||||
if (activeHandles.has(id)) {
|
if (activeHandles.has(id)) {
|
||||||
return highlighted
|
return highlighted
|
||||||
@@ -118,7 +122,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
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}
|
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
|
||||||
transition-all duration-300 ${flashClass}`}
|
transition-all duration-300 ${flashClass}`}
|
||||||
|
|||||||
+25
-6
@@ -1,7 +1,16 @@
|
|||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
|
import fs from "fs";
|
||||||
import type { Service, Connection, LogLine } from "../shared/types";
|
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 };
|
export { docker };
|
||||||
|
|
||||||
@@ -191,6 +200,10 @@ export function streamContainerLogs(
|
|||||||
let stream: NodeJS.ReadableStream | null = null;
|
let stream: NodeJS.ReadableStream | null = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
|
||||||
|
const destroyStream = (s: unknown) => {
|
||||||
|
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
|
||||||
|
};
|
||||||
|
|
||||||
container.logs({
|
container.logs({
|
||||||
stdout: true,
|
stdout: true,
|
||||||
stderr: true,
|
stderr: true,
|
||||||
@@ -198,16 +211,19 @@ export function streamContainerLogs(
|
|||||||
since: Math.floor(Date.now() / 1000),
|
since: Math.floor(Date.now() / 1000),
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
}).then((s) => {
|
}).then((s) => {
|
||||||
|
stream = s as unknown as NodeJS.ReadableStream;
|
||||||
|
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
|
destroyStream(stream);
|
||||||
|
stream = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stream = s as unknown as NodeJS.ReadableStream;
|
|
||||||
|
|
||||||
// Docker multiplexed stream parsing for follow mode
|
// Docker multiplexed stream parsing for follow mode
|
||||||
let buffer = Buffer.alloc(0);
|
let buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
stream.on("data", (chunk: Buffer) => {
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
if (destroyed) return;
|
||||||
buffer = Buffer.concat([buffer, chunk]);
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
|
||||||
while (buffer.length >= 8) {
|
while (buffer.length >= 8) {
|
||||||
@@ -232,13 +248,16 @@ export function streamContainerLogs(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch((err) => {
|
||||||
|
console.error(`Failed to stream logs for ${id}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
if (stream && typeof (stream as any).destroy === "function") {
|
if (stream) {
|
||||||
(stream as any).destroy();
|
destroyStream(stream);
|
||||||
|
stream = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+58
-16
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||||
@@ -9,6 +10,9 @@ import type { WSMessage } from "../shared/types";
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
// ── CORS ──
|
||||||
|
app.use("/api/*", cors());
|
||||||
|
|
||||||
// ── CLI args ──
|
// ── CLI args ──
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const ALL = args.includes("--all");
|
const ALL = args.includes("--all");
|
||||||
@@ -23,6 +27,8 @@ const PROJECTS = projectsFlag
|
|||||||
const PORT = parseInt(process.env.PORT || "9470");
|
const PORT = parseInt(process.env.PORT || "9470");
|
||||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
|
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
|
||||||
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
|
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
const WS_RECONNECT_MS = 3000;
|
||||||
|
|
||||||
// ── Auth middleware ──
|
// ── Auth middleware ──
|
||||||
if (AUTH_TOKEN) {
|
if (AUTH_TOKEN) {
|
||||||
@@ -60,7 +66,10 @@ app.get("/api/flows", (c) => {
|
|||||||
|
|
||||||
app.get("/api/logs/:id", async (c) => {
|
app.get("/api/logs/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
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 {
|
try {
|
||||||
const lines = await getContainerLogs(id, tail);
|
const lines = await getContainerLogs(id, tail);
|
||||||
return c.json(lines);
|
return c.json(lines);
|
||||||
@@ -78,7 +87,9 @@ app.get("/api/positions", (c) => {
|
|||||||
const data = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error("Failed to read positions file:", err);
|
||||||
|
}
|
||||||
return c.json({});
|
return c.json({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,11 +109,17 @@ app.get("/*", serveStatic({ root: "./dist", path: "index.html" }));
|
|||||||
|
|
||||||
// ── WebSocket ──
|
// ── WebSocket ──
|
||||||
const clients = new Set<WebSocket>();
|
const clients = new Set<WebSocket>();
|
||||||
|
const authenticatedClients = new Set<WebSocket>();
|
||||||
const logStreams = new Map<WebSocket, { destroy: () => void }>();
|
const logStreams = new Map<WebSocket, { destroy: () => void }>();
|
||||||
|
|
||||||
|
function isAuthenticated(ws: WebSocket): boolean {
|
||||||
|
return !AUTH_TOKEN || authenticatedClients.has(ws);
|
||||||
|
}
|
||||||
|
|
||||||
function broadcast(msg: WSMessage) {
|
function broadcast(msg: WSMessage) {
|
||||||
const data = JSON.stringify(msg);
|
const data = JSON.stringify(msg);
|
||||||
for (const ws of clients) {
|
for (const ws of clients) {
|
||||||
|
if (!isAuthenticated(ws)) continue;
|
||||||
try {
|
try {
|
||||||
ws.send(data);
|
ws.send(data);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -112,8 +129,12 @@ function broadcast(msg: WSMessage) {
|
|||||||
function cleanupLogStream(ws: WebSocket) {
|
function cleanupLogStream(ws: WebSocket) {
|
||||||
const stream = logStreams.get(ws);
|
const stream = logStreams.get(ws);
|
||||||
if (stream) {
|
if (stream) {
|
||||||
stream.destroy();
|
|
||||||
logStreams.delete(ws);
|
logStreams.delete(ws);
|
||||||
|
try {
|
||||||
|
stream.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to destroy log stream:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +171,7 @@ setInterval(async () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Poll error:", err);
|
console.error("Poll error:", err);
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// ── Start ──
|
// ── Start ──
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
@@ -159,12 +180,8 @@ const server = Bun.serve({
|
|||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// WebSocket upgrade
|
// WebSocket upgrade (auth handled via first message)
|
||||||
if (url.pathname === "/ws") {
|
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;
|
if (server.upgrade(req)) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -175,24 +192,47 @@ const server = Bun.serve({
|
|||||||
open(ws) {
|
open(ws) {
|
||||||
const native = ws as unknown as WebSocket;
|
const native = ws as unknown as WebSocket;
|
||||||
clients.add(native);
|
clients.add(native);
|
||||||
// Send flows to new client
|
|
||||||
const flowsData = { flows: getFlows(), settings: getSettings() };
|
if (!AUTH_TOKEN) {
|
||||||
if (flowsData.flows.length > 0) {
|
// No auth required — send data immediately
|
||||||
try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {}
|
const flowsData = { flows: getFlows(), settings: getSettings() };
|
||||||
|
if (flowsData.flows.length > 0) {
|
||||||
|
try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
const native = ws as unknown as WebSocket;
|
const native = ws as unknown as WebSocket;
|
||||||
cleanupLogStream(native);
|
cleanupLogStream(native);
|
||||||
clients.delete(native);
|
clients.delete(native);
|
||||||
|
authenticatedClients.delete(native);
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
message(ws, message) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
|
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
|
||||||
const native = ws as unknown as WebSocket;
|
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) {
|
if (msg.type === "subscribe_logs" && msg.container) {
|
||||||
// Clean up any existing stream first
|
|
||||||
cleanupLogStream(native);
|
cleanupLogStream(native);
|
||||||
|
|
||||||
const stream = streamContainerLogs(msg.container, (line) => {
|
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(", ")}`;
|
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(` → http://${HOST}:${PORT}`);
|
||||||
console.log(` → Mode: ${mode}`);
|
console.log(` → Mode: ${mode}`);
|
||||||
console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`);
|
console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`);
|
||||||
|
|||||||
+32
-19
@@ -44,28 +44,41 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
stream.on("data", (chunk: Buffer) => {
|
stream.on("data", (chunk: Buffer) => {
|
||||||
try {
|
buffer += chunk.toString();
|
||||||
const event = JSON.parse(chunk.toString());
|
const lines = buffer.split("\n");
|
||||||
if (event.Type !== "container") return;
|
buffer = lines.pop() || ""; // keep incomplete last line in buffer
|
||||||
|
|
||||||
const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
|
for (const line of lines) {
|
||||||
if (!["start", "stop", "die", "restart", "health_status"].includes(action)) return;
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
const svcName =
|
try {
|
||||||
event.Actor?.Attributes?.["com.docker.compose.service"] ||
|
const event = JSON.parse(trimmed);
|
||||||
event.Actor?.Attributes?.name ||
|
if (event.Type !== "container") continue;
|
||||||
"unknown";
|
|
||||||
const svcProject =
|
const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
|
||||||
event.Actor?.Attributes?.["com.docker.compose.project"] ||
|
if (!["start", "stop", "die", "restart", "health_status"].includes(action)) continue;
|
||||||
"standalone";
|
|
||||||
onEvent({
|
const svcName =
|
||||||
type: "docker",
|
event.Actor?.Attributes?.["com.docker.compose.service"] ||
|
||||||
action,
|
event.Actor?.Attributes?.name ||
|
||||||
service: `${svcProject}/${svcName}`,
|
"unknown";
|
||||||
time: event.time || Date.now() / 1000,
|
const svcProject =
|
||||||
});
|
event.Actor?.Attributes?.["com.docker.compose.project"] ||
|
||||||
} catch {}
|
"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,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 9420,
|
||||||
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:9470",
|
"/api": "http://localhost:9470",
|
||||||
"/ws": {
|
"/ws": {
|
||||||
|
|||||||
Reference in New Issue
Block a user