35 KiB
Alteonx DockerFlow
Herramienta open source para visualizar arquitecturas Docker en tiempo real.
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 | CSS transitions + keyframes | 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 │
│ │ │ • inspect_service │
│ Docker watcher: │ │ • get_networks │
│ • listContainers │ │ │
│ • getEvents stream │ │ Resources: │
│ • stats polling │ │ • docker://services │
│ │ │ │
│ Para: humanos 👀 │ │ │
└──────────────────────┘ │ │
│ 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
│ ├── mcp/
│ │ └── index.ts # MCP server (stdio)
│ ├── client/
│ │ ├── App.tsx # React Flow canvas
│ │ ├── main.tsx # Entry point
│ │ ├── nodes/
│ │ │ ├── ServiceNode.tsx # Nodo de container
│ │ │ └── GroupNode.tsx # Nodo grupo (compose project)
│ │ ├── panels/
│ │ │ └── DetailPanel.tsx # Panel lateral de detalles
│ │ ├── hooks/
│ │ │ ├── useDocker.ts # WebSocket hook
│ │ │ └── useStatsStore.ts# Stats por nodo (useSyncExternalStore)
│ │ └── engine/
│ │ └── layout.ts # Auto-layout
│ └── shared/
│ └── types.ts # Tipos compartidos server/client
├── 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 {}
});
});
}
Cada start/stop/restart se ve como un pulso animado (flash) 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;
}
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";
const app = new Hono();
// 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()));
// 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) {
// Handle subscribe_logs, unsubscribe_logs, etc.
},
},
});
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);
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) }] };
}
);
// ── 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),
}],
}));
// ── 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
> "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 |
|---|---|---|
| Nodo running | #22c55e verde |
Borde + pulso animado |
| Nodo stopped | #ef4444 rojo |
Borde estático |
| Nodo paused | #f59e0b amarillo |
Borde estático |
| Docker event start | #22c55e verde |
Flash en nodo |
| Docker event stop | #ef4444 rojo |
Flash en nodo |
| Docker event restart | #f59e0b amarillo |
Flash en nodo |
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) |
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 — MCP
- MCP server con tools de Docker
- start_dashboard, list_services, get_stats, get_logs, restart
- inspect_service, get_networks
- Resources: docker://services
- README con instrucciones de setup
Fase 3 — Polish
- Click en nodo → panel lateral con logs en vivo
- Notificaciones y alertas
- Responsive (funcione en tablet)
- Export PNG/SVG del grafo actual
- Customizar posiciones de nodos (drag + guardar layout)
Fase 4 — Avanzado
- 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)