46 KiB
Alteonx DockerFlow
Herramienta open source para visualizar arquitecturas Docker en tiempo real con partículas animadas que muestran el flujo de datos entre servicios.
git clone github.com/user/alteonx-dockerflow
cd alteonx-dockerflow
bun install
claude mcp add alteonx-dockerflow -- bun run src/mcp.ts
# Desde Claude Code:
> "arranca el visualizer"
> "Listo, abre http://localhost:9470"
Stack
| Capa | Tecnología | Por qué |
|---|---|---|
| Runtime | Bun | 3x más rápido que Node, WebSocket nativo, bundler incluido, menos RAM |
| Server | Hono | 14KB, ultra rápido, soporte nativo Bun, middleware mínimo |
| Frontend | React 19 + @xyflow/react 12 | Librería de grafos más madura, nodos custom, edges custom, minimap |
| Styling | Tailwind v4 | Utility-first, tree-shaking agresivo, sin runtime |
| Real-time | Bun WebSocket | Nativo en Bun, zero dependencias, más rápido que socket.io |
| Animaciones | SVG animateMotion + CSS | Sin librerías extra, GPU-accelerated, zero overhead |
| Docker API | dockerode | Estándar de facto, tipado, streams |
| MCP | @modelcontextprotocol/sdk | SDK oficial, stdio transport |
| Build | Vite 6 | HMR instantáneo, tree-shaking, build optimizado |
Recursos estimados
| Recurso | Valor |
|---|---|
| RAM | ~30-50 MB |
| CPU | <1% idle, ~2% durante polling |
| Disco | ~80 MB (node_modules), ~2 MB build |
| Bundle | ~150 KB gzip |
| Startup | <500ms con Bun |
Dependencias totales (mínimas)
{
"dependencies": {
"hono": "^4",
"dockerode": "^4",
"@modelcontextprotocol/sdk": "^1.12",
"zod": "^3",
"yaml": "^2"
},
"devDependencies": {
"react": "^19",
"react-dom": "^19",
"@xyflow/react": "^12",
"@dagrejs/dagre": "^1",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"vite": "^6",
"typescript": "^5"
}
}
Arquitectura
┌──────────────────────────────────────────────────────────────────┐
│ Docker Socket │
│ /var/run/docker.sock │
└──────────┬──────────────────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Hono Server (:9470)│ │ MCP Server (stdio) │
│ │ │ │
│ GET / │ │ Tools: │
│ → serve SPA │ │ • start_dashboard │
│ │ │ • list_services │
│ WS /ws │ │ • get_stats │
│ → push eventos │ │ • get_logs │
│ → push stats │ │ • restart_service │
│ │ │ • list_flows │
│ Docker watcher: │ │ • simulate_flow │
│ • listContainers │ │ • create_flow │
│ • getEvents stream │ │ │
│ • stats polling │ │ Resources: │
│ │ │ • docker://services │
│ Para: humanos 👀 │ │ • docker://flows │
└──────────────────────┘ │ │
│ Para: Claude Code 🤖 │
└──────────────────────────┘
Estructura del proyecto
alteonx-dockerflow/
├── src/
│ ├── server/
│ │ ├── index.ts # Hono server + WebSocket
│ │ ├── docker.ts # Docker API wrapper (dockerode)
│ │ ├── watcher.ts # Docker events stream + stats polling
│ │ └── flows.ts # Flow engine (partículas)
│ ├── mcp/
│ │ └── index.ts # MCP server (stdio)
│ ├── client/
│ │ ├── App.tsx # React Flow canvas
│ │ ├── main.tsx # Entry point
│ │ ├── nodes/
│ │ │ ├── ServiceNode.tsx # Nodo de container
│ │ │ └── ExternalNode.tsx# Nodo externo (APIs, users)
│ │ ├── edges/
│ │ │ └── ParticleEdge.tsx# Edge con partículas animadas
│ │ ├── panels/
│ │ │ ├── FlowPanel.tsx # Panel de control de flows
│ │ │ ├── StatsPanel.tsx # Panel de stats CPU/MEM
│ │ │ └── LogPanel.tsx # Panel de logs
│ │ ├── hooks/
│ │ │ ├── useDocker.ts # WebSocket hook
│ │ │ └── useFlows.ts # Flow state management
│ │ └── engine/
│ │ ├── particles.ts # Motor de partículas
│ │ └── layout.ts # Auto-layout con dagre
│ └── shared/
│ └── types.ts # Tipos compartidos server/client
├── flows.yaml # Config de flows (opcional)
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md
Cómo funciona (para cualquier proyecto)
1. Auto-discovery (zero config)
El dashboard lee el Docker socket y automáticamente:
// src/server/docker.ts
import Docker from "dockerode";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export async function discoverServices() {
const containers = await docker.listContainers({ all: true });
return containers.map((c) => ({
id: c.Id.slice(0, 12),
name: c.Labels["com.docker.compose.service"] || c.Names[0].replace("/", ""),
image: c.Image,
state: c.State,
status: c.Status,
ports: c.Ports.filter((p) => p.PublicPort).map((p) => ({
host: p.PublicPort,
container: p.PrivatePort,
})),
networks: Object.keys(c.NetworkSettings.Networks),
project: c.Labels["com.docker.compose.project"] || "standalone",
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
}));
}
export async function discoverConnections() {
// Servicios en la misma network = conectados
const networks = await docker.listNetworks();
const connections: { from: string; to: string; network: string }[] = [];
for (const net of networks) {
const info = await docker.getNetwork(net.Id).inspect();
const members = Object.values(info.Containers || {}).map((c: any) => c.Name);
// Cada par de containers en la misma red = edge
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
connections.push({
from: members[i],
to: members[j],
network: net.Name,
});
}
}
}
return connections;
}
Resultado: sin configurar nada, el usuario ve todos sus containers como nodos y las conexiones de red como edges. Funciona con cualquier proyecto Docker.
2. Agrupación visual (subgraphs automáticos)
Cada container es su propio nodo (cuadro) con imagen, estado, puertos, stats. Los subgraphs son bordes visuales que agrupan containers del mismo compose file o proyecto.
Docker expone 2 labels para agrupar:
| Label | Qué es | Ejemplo |
|---|---|---|
com.docker.compose.project |
Nombre del proyecto (directorio) | ninjasagacw |
com.docker.compose.project.config_files |
Qué compose file lo levantó | docker-compose.infra.yml |
Auto-detección del nivel de agrupación:
function detectGrouping(services: Service[]): "project" | "compose_file" {
const projects = new Set(services.map((s) => s.project));
// Múltiples proyectos → agrupar por proyecto
if (projects.size > 1) return "project";
// 1 solo proyecto con múltiples compose files → agrupar por compose file
return "compose_file";
}
Caso 1: Un proyecto, múltiples compose files (como NinjaSaga):
┌─ infra.yml ──────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 🐘 db │ │ 📡 collector │ │ ⚡ redis │ │
│ │ postgres:17 │ │ ./backend │ │ redis:7 │ │
│ │ CPU 0.3% │ │ CPU 1.2% │ │ CPU 0.1% │ │
│ │ MEM 45MB │ │ MEM 82MB │ │ MEM 12MB │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ ⚙️ celery- │ │ ⏰ celery- │ │
│ │ worker │ │ beat │ │
│ │ CPU 0.5% │ │ CPU 0.1% │ │
│ │ MEM 65MB │ │ MEM 40MB │ │
│ └──────────────┘ └──────────────┘ │
└───────────────────────────────────────────────────────────┘
┌─ dev.yml ────────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 🔧 backend │ │ 🔑 auth │ │ ⚙️ celery │ │
│ │ -dev │ │ -dev │ │ -dev │ │
│ │ :4020 │ │ :4021 │ │ │ │
│ │ CPU 0.8% │ │ CPU 0.3% │ │ CPU 0.2% │ │
│ │ MEM 120MB │ │ MEM 95MB │ │ MEM 60MB │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 🖥️ frontend │ │
│ │ -dev │ │
│ │ :4030 │ │
│ │ CPU 0.5% │ │
│ │ MEM 180MB │ │
│ └──────────────┘ │
└───────────────────────────────────────────────────────────┘
Edges cruzan entre subgraphs:
backend-dev ──→ db (postgres)
backend-dev ──→ redis (cache)
frontend-dev ──→ backend-dev (proxy /data)
collector ──→ db (snapshot)
collector ──→ redis (invalidate)
celery-worker ──→ redis (broker)
Caso 2: Múltiples proyectos (usuario con varios repos):
┌─ mi-saas ──────────────────┐ ┌─ monitoring ──────────────────┐
│ │ │ │
│ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ ┌────────────┐ │
│ │ 🚀 api │ │ 🐘 db │ │ │ │ 📊 grafana│ │ 📈 prometheus│
│ │ :3000 │ │ │ │ │ │ :3001 │ │ │ │
│ └────────┘ └────────┘ │ │ └──────────┘ └────────────┘ │
│ │ │ │
│ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ │
│ │ ⚡redis│ │ 🌐 web │ │ │ │ 📋 loki │ │
│ │ │ │ :8080 │ │ │ │ │ │
│ └────────┘ └────────┘ │ │ └──────────┘ │
└──────────────────────────────┘ └──────────────────────────────┘
Implementación en React Flow:
// Los subgraphs se renderizan como nodos "group" de React Flow
function buildGroupNodes(services: Service[], groupBy: "project" | "compose_file") {
const groups = new Map<string, Service[]>();
for (const svc of services) {
const key = groupBy === "project" ? svc.project : svc.compose_file;
const label = groupBy === "compose_file"
? key.replace("docker-compose.", "").replace(".yml", "") // "infra", "dev", "prod"
: key; // "mi-saas", "monitoring"
if (!groups.has(label)) groups.set(label, []);
groups.get(label)!.push(svc);
}
const nodes = [];
for (const [groupLabel, svcs] of groups) {
// Nodo grupo (subgraph visual)
nodes.push({
id: `group-${groupLabel}`,
type: "group",
data: { label: groupLabel },
position: { x: 0, y: 0 },
style: {
border: "1px dashed #334155",
borderRadius: 16,
padding: 24,
background: "rgba(30, 41, 59, 0.3)",
},
});
// Nodos hijos dentro del grupo
for (const svc of svcs) {
nodes.push({
id: svc.name,
type: "service",
data: { ...svc, label: svc.name },
parentId: `group-${groupLabel}`, // ← lo pone dentro del subgraph
extent: "parent",
position: { x: 0, y: 0 }, // dagre calcula la posición
});
}
}
return nodes;
}
3. Smart edge detection (heurísticas)
Además de redes, detecta relaciones por convención:
// src/server/docker.ts
export function inferEdgeType(from: Service, to: Service): EdgeType | null {
const toImage = to.image.toLowerCase();
const fromEnv = from.env || {};
// Detectar DB connections
if (toImage.includes("postgres") || toImage.includes("mysql") || toImage.includes("mongo")) {
// Buscar en env vars del "from" si referencia al "to"
for (const [key, val] of Object.entries(fromEnv)) {
if (key.includes("DATABASE") || key.includes("DB_HOST") || key.includes("MONGO")) {
return { type: "database", label: key.split("_")[0].toLowerCase() };
}
}
return { type: "database", label: "db" };
}
// Detectar Redis connections
if (toImage.includes("redis")) {
return { type: "cache", label: "redis" };
}
// Detectar RabbitMQ / message brokers
if (toImage.includes("rabbit") || toImage.includes("kafka")) {
return { type: "broker", label: "messages" };
}
// Detectar nginx/traefik → upstream
if (fromImage.includes("nginx") || fromImage.includes("traefik")) {
return { type: "proxy", label: "upstream" };
}
return null;
}
3. Docker Events (automático, zero config)
// src/server/watcher.ts
import Docker from "dockerode";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export function watchDockerEvents(onEvent: (e: DockerEvent) => void) {
docker.getEvents({}, (err, stream) => {
if (err || !stream) return;
stream.on("data", (chunk) => {
try {
const event = JSON.parse(chunk.toString());
if (event.Type !== "container") return;
onEvent({
type: "docker",
action: event.Action, // start, stop, die, restart, health_status
service: event.Actor?.Attributes?.["com.docker.compose.service"]
|| event.Actor?.Attributes?.name
|| "unknown",
time: event.time,
});
} catch {}
});
});
}
Partículas gratis: cada start/stop/restart se ve como un pulso animado en el nodo.
4. Stats polling
// src/server/watcher.ts
export async function pollStats(services: Service[]): Promise<Stats[]> {
const running = services.filter((s) => s.state === "running");
const results: Stats[] = [];
for (const svc of running) {
try {
const container = docker.getContainer(svc.id);
const raw = await container.stats({ stream: false });
const cpuDelta = raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
const sysDelta = raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
results.push({
service: svc.name,
cpu: sysDelta > 0 ? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100 : 0,
mem_mb: (raw.memory_stats.usage || 0) / 1024 / 1024,
mem_percent: ((raw.memory_stats.usage || 0) / (raw.memory_stats.limit || 1)) * 100,
net_rx_mb: Object.values(raw.networks || {}).reduce((a: number, n: any) => a + (n.rx_bytes || 0), 0) / 1024 / 1024,
net_tx_mb: Object.values(raw.networks || {}).reduce((a: number, n: any) => a + (n.tx_bytes || 0), 0) / 1024 / 1024,
});
} catch {}
}
return results;
}
Flows (simulaciones visuales)
Los flows son recorridos animados que muestran cómo viaja una request o evento por la arquitectura. Son puramente visuales y sirven para entender y presentar tu sistema.
Dos modos
Modo simulación (MVP, funciona para todos):
- El usuario define un path: "request pasa por nginx → backend → redis → db"
- Click en "simular" → partícula recorre esa ruta con animación
- Como un diagrama de secuencia pero animado y en el grafo real
- No necesita conectar nada, solo definir el camino
Modo eventos reales (avanzado, opcional):
- Requiere un probe (middleware) en los servicios
- El probe emite eventos a Redis/WebSocket
- El dashboard escucha y dispara partículas automáticamente
- Para fase 2+, no MVP
Configuración de flows — flows.yaml
# flows.yaml — Define recorridos visuales en tu arquitectura
# Cada flow es un camino que una operación recorre entre servicios
flows:
# Ejemplo genérico: request HTTP
api_request:
name: "API Request"
description: "Request del usuario al backend"
color: "#3b82f6" # azul
speed: 0.8 # segundos por edge
path: [nginx, backend, redis, db, backend, nginx]
# Ejemplo: background job
background_job:
name: "Background Job"
description: "Tarea programada del worker"
color: "#ec4899" # rosa
speed: 1.0
path: [redis, worker, db, worker]
# Ejemplo: cache flow
cache_hit:
name: "Cache Hit"
description: "Request servida desde cache"
color: "#22c55e" # verde
speed: 0.5
path: [nginx, backend, redis, backend, nginx]
cache_miss:
name: "Cache Miss"
description: "Cache miss, va a DB"
color: "#f59e0b" # amarillo
speed: 0.8
path: [nginx, backend, redis, db, redis, backend, nginx]
# Los nombres de servicios deben coincidir con el nombre del container
# (com.docker.compose.service label o container name)
settings:
particle_size: 5 # radio en px
trail: true # estela detrás de la partícula
trail_opacity: 0.3
glow: true # efecto neón
max_particles: 50 # máximo simultáneas
auto_simulate: false # simular automáticamente en idle
auto_simulate_interval: 5 # segundos entre simulaciones auto
Zero config posible: sin flows.yaml el dashboard muestra la arquitectura estática con stats. Los flows son un addon visual opcional.
Motor de partículas
// src/client/engine/particles.ts
interface Particle {
id: string;
flowName: string;
color: string;
path: string[]; // lista de service names
currentStep: number; // índice actual en path
progress: number; // 0-1 progreso en el edge actual
label?: string;
}
class ParticleEngine {
particles: Particle[] = [];
private maxParticles = 50;
spawn(flow: Flow, label?: string) {
if (this.particles.length >= this.maxParticles) return;
this.particles.push({
id: crypto.randomUUID(),
flowName: flow.name,
color: flow.color,
path: flow.path,
currentStep: 0,
progress: 0,
label,
});
}
tick(deltaMs: number, speed: number) {
const step = deltaMs / (speed * 1000);
this.particles = this.particles.filter((p) => {
p.progress += step;
if (p.progress >= 1) {
p.currentStep++;
p.progress = 0;
// Llegó al final → flash en nodo destino → eliminar
if (p.currentStep >= p.path.length - 1) return false;
}
return true;
});
}
}
Partícula visual (SVG sobre React Flow edge)
// src/client/edges/ParticleEdge.tsx
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
export function ParticleEdge({ id, sourceX, sourceY, targetX, targetY, ...props }: EdgeProps) {
const [path] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY });
const particles = useParticlesForEdge(id); // del engine
return (
<>
<BaseEdge id={id} path={path} style={{ stroke: "#334155", strokeWidth: 2 }} />
<svg>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
{particles.map((p) => (
<g key={p.id}>
{/* Estela */}
<circle r="3" fill={p.color} opacity="0.3" filter="url(#glow)">
<animateMotion dur={`${p.speed}s`} path={path} keyPoints={`${p.progress};${p.progress}`} keyTimes="0;1" fill="freeze" />
</circle>
{/* Partícula principal */}
<circle r="5" fill={p.color} filter="url(#glow)">
<animateMotion dur={`${p.speed}s`} path={path} keyPoints={`${p.progress};${p.progress}`} keyTimes="0;1" fill="freeze" />
</circle>
{/* Label */}
{p.label && (
<text fill="#e2e8f0" fontSize="9" textAnchor="middle" dy="-14">
<animateMotion dur={`${p.speed}s`} path={path} keyPoints={`${p.progress};${p.progress}`} keyTimes="0;1" fill="freeze" />
{p.label}
</text>
)}
</g>
))}
</svg>
</>
);
}
Panel de control de flows
┌──────────────────────────────────────────────────────────────┐
│ Flows [⚙️] │
├──────────────────────────────────────────────────────────────┤
│ ● API Request 0/min [ ON ] [ ▶ Simular ] │
│ ● Cache Hit 0/min [ ON ] [ ▶ Simular ] │
│ ● Cache Miss 0/min [ ON ] [ ▶ Simular ] │
│ ● Background Job 0/min [ ON ] [ ▶ Simular ] │
├──────────────────────────────────────────────────────────────┤
│ [ ▶ Simular todos ] [ ⏸ Pausar ] [ + Nuevo flow ] │
└──────────────────────────────────────────────────────────────┘
Nodo visual (ServiceNode)
// src/client/nodes/ServiceNode.tsx
import { Handle, Position } from "@xyflow/react";
const stateStyles = {
running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10" },
exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" },
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" },
};
const imageIcons: Record<string, string> = {
postgres: "🐘", redis: "⚡", nginx: "🔀", node: "💚", python: "🐍",
mongo: "🍃", mysql: "🐬", rabbitmq: "🐰", certbot: "📜",
};
function guessIcon(image: string): string {
for (const [key, icon] of Object.entries(imageIcons)) {
if (image.toLowerCase().includes(key)) return icon;
}
return "📦";
}
export function ServiceNode({ data }: { data: ServiceNodeData }) {
const s = stateStyles[data.state] || stateStyles.exited;
const icon = guessIcon(data.image);
return (
<div className={`relative rounded-xl border border-slate-700 ${s.bg} backdrop-blur-sm
shadow-lg shadow-black/30 p-4 min-w-[180px] ring-2 ${s.ring}
transition-all duration-500`}>
<Handle type="target" position={Position.Top} className="!bg-slate-500" />
{/* Status dot + name */}
<div className="flex items-center gap-2 mb-2">
<div className={`w-2.5 h-2.5 rounded-full ${s.dot}
${data.state === "running" ? "animate-pulse" : ""}`} />
<span className="font-bold text-white text-sm">{icon} {data.label}</span>
</div>
{/* Image */}
<div className="text-[11px] text-slate-400 truncate mb-1">{data.image}</div>
{/* Ports */}
{data.ports?.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1.5">
{data.ports.map((p) => (
<span key={p} className="text-[10px] bg-slate-800 text-cyan-400 px-1.5 py-0.5 rounded font-mono">
:{p}
</span>
))}
</div>
)}
{/* Stats bar */}
{data.stats && (
<div className="mt-2.5 space-y-1">
<div className="flex justify-between text-[10px] text-slate-400">
<span>CPU {data.stats.cpu.toFixed(1)}%</span>
<span>MEM {data.stats.mem_mb.toFixed(0)}MB</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500/70 rounded-full transition-all duration-500"
style={{ width: `${Math.min(data.stats.cpu, 100)}%` }} />
</div>
</div>
)}
{/* Compose project badge */}
{data.project && (
<div className="mt-2 flex justify-end">
<span className="text-[9px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">
{data.project}
</span>
</div>
)}
<Handle type="source" position={Position.Bottom} className="!bg-slate-500" />
</div>
);
}
Server (Hono + Bun WebSocket)
// src/server/index.ts
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { discoverServices, discoverConnections } from "./docker";
import { watchDockerEvents, pollStats } from "./watcher";
import { FlowEngine } from "./flows";
import { parse } from "yaml";
import { readFileSync, existsSync } from "fs";
const app = new Hono();
const flowEngine = new FlowEngine();
// Load flows if exists
if (existsSync("flows.yaml")) {
const config = parse(readFileSync("flows.yaml", "utf-8"));
flowEngine.loadFlows(config.flows || []);
}
// Serve frontend
app.use("/*", serveStatic({ root: "./dist" }));
// REST endpoints (para MCP y fallback)
app.get("/api/services", async (c) => c.json(await discoverServices()));
app.get("/api/connections", async (c) => c.json(await discoverConnections()));
app.get("/api/flows", (c) => c.json(flowEngine.getFlows()));
app.post("/api/flows/:name/simulate", (c) => {
flowEngine.simulate(c.req.param("name"));
return c.json({ ok: true });
});
// WebSocket (Bun native)
const clients = new Set<WebSocket>();
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";
Bun.serve({
hostname: HOST,
port: PORT,
fetch: app.fetch,
websocket: {
open(ws) { clients.add(ws); },
close(ws) { clients.delete(ws); },
message(ws, msg) {
const data = JSON.parse(msg.toString());
if (data.type === "simulate") flowEngine.simulate(data.flow);
},
},
});
function broadcast(type: string, data: any) {
const msg = JSON.stringify({ type, data });
for (const ws of clients) ws.send(msg);
}
// Docker events → broadcast
watchDockerEvents((event) => broadcast("docker_event", event));
// Stats polling cada 3s
setInterval(async () => {
const services = await discoverServices();
const connections = await discoverConnections();
const stats = await pollStats(services);
broadcast("services", services);
broadcast("connections", connections);
broadcast("stats", stats);
}, 3000);
// Particle engine tick (60fps)
setInterval(() => {
const particles = flowEngine.tick(16);
if (particles.length > 0) broadcast("particles", particles);
}, 16);
console.log(`Alteonx DockerFlow running on http://${HOST}:${PORT}`);
MCP Server
// src/mcp/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import Docker from "dockerode";
import { z } from "zod";
import { execSync } from "child_process";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const server = new McpServer({ name: "alteonx-dockerflow", version: "1.0.0" });
// ── Dashboard control ──
server.tool("start_dashboard",
"Start the Alteonx DockerFlow dashboard and return the URL",
{ port: z.number().default(9470) },
async ({ port }) => {
// Arranca el server en background
execSync(`bun run src/server/index.ts &`, { stdio: "ignore" });
return { content: [{ type: "text", text: `Dashboard running at http://localhost:${port}` }] };
}
);
// ── Docker tools ──
server.tool("list_services",
"List all Docker containers with status, ports, image",
{},
async () => {
const containers = await docker.listContainers({ all: true });
const services = containers.map((c) => ({
name: c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", ""),
state: c.State,
status: c.Status,
image: c.Image,
ports: c.Ports.filter((p) => p.PublicPort).map((p) => `${p.PublicPort}:${p.PrivatePort}`),
project: c.Labels["com.docker.compose.project"] || "",
}));
return { content: [{ type: "text", text: JSON.stringify(services, null, 2) }] };
}
);
server.tool("get_stats",
"Get CPU and memory stats for a container",
{ container: z.string().describe("Container name or ID") },
async ({ container }) => {
const c = docker.getContainer(container);
const raw = await c.stats({ stream: false });
const cpuDelta = raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
const sysDelta = raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
return {
content: [{
type: "text",
text: JSON.stringify({
cpu_percent: (sysDelta > 0 ? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100 : 0).toFixed(2),
mem_mb: ((raw.memory_stats.usage || 0) / 1024 / 1024).toFixed(1),
mem_percent: (((raw.memory_stats.usage || 0) / (raw.memory_stats.limit || 1)) * 100).toFixed(1),
}, null, 2),
}],
};
}
);
server.tool("get_logs",
"Get recent logs from a container",
{
container: z.string().describe("Container name or ID"),
tail: z.number().default(50).describe("Number of lines"),
},
async ({ container, tail }) => {
const logs = await docker.getContainer(container).logs({
stdout: true, stderr: true, tail, timestamps: true,
});
return { content: [{ type: "text", text: logs.toString() }] };
}
);
server.tool("restart_service",
"Restart a Docker container",
{ container: z.string() },
async ({ container }) => {
await docker.getContainer(container).restart();
return { content: [{ type: "text", text: `Restarted: ${container}` }] };
}
);
server.tool("inspect_service",
"Get detailed info about a container (filters secrets from env)",
{ container: z.string() },
async ({ container }) => {
const info = await docker.getContainer(container).inspect();
return {
content: [{
type: "text",
text: JSON.stringify({
name: info.Name,
state: info.State,
image: info.Config.Image,
cmd: info.Config.Cmd,
env: info.Config.Env?.filter((e) =>
!e.match(/PASSWORD|SECRET|KEY|TOKEN|PRIVATE/i)
),
networks: Object.keys(info.NetworkSettings.Networks || {}),
mounts: info.Mounts?.map((m) => ({
type: m.Type, src: m.Source, dst: m.Destination,
})),
ports: info.NetworkSettings.Ports,
}, null, 2),
}],
};
}
);
server.tool("get_networks",
"List Docker networks and connected containers",
{},
async () => {
const networks = await docker.listNetworks();
const result = [];
for (const net of networks) {
const info = await docker.getNetwork(net.Id).inspect();
result.push({
name: net.Name,
driver: net.Driver,
containers: Object.values(info.Containers || {}).map((c: any) => c.Name),
});
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
);
// ── Flow tools ──
server.tool("list_flows",
"List configured flow simulations",
{},
async () => {
// Read flows.yaml
const { readFileSync, existsSync } = await import("fs");
const { parse } = await import("yaml");
if (!existsSync("flows.yaml")) {
return { content: [{ type: "text", text: "No flows.yaml found. Create one to define flow simulations." }] };
}
const config = parse(readFileSync("flows.yaml", "utf-8"));
const flows = Object.entries(config.flows || {}).map(([key, f]: [string, any]) => ({
id: key,
name: f.name,
color: f.color,
path: f.path.join(" → "),
description: f.description || "",
}));
return { content: [{ type: "text", text: JSON.stringify(flows, null, 2) }] };
}
);
server.tool("simulate_flow",
"Trigger a flow simulation on the dashboard",
{ flow: z.string().describe("Flow ID from flows.yaml") },
async ({ flow }) => {
// POST to dashboard API
try {
await fetch(`http://localhost:9470/api/flows/${flow}/simulate`, { method: "POST" });
return { content: [{ type: "text", text: `Simulation triggered: ${flow}` }] };
} catch {
return { content: [{ type: "text", text: "Dashboard not running. Use start_dashboard first." }] };
}
}
);
server.tool("create_flow",
"Add a new flow simulation to flows.yaml",
{
id: z.string().describe("Unique flow ID (snake_case)"),
name: z.string().describe("Display name"),
color: z.string().describe("Hex color for the particle"),
path: z.array(z.string()).describe("Ordered list of service names the flow traverses"),
description: z.string().optional(),
},
async ({ id, name, color, path, description }) => {
const { readFileSync, writeFileSync, existsSync } = await import("fs");
const { parse, stringify } = await import("yaml");
const file = "flows.yaml";
const config = existsSync(file) ? parse(readFileSync(file, "utf-8")) : { flows: {}, settings: {} };
config.flows[id] = {
name,
description: description || "",
color,
speed: 0.8,
path,
};
writeFileSync(file, stringify(config));
return { content: [{ type: "text", text: `Flow "${name}" created: ${path.join(" → ")}` }] };
}
);
// ── Resources ──
server.resource("services", "docker://services", async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(await docker.listContainers({ all: true }), null, 2),
}],
}));
server.resource("flows", "docker://flows", async (uri) => {
const { readFileSync, existsSync } = await import("fs");
return {
contents: [{
uri: uri.href,
mimeType: "text/yaml",
text: existsSync("flows.yaml") ? readFileSync("flows.yaml", "utf-8") : "# No flows configured",
}],
};
});
// ── Start ──
const transport = new StdioServerTransport();
await server.connect(transport);
Setup en Claude Code
# Instalar
git clone github.com/user/alteonx-dockerflow
cd alteonx-dockerflow
bun install
# Agregar MCP
claude mcp add alteonx-dockerflow -- bun run src/mcp/index.ts
# Usar
> "arranca el dashboard" → start_dashboard
> "qué containers tengo?" → list_services
> "cuánta RAM usa el backend?" → get_stats
> "crea un flow para mi login endpoint" → create_flow
> "simula el login flow" → simulate_flow
> "muéstrame los logs de redis" → get_logs
Seguridad
Comportamiento por defecto (seguro sin configurar nada)
AUTH_TOKEN |
Bind | Acceso | Uso |
|---|---|---|---|
| No definido | 127.0.0.1:9470 |
Solo local | Dev en tu máquina |
| Definido | 0.0.0.0:9470 |
Remoto (con token) | SSH a servidor, equipo |
// src/server/index.ts
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
Bun.serve({
hostname: HOST,
port: 9470,
// ...
});
Cómo funciona el auth
Sin token (default): bind a 127.0.0.1, solo accesible desde tu máquina. No pide nada.
Con token: bind a 0.0.0.0, el dashboard pide el token al entrar:
┌─────────────────────────────────────────┐
│ │
│ 🔒 Alteonx DockerFlow │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Token: •••••••••• │ │
│ └─────────────────────────────────┘ │
│ [ Entrar ] │
│ │
└─────────────────────────────────────────┘
- El token se guarda en
localStorage(no lo pide cada vez) - Toda request HTTP y conexión WebSocket valida el token
- Token inválido → 401 Unauthorized
// Middleware de auth
app.use("*", async (c, next) => {
if (!AUTH_TOKEN) return next(); // sin token = sin auth
// Skip para la página de login
if (c.req.path === "/" || c.req.path === "/auth") return next();
const token = c.req.header("Authorization")?.replace("Bearer ", "");
if (token !== AUTH_TOKEN) return c.json({ error: "Unauthorized" }, 401);
return next();
});
// WebSocket auth
websocket: {
open(ws) {
if (AUTH_TOKEN && ws.data.token !== AUTH_TOKEN) {
ws.close(1008, "Unauthorized");
return;
}
clients.add(ws);
},
}
Setup remoto (SSH)
# En el servidor
AUTH_TOKEN=mi-clave-super-segura bun run src/server/index.ts
# Desde tu máquina
# Abrir http://servidor:9470 → pide token → listo
Qué se protege
| Recurso | Sin auth | Con auth |
|---|---|---|
| Dashboard visual | Accesible (localhost) | Requiere token |
| WebSocket (stats, eventos) | Accesible (localhost) | Requiere token |
API /api/services |
Accesible (localhost) | Requiere token |
| MCP Server | Siempre local (stdio) | N/A (no pasa por HTTP) |
| Docker socket | Read-only (:ro) |
Read-only (:ro) |
Qué NO expone nunca
- Variables de entorno con
PASSWORD,SECRET,KEY,TOKEN,PRIVATEse filtran automáticamente eninspect_service - El Docker socket se monta como read-only (
:ro) — no puede crear/eliminar containers desde el dashboard - El MCP sí puede hacer
restart_serviceporque corre local y tiene approval flow de Claude Code
Colores
| Tipo | Color | Uso |
|---|---|---|
| HTTP request | #3b82f6 azul |
Partículas de requests |
| Cache | #f59e0b amarillo |
Redis hit/miss/invalidate |
| Database | #8b5cf6 violeta |
Queries a PostgreSQL/MySQL |
| Push/WebSocket | #22c55e verde |
Notificaciones, eventos real-time |
| Email/external | #ef4444 rojo |
Envíos de email, llamadas API externas |
| Background job | #ec4899 rosa |
Celery, cron, workers |
| Docker event | #64748b gris |
Container start/stop/restart |
| Nodo running | #22c55e verde |
Borde + pulso animado |
| Nodo stopped | #ef4444 rojo |
Borde estático |
Filtrado de proyectos
Por CLI (qué containers carga el backend)
| Modo | Comando | Qué carga |
|---|---|---|
| Auto (default) | bunx alteonx-dockerflow |
Solo containers del proyecto actual (detecta por directorio) |
| Multi | bunx alteonx-dockerflow --projects ninjasagacw,tonal |
Containers de proyectos específicos |
| All | bunx alteonx-dockerflow --all |
Todo lo que esté corriendo |
// src/server/index.ts
const args = process.argv.slice(2);
const ALL = args.includes("--all");
const PROJECTS = args.find((a) => a.startsWith("--projects="))?.split("=")[1]?.split(",")
|| [path.basename(process.cwd())]; // default: nombre del directorio actual
function filterServices(services: Service[]): Service[] {
if (ALL) return services;
return services.filter((s) => PROJECTS.includes(s.project));
}
Por frontend (filtro visual en vivo)
El backend siempre envía el campo project en cada servicio. El frontend tiene un dropdown con checkboxes para filtrar sin reiniciar:
┌──────────────────────────────────────────────────────┐
│ Alteonx DockerFlow [Proyecto ▼] [⚙️] │
│ ☑ ninjasagacw │
│ ☑ tonal │
│ ☑ megalabs │
│ ────────── │
│ ☑ Mostrar todos │
└──────────────────────────────────────────────────────┘
- Checkboxes por proyecto, filtra los nodos en vivo
- Selección se guarda en
localStorage - Si usaste
--allves todos los proyectos disponibles para filtrar - Si usaste modo auto, solo ves el proyecto actual (sin dropdown)
Para apagar
Ctrl+Cen la terminal- Desde MCP:
> "apaga el dashboard"→ toolstop_dashboard
Dificultad para el usuario final
| Paso | Dificultad | Tiempo |
|---|---|---|
git clone + bun install |
Trivial | 30s |
claude mcp add |
Trivial | 10s |
| "arranca el dashboard" | Trivial | 5s |
| Ver arquitectura + stats | Automático | 0s (auto-discovery) |
| Definir flows custom | Fácil (YAML o via MCP) | 2-5 min |
| Conectar probes reales | Intermedio (middleware) | 15-30 min |
Requisitos del usuario:
- Docker instalado y corriendo
- Bun instalado (
curl -fsSL https://bun.sh/install | bash) - Claude Code con MCP (opcional, puede usar sin MCP también)
Roadmap
Fase 1 — MVP (auto-discovery + stats + agrupación)
- Server Hono + Bun WebSocket
- Auto-discovery de containers desde Docker socket
- Smart edge detection (networks + heurísticas)
- Agrupación visual por compose project/file (subgraph con borde + label)
- Filtrado por proyecto: CLI (
--all,--projects, auto) + dropdown en frontend - ServiceNode con estado, imagen, puertos, stats
- Auto-layout con dagre (respetando grupos)
- Stats polling (CPU/MEM) cada 3s
- Docker events (start/stop/restart) como pulso en nodos
- Dark theme, minimap, zoom, pan
Fase 2 — Flows (partículas)
- Flow engine con partículas SVG animadas
- flows.yaml parser
- Panel de control de flows (toggle, simulate)
- Efecto glow/neón en partículas
- Trail (estela) detrás de cada partícula
Fase 3 — MCP
- MCP server con tools de Docker + flows
- start_dashboard, list_services, get_stats, get_logs, restart
- list_flows, simulate_flow, create_flow
- Resources: docker://services, docker://flows
- README con instrucciones de setup
Fase 4 — Polish
- Click en nodo → panel lateral con logs en vivo
- Modo "demo" / auto-simulate para presentaciones
- Responsive (funcione en tablet)
- Export PNG/SVG del grafo actual
- Customizar posiciones de nodos (drag + guardar layout)
Fase 5 — Avanzado
- Probe middleware genérico (npm package para Express/Fastify/Hono)
- Probe middleware genérico (pip package para FastAPI/Django)
- Eventos reales → partículas automáticas
- Health check status en nodos (healthy/unhealthy/starting)
- Métricas históricas (SQLite para mini-gráficas en cada nodo)
- Alertas cuando un container se cae (webhook/push)
- Multi-host (Docker Swarm / remote Docker sockets)