2026-03-22 09:53:08 +00:00
# Alteonx DockerFlow
2026-05-02 03:58:56 +00:00
Herramienta open source para visualizar arquitecturas Docker en tiempo real.
2026-03-22 09:53:08 +00:00
```
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 |
2026-05-02 03:58:56 +00:00
| **Animaciones** | CSS transitions + keyframes | Sin librerías extra, GPU-accelerated, zero overhead |
2026-03-22 09:53:08 +00:00
| **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)
```json
{
"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 │
2026-05-02 03:58:56 +00:00
│ │ │ • inspect_service │
│ Docker watcher: │ │ • get_networks │
│ • listContainers │ │ │
│ • getEvents stream │ │ Resources: │
│ • stats polling │ │ • docker://services │
│ │ │ │
│ Para: humanos 👀 │ │ │
2026-03-22 09:53:08 +00:00
└──────────────────────┘ │ │
│ Para: Claude Code 🤖 │
└──────────────────────────┘
```
---
## Estructura del proyecto
```
alteonx-dockerflow/
├── src/
│ ├── server/
│ │ ├── index.ts # Hono server + WebSocket
│ │ ├── docker.ts # Docker API wrapper (dockerode)
2026-05-02 03:58:56 +00:00
│ │ └── watcher.ts # Docker events stream + stats polling
2026-03-22 09:53:08 +00:00
│ ├── mcp/
│ │ └── index.ts # MCP server (stdio)
│ ├── client/
│ │ ├── App.tsx # React Flow canvas
│ │ ├── main.tsx # Entry point
│ │ ├── nodes/
│ │ │ ├── ServiceNode.tsx # Nodo de container
2026-05-02 03:58:56 +00:00
│ │ │ └── GroupNode.tsx # Nodo grupo (compose project)
2026-03-22 09:53:08 +00:00
│ │ ├── panels/
2026-05-02 03:58:56 +00:00
│ │ │ └── DetailPanel.tsx # Panel lateral de detalles
2026-03-22 09:53:08 +00:00
│ │ ├── hooks/
│ │ │ ├── useDocker.ts # WebSocket hook
2026-05-02 03:58:56 +00:00
│ │ │ └── useStatsStore.ts# Stats por nodo (useSyncExternalStore)
2026-03-22 09:53:08 +00:00
│ │ └── engine/
2026-05-02 03:58:56 +00:00
│ │ └── layout.ts # Auto-layout
2026-03-22 09:53:08 +00:00
│ └── 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:
```ts
// 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:**
```ts
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:**
```tsx
// 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:
```ts
// 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)
```ts
// 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 {}
});
});
}
```
2026-05-02 03:58:56 +00:00
Cada start/stop/restart se ve como un pulso animado (flash) en el nodo.
2026-03-22 09:53:08 +00:00
### 4. Stats polling
```ts
// 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)
```tsx
// 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)
```ts
// 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 ) {
2026-05-02 03:58:56 +00:00
// Handle subscribe_logs, unsubscribe_logs, etc.
2026-03-22 09:53:08 +00:00
},
},
});
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
```ts
// 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
```bash
# 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 |
```ts
// 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
```ts
// 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)
```bash
# 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` , `PRIVATE` se filtran automáticamente en `inspect_service`
- El Docker socket se monta como read-only (`:ro` ) — no puede crear/eliminar containers desde el dashboard
- El MCP sí puede hacer `restart_service` porque 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 |
2026-05-02 03:58:56 +00:00
| 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 |
2026-03-22 09:53:08 +00:00
---
## 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 |
```ts
// 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 `--all` ves todos los proyectos disponibles para filtrar
- Si usaste modo auto, solo ves el proyecto actual (sin dropdown)
### Para apagar
- `Ctrl+C` en la terminal
- Desde MCP: `> "apaga el dashboard"` → tool `stop_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
2026-05-02 03:58:56 +00:00
### Fase 2 — MCP
- [ ] MCP server con tools de Docker
2026-03-22 09:53:08 +00:00
- [ ] start_dashboard, list_services, get_stats, get_logs, restart
2026-05-02 03:58:56 +00:00
- [ ] inspect_service, get_networks
- [ ] Resources: docker://services
2026-03-22 09:53:08 +00:00
- [ ] README con instrucciones de setup
2026-05-02 03:58:56 +00:00
### Fase 3 — Polish
2026-03-22 09:53:08 +00:00
- [ ] Click en nodo → panel lateral con logs en vivo
2026-05-02 03:58:56 +00:00
- [ ] Notificaciones y alertas
2026-03-22 09:53:08 +00:00
- [ ] Responsive (funcione en tablet)
- [ ] Export PNG/SVG del grafo actual
- [ ] Customizar posiciones de nodos (drag + guardar layout)
2026-05-02 03:58:56 +00:00
### Fase 4 — Avanzado
2026-03-22 09:53:08 +00:00
- [ ] 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)