From 8703f2885630ef475db0b98d0b3fad47d3b7e405 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Sun, 22 Mar 2026 09:53:08 +0000 Subject: [PATCH] v0.0.1 --- .env.example | 6 + .gitignore | 5 + README.md | 115 ++ bun.lock | 558 +++++++++ docker-project.md | 1255 ++++++++++++++++++++ package.json | 35 + src/client/App.tsx | 744 ++++++++++++ src/client/engine/layout.ts | 358 ++++++ src/client/favicon.png | Bin 0 -> 2164 bytes src/client/hooks/useDocker.ts | 138 +++ src/client/index.css | 81 ++ src/client/index.html | 13 + src/client/main.tsx | 5 + src/client/nodes/GroupNode.tsx | 73 ++ src/client/nodes/ServiceNode.tsx | 214 ++++ src/client/panels/LogPanel.tsx | 141 +++ src/client/public/alteonx-logo.png | Bin 0 -> 121741 bytes src/server/docker.ts | 245 ++++ src/server/index.ts | 202 ++++ src/server/watcher.ts | 71 ++ src/shared/types.ts | 50 + tareas/completadas/01-project-setup.md | 25 + tareas/completadas/02-docker-discovery.md | 21 + tareas/completadas/03-websocket-stats.md | 24 + tareas/completadas/04-service-node.md | 21 + tareas/completadas/05-react-flow-layout.md | 26 + tareas/completadas/06-project-filter.md | 23 + tareas/completadas/07-security.md | 23 + tareas/completadas/08-polish-fase1.md | 29 + tsconfig.json | 20 + vite.config.ts | 22 + 31 files changed, 4543 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 docker-project.md create mode 100644 package.json create mode 100644 src/client/App.tsx create mode 100644 src/client/engine/layout.ts create mode 100644 src/client/favicon.png create mode 100644 src/client/hooks/useDocker.ts create mode 100644 src/client/index.css create mode 100644 src/client/index.html create mode 100644 src/client/main.tsx create mode 100644 src/client/nodes/GroupNode.tsx create mode 100644 src/client/nodes/ServiceNode.tsx create mode 100644 src/client/panels/LogPanel.tsx create mode 100644 src/client/public/alteonx-logo.png create mode 100644 src/server/docker.ts create mode 100644 src/server/index.ts create mode 100644 src/server/watcher.ts create mode 100644 src/shared/types.ts create mode 100644 tareas/completadas/01-project-setup.md create mode 100644 tareas/completadas/02-docker-discovery.md create mode 100644 tareas/completadas/03-websocket-stats.md create mode 100644 tareas/completadas/04-service-node.md create mode 100644 tareas/completadas/05-react-flow-layout.md create mode 100644 tareas/completadas/06-project-filter.md create mode 100644 tareas/completadas/07-security.md create mode 100644 tareas/completadas/08-polish-fase1.md create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69f5b22 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Puerto del servidor (por defecto: 9470) +PORT=9470 + +# Token de autenticacion — dejar vacio para acceso solo en localhost (sin login) +# Poner un valor para activar auth + acceso remoto (0.0.0.0) +AUTH_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6f2cf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.env +.dockerflow-positions.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..17c9e33 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# DockerFlow AlteonX + +Visualizador en tiempo real de arquitecturas Docker. Muestra servicios, conexiones y metricas de todos tus proyectos Docker Compose en un dashboard interactivo. + +## Requisitos + +- [Bun](https://bun.sh) >= 1.0 +- Docker corriendo con acceso al socket (`/var/run/docker.sock`) + +## Instalacion + +```bash +git clone https://github.com/RGJorge/alteonx-dockerflow.git +cd alteonx-dockerflow +bun install +``` + +## Configuracion + +Copiar el archivo de ejemplo y editar: + +```bash +cp .env.example .env +``` + +Variables disponibles: + +| Variable | Default | Descripcion | +|---|---|---| +| `PORT` | `9470` | Puerto del servidor | +| `AUTH_TOKEN` | _(vacio)_ | Token de autenticacion. Vacio = sin auth, solo localhost. Con valor = auth activado, acceso remoto | + +## Uso + +### Desarrollo (hot reload) + +```bash +bun run dev +``` + +Abre `http://localhost:5173` (Vite proxy → backend en puerto 9470). + +### Produccion + +```bash +bun run build +bun run start +``` + +Abre `http://localhost:9470`. + +### Modos de visualizacion + +```bash +# Ver TODOS los containers Docker +bun run start -- --all + +# Ver solo proyectos especificos +bun run start -- --projects=mi-proyecto,otro-proyecto + +# Auto-detectar desde el directorio actual +bun run start +``` + +## Funcionalidades + +- **Descubrimiento automatico** — detecta servicios via Docker socket, agrupa por proyecto o compose file +- **Conexiones inteligentes** — detecta relaciones app→database, app→cache, proxy→app, worker→broker +- **Metricas en tiempo real** — CPU y memoria por container, actualizado cada 3 segundos +- **Eventos Docker** — flash visual cuando un container inicia, para o reinicia +- **Filtro de proyectos** — dropdown para mostrar/ocultar proyectos, persiste entre sesiones +- **Autenticacion** — pantalla de login con AUTH_TOKEN para acceso remoto seguro +- **Leyenda de conexiones** — colores por tipo: Database (azul), Cache (rojo), Broker (naranja), Proxy (verde) +- **Grupos visuales** — recuadros por proyecto/compose con titulo, archivo compose y conteo de containers +- **Tooltips** — hover sobre cada nodo para ver estado, imagen, ID y puertos + +## Stack + +| Componente | Tecnologia | +|---|---| +| Runtime | Bun | +| Server | Hono | +| Frontend | React 19 + Vite 6 | +| Grafos | @xyflow/react 12 | +| Estilos | Tailwind CSS 4 | +| Iconos | Lucide React | +| Docker API | dockerode | +| Comunicacion | WebSocket nativo | + +## Estructura + +``` +src/ + server/ + index.ts — servidor Hono + WebSocket + CLI args + docker.ts — descubrimiento de servicios y conexiones + watcher.ts — polling de stats + stream de eventos Docker + client/ + App.tsx — dashboard principal + login screen + main.tsx — entry point React + index.css — Tailwind + animaciones custom + nodes/ + ServiceNode.tsx — nodo visual por container + GroupNode.tsx — header de grupo (proyecto/compose) + hooks/ + useDocker.ts — hook WebSocket para datos en tiempo real + engine/ + layout.ts — layout de grupos + grid + edges + shared/ + types.ts — tipos compartidos server/client +``` + +## Licencia + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e3226b3 --- /dev/null +++ b/bun.lock @@ -0,0 +1,558 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "alteonx-dockerflow", + "dependencies": { + "dockerode": "^4", + "hono": "^4", + "lucide-react": "^0.577.0", + "yaml": "^2", + "zod": "^3", + }, + "devDependencies": { + "@dagrejs/dagre": "^1", + "@tailwindcss/vite": "^4", + "@types/dockerode": "^3", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4", + "@xyflow/react": "^12", + "concurrently": "^9", + "react": "^19", + "react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5", + "vite": "^6", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], + + "@dagrejs/dagre": ["@dagrejs/dagre@1.1.8", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.1", "", { "os": "android", "cpu": "arm" }, "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.1", "", { "os": "android", "cpu": "arm64" }, "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.1", "", { "os": "linux", "cpu": "arm" }, "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.1", "", { "os": "linux", "cpu": "none" }, "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.1", "", { "os": "linux", "cpu": "none" }, "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.1", "", { "os": "linux", "cpu": "none" }, "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.1", "", { "os": "linux", "cpu": "none" }, "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.1", "", { "os": "win32", "cpu": "x64" }, "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], + + "@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@xyflow/react": ["@xyflow/react@12.10.1", "", { "dependencies": { "@xyflow/system": "0.0.75", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q=="], + + "@xyflow/system": ["@xyflow/system@0.0.75", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="], + + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="], + + "dockerode": ["dockerode@4.0.10", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nan": ["nan@2.26.2", "", {}, "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "rollup": ["rollup@4.59.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.1", "@rollup/rollup-android-arm64": "4.59.1", "@rollup/rollup-darwin-arm64": "4.59.1", "@rollup/rollup-darwin-x64": "4.59.1", "@rollup/rollup-freebsd-arm64": "4.59.1", "@rollup/rollup-freebsd-x64": "4.59.1", "@rollup/rollup-linux-arm-gnueabihf": "4.59.1", "@rollup/rollup-linux-arm-musleabihf": "4.59.1", "@rollup/rollup-linux-arm64-gnu": "4.59.1", "@rollup/rollup-linux-arm64-musl": "4.59.1", "@rollup/rollup-linux-loong64-gnu": "4.59.1", "@rollup/rollup-linux-loong64-musl": "4.59.1", "@rollup/rollup-linux-ppc64-gnu": "4.59.1", "@rollup/rollup-linux-ppc64-musl": "4.59.1", "@rollup/rollup-linux-riscv64-gnu": "4.59.1", "@rollup/rollup-linux-riscv64-musl": "4.59.1", "@rollup/rollup-linux-s390x-gnu": "4.59.1", "@rollup/rollup-linux-x64-gnu": "4.59.1", "@rollup/rollup-linux-x64-musl": "4.59.1", "@rollup/rollup-openbsd-x64": "4.59.1", "@rollup/rollup-openharmony-arm64": "4.59.1", "@rollup/rollup-win32-arm64-msvc": "4.59.1", "@rollup/rollup-win32-ia32-msvc": "4.59.1", "@rollup/rollup-win32-x64-gnu": "4.59.1", "@rollup/rollup-win32-x64-msvc": "4.59.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], + + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + + "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + } +} diff --git a/docker-project.md b/docker-project.md new file mode 100644 index 0000000..394fd52 --- /dev/null +++ b/docker-project.md @@ -0,0 +1,1255 @@ +# 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) +```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 │ +│ │ │ • 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: + +```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(); + + 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 {} + }); + }); +} +``` + +**Partículas gratis:** cada start/stop/restart se ve como un pulso animado en el nodo. + +### 4. Stats polling + +```ts +// src/server/watcher.ts +export async function pollStats(services: Service[]): Promise { + 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` + +```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 + +```tsx +// 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) + +```tsx +// 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 ( + <> + + + + + + + + + {particles.map((p) => ( + + {/* Estela */} + + + + {/* Partícula principal */} + + + + {/* Label */} + {p.label && ( + + + {p.label} + + )} + + ))} + + + ); +} +``` + +### 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) + +```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 = { + 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 ( +
+ + + {/* Status dot + name */} +
+
+ {icon} {data.label} +
+ + {/* Image */} +
{data.image}
+ + {/* Ports */} + {data.ports?.length > 0 && ( +
+ {data.ports.map((p) => ( + + :{p} + + ))} +
+ )} + + {/* Stats bar */} + {data.stats && ( +
+
+ CPU {data.stats.cpu.toFixed(1)}% + MEM {data.stats.mem_mb.toFixed(0)}MB +
+
+
+
+
+ )} + + {/* Compose project badge */} + {data.project && ( +
+ + {data.project} + +
+ )} + + +
+ ); +} +``` + +--- + +## 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"; +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(); + +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 + +```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) }] }; + } +); + +// ── 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 +```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 +> "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 | + +```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 | +|---|---|---| +| 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 | + +```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) | +| 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 +- [ ] Labels flotantes en partículas + +### 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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..a50bd53 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "alteonx-dockerflow", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "concurrently \"bun run dev:server -- --all\" \"bun run dev:client\"", + "dev:server": "bun --watch src/server/index.ts", + "dev:client": "vite", + "build": "vite build", + "start": "bun run src/server/index.ts --all", + "preview": "bun run build && bun run start" + }, + "dependencies": { + "dockerode": "^4", + "hono": "^4", + "lucide-react": "^0.577.0", + "yaml": "^2", + "zod": "^3" + }, + "devDependencies": { + "@types/dockerode": "^3", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4", + "concurrently": "^9", + "react": "^19", + "react-dom": "^19", + "@xyflow/react": "^12", + "@dagrejs/dagre": "^1", + "tailwindcss": "^4", + "@tailwindcss/vite": "^4", + "typescript": "^5", + "vite": "^6" + } +} diff --git a/src/client/App.tsx b/src/client/App.tsx new file mode 100644 index 0000000..4f4ffa7 --- /dev/null +++ b/src/client/App.tsx @@ -0,0 +1,744 @@ +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { + ReactFlow, + Background, + Controls, + MiniMap, + SmoothStepEdge, + useNodesState, + useEdgesState, + type Node, + type Edge, + type EdgeProps, + type NodeChange, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { Wifi, WifiOff, ChevronDown, Check, Lock, LogOut, Eye, EyeOff, Terminal, Database, Zap, Radio, Globe } from "lucide-react"; + +import { ServiceNode } from "./nodes/ServiceNode"; +import { GroupNode } from "./nodes/GroupNode"; +import { useDocker } from "./hooks/useDocker"; +import { buildLayout, computeEdges } from "./engine/layout"; +import { LogPanel } from "./panels/LogPanel"; +import type { Service } from "../shared/types"; + +function OffsetEdge(props: EdgeProps) { + const offset = (props.data as any)?.offset ?? 0; + return ; +} + +const nodeTypes = { service: ServiceNode, group: GroupNode }; +const edgeTypes = { offsetSmooth: OffsetEdge }; + +function loadFilter(): Set { + try { + const raw = localStorage.getItem("df:filter"); + if (raw) return new Set(JSON.parse(raw)); + } catch {} + return new Set(); +} + +function getToken(): string { + return localStorage.getItem("df:token") || ""; +} + +function LoginScreen({ onAuth }: { onAuth: (token: string) => void }) { + const [token, setToken] = useState(""); + const [error, setError] = useState(""); + const [showToken, setShowToken] = useState(false); + const [connecting, setConnecting] = useState(false); + const [connected, setConnected] = useState(false); + const [logLines, setLogLines] = useState([]); + + 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 ( +
+
+ {/* Logo + Title */} + Alteonx +
+

DockerFlow

+ AlteonX +
+ + {/* Form */} +
+
+ + { 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} + /> + +
+ {error && {error}} + +
+ + {/* Terminal log */} + {logLines.length > 0 && ( +
+ {logLines.map((line, i) => ( +
+ {line} + {i === logLines.length - 1 && !connected && ( + + )} +
+ ))} +
+ )} +
+
+ ); +} + +export default function App() { + const [authToken, setAuthToken] = useState(null); + const [needsAuth, setNeedsAuth] = useState(null); + + // Check if auth is required + useEffect(() => { + fetch("/api/health").then((r) => { + if (r.ok) { + setNeedsAuth(false); + setAuthToken(""); + } else if (r.status === 401) { + const saved = getToken(); + if (saved) { + fetch("/api/health", { headers: { Authorization: `Bearer ${saved}` } }).then((r2) => { + if (r2.ok) { setAuthToken(saved); setNeedsAuth(false); } + else { localStorage.removeItem("df:token"); setNeedsAuth(true); } + }); + } else { + setNeedsAuth(true); + } + } + }).catch(() => setNeedsAuth(false)); + }, []); + + if (needsAuth === null) return
; + if (needsAuth) return { setAuthToken(t); setNeedsAuth(false); }} />; + + return ; +} + +function Dashboard({ token }: { token: string }) { + const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines } = useDocker(token); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const initialLayoutDone = useRef(false); + const savedPositions = useRef>({}); + const [hiddenProjects, setHiddenProjects] = useState>(loadFilter); + const [filterOpen, setFilterOpen] = useState(false); + const filterRef = useRef(null); + const [selectedNode, setSelectedNode] = useState(null); + const [logPanelService, setLogPanelService] = useState(null); + const reactFlowRef = useRef(null); + + // Fit view when log panel opens/closes so graph adjusts to available space + useEffect(() => { + if (reactFlowRef.current) { + // Small delay to let the DOM resize first + setTimeout(() => { + reactFlowRef.current?.fitView({ padding: 0.3, duration: 300 }); + }, 50); + } + }, [logPanelService]); + + // Load saved positions from server on mount + useEffect(() => { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/positions", { headers }) + .then((r) => r.json()) + .then((data) => { savedPositions.current = data || {}; }) + .catch(() => {}); + }, [token]); + + // Save positions to server (debounced) + const saveTimer = useRef>(undefined); + const savePositions = useCallback((nodes: Node[]) => { + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + const positions: Record = {}; + for (const n of nodes) { + positions[n.id] = { x: n.position.x, y: n.position.y }; + } + savedPositions.current = positions; + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/positions", { + method: "PUT", + headers, + body: JSON.stringify(positions), + }).catch(() => {}); + }, 500); + }, [token]); + + // Resize groups and clamp child positions + const NODE_W = 240; + const NODE_H = 160; + const G_PAD = 28; + const G_HEADER = 44; + const MIN_X = G_PAD; + const MIN_Y = G_HEADER + G_PAD; + + const handleNodesChange = useCallback((changes: NodeChange[]) => { + onNodesChange(changes); + + const hasPositionChange = changes.some((c) => c.type === "position"); + if (!hasPositionChange) return; + + const isDragEnd = changes.some((c) => c.type === "position" && (c as any).dragging === false); + + setNodes((prev) => { + let changed = false; + let nodes = [...prev]; + + // 1. Clamp Y only (prevent going above header), allow X freely + nodes = nodes.map((n) => { + if (!n.parentId) return n; + const clampedY = Math.max(MIN_Y, n.position.y); + if (clampedY !== n.position.y) { + changed = true; + return { ...n, position: { x: n.position.x, y: clampedY } }; + } + return n; + }); + + // 2. For each group, keep leftmost child at MIN_X — shift group + children to match + const groupIds = [...new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!))]; + for (const gid of groupIds) { + const kids = nodes.filter((n) => n.parentId === gid); + const minChildX = Math.min(...kids.map((k) => k.position.x)); + if (minChildX !== MIN_X) { + const shift = minChildX - MIN_X; // positive = children too far right, negative = too far left + changed = true; + nodes = nodes.map((n) => { + if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } }; + if (n.parentId === gid) return { ...n, position: { x: n.position.x - shift, y: n.position.y } }; + return n; + }); + } + } + + // 3. Resize groups to fit children (grows AND shrinks) + nodes = nodes.map((n) => { + if (!n.id.startsWith("group-")) return n; + const kids = nodes.filter((c) => c.parentId === n.id); + if (kids.length === 0) return n; + + let maxRight = 0; + let maxBottom = 0; + for (const k of kids) { + maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); + maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); + } + + const minW = NODE_W + G_PAD * 3; + const newW = Math.max(maxRight, minW); + const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); + + const curW = (n.style?.width as number) || 0; + const curH = (n.style?.height as number) || 0; + + if (newW !== curW || newH !== curH) { + changed = true; + return { ...n, style: { ...n.style, width: newW, height: newH } }; + } + return n; + }); + + if (isDragEnd) savePositions(nodes); + return changed ? nodes : prev; + }); + }, [onNodesChange, setNodes, savePositions]); + + const projects = useMemo(() => [...new Set(services.map((s) => s.project))].sort(), [services]); + + const toggleProject = (p: string) => { + setHiddenProjects((prev) => { + const next = new Set(prev); + if (next.has(p)) next.delete(p); + else next.add(p); + localStorage.setItem("df:filter", JSON.stringify([...next])); + return next; + }); + }; + + // Close dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (filterRef.current && !filterRef.current.contains(e.target as HTMLElement)) { + setFilterOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const filteredServices = useMemo( + () => services.filter((s) => !hiddenProjects.has(s.project)), + [services, hiddenProjects] + ); + + const filteredConnections = useMemo( + () => { + const uids = new Set(filteredServices.map((s) => s.uid)); + return connections.filter((c) => uids.has(c.from) && uids.has(c.to)); + }, + [connections, filteredServices] + ); + + // Build layout when data changes + useEffect(() => { + if (filteredServices.length === 0) { + setNodes([]); + setEdges([]); + return; + } + + const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats); + + if (!initialLayoutDone.current) { + // Apply saved positions to all nodes (groups + services) + let positioned = newNodes.map((n) => { + const saved = savedPositions.current[n.id]; + if (saved) return { ...n, position: saved }; + return n; + }); + // Recalculate group sizes based on actual child positions + positioned = positioned.map((n) => { + if (n.type !== "group") return n; + const kids = positioned.filter((c) => c.parentId === n.id); + if (kids.length === 0) return n; + let maxRight = 0; + let maxBottom = 0; + for (const k of kids) { + maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); + maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); + } + const minW = NODE_W + G_PAD * 3; + const newW = Math.max(maxRight, minW); + const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); + return { ...n, style: { ...n.style, width: newW, height: newH } }; + }); + // Compute edges + activeHandles based on positioned nodes + const { edges, activeHandles } = computeEdges(positioned, filteredConnections); + for (const n of positioned) { + if (n.type === "service") { + (n.data as any).activeHandles = activeHandles.get(n.id) || []; + } + } + setNodes(positioned); + setEdges(edges); + initialLayoutDone.current = true; + } else { + setNodes((prev) => { + // Keep existing nodes, update data only + const updated = prev.map((n) => { + const u = newNodes.find((nn) => nn.id === n.id); + if (!u) return null; + return { ...n, data: u.data }; + }).filter(Boolean) as Node[]; + + const existingIds = new Set(updated.map((n) => n.id)); + const brand = newNodes.filter((n) => !existingIds.has(n.id)); + + if (brand.length === 0) return updated; + + // Apply saved positions to brand-new nodes (e.g. re-enabled project filter) + const hasSaved = brand.some((n) => savedPositions.current[n.id]); + if (hasSaved) { + let positioned = brand.map((n) => { + const saved = savedPositions.current[n.id]; + if (saved) return { ...n, position: saved }; + return n; + }); + // Recalculate group sizes for restored nodes + positioned = positioned.map((n) => { + if (n.type !== "group") return n; + const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id); + if (kids.length === 0) return n; + let maxRight = 0; + let maxBottom = 0; + for (const k of kids) { + maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); + maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); + } + const minW = NODE_W + G_PAD * 3; + const newW = Math.max(maxRight, minW); + const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); + return { ...n, style: { ...n.style, width: newW, height: newH } }; + }); + return [...updated, ...positioned]; + } + + // Find rightmost edge of existing groups to place new ones after + let maxRightX = 0; + for (const n of updated) { + if (n.type === "group") { + const w = (n.style?.width as number) || NODE_W + G_PAD * 3; + maxRightX = Math.max(maxRightX, n.position.x + w); + } + } + + // Offset new groups so they appear to the right + const newGroups = brand.filter((n) => n.type === "group"); + const offsetX = maxRightX > 0 ? maxRightX + 50 - (newGroups[0]?.position.x || 0) : 0; + + const positioned = brand.map((n) => { + if (n.type === "group" && offsetX > 0) { + return { ...n, position: { x: n.position.x + offsetX, y: n.position.y } }; + } + return n; + }); + + return [...updated, ...positioned]; + }); + } + }, [filteredServices, filteredConnections, statsVersion]); + + // Recompute edges + handles whenever nodes move + useEffect(() => { + if (nodes.length === 0 || filteredConnections.length === 0) return; + const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections); + setEdges(newEdges); + // Update activeHandles on nodes + setNodes((prev) => + prev.map((n) => { + if (n.type !== "service") return n; + const handles = activeHandles.get(n.id) || []; + const current = (n.data as any).activeHandles || []; + // Skip if unchanged + if (handles.length === current.length && handles.every((h: string, i: number) => h === current[i])) return n; + return { ...n, data: { ...n.data, activeHandles: handles } }; + }) + ); + }, [nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","), filteredConnections]); + + // Flash nodes on Docker events + useEffect(() => { + if (events.length === 0) return; + const latest = events[events.length - 1]!; + const flashClass = + latest.action === "start" + ? "flash-start" + : latest.action === "die" || latest.action === "stop" + ? "flash-stop" + : latest.action === "restart" + ? "flash-restart" + : ""; + + if (!flashClass) return; + + setNodes((prev) => + prev.map((n) => + n.id === latest.service ? { ...n, data: { ...n.data, flash: flashClass } } : n + ) + ); + + setTimeout(() => { + setNodes((prev) => + prev.map((n) => + n.id === latest.service ? { ...n, data: { ...n.data, flash: "" } } : n + ) + ); + }, 1200); + }, [events]); + + const runningCount = filteredServices.filter((s) => s.state === "running").length; + + // Highlight edges connected to selected node, dim the rest + const connectedNodeIds = useMemo(() => { + if (!selectedNode) return null; + const ids = new Set([selectedNode]); + for (const e of edges) { + if (e.source === selectedNode) ids.add(e.target); + if (e.target === selectedNode) ids.add(e.source); + } + return ids; + }, [selectedNode, edges]); + + const styledEdges = useMemo(() => { + if (!selectedNode) return edges; + return edges.map((e) => { + const isConnected = e.source === selectedNode || e.target === selectedNode; + return { + ...e, + style: { + ...e.style, + opacity: isConnected ? 1 : 0.08, + strokeWidth: isConnected ? 2.5 : 1, + }, + }; + }); + }, [edges, selectedNode]); + + const styledNodes = useMemo(() => { + if (!connectedNodeIds) return nodes; + return nodes.map((n) => { + if (n.type !== "service") return n; + const isConnected = connectedNodeIds.has(n.id); + const isSelected = n.id === selectedNode; + return { + ...n, + style: { ...n.style, opacity: isConnected ? 1 : 0.3 }, + data: { ...n.data, highlighted: isSelected || isConnected }, + }; + }); + }, [nodes, connectedNodeIds, selectedNode]); + + return ( +
+ {/* Header */} +
+
+
+ Alteonx + + DockerFlow + + + AlteonX + +
+ + v0.1 + +
+ +
+ {/* Project filter dropdown */} + {projects.length > 1 && ( +
+ + {filterOpen && ( +
+ {projects.map((p) => { + const active = !hiddenProjects.has(p); + const count = services.filter((s) => s.project === p).length; + return ( + + ); + })} +
+ )} +
+ )} + + {/* Stats */} + + {runningCount} + /{filteredServices.length} + containers + + + {/* Connection status */} +
+ {connected ? ( + + ) : ( + + )} + + {connected ? "Live" : "Offline"} + +
+ + {/* Logout (only if auth is active) */} + {token && ( + + )} +
+
+ + {/* Canvas */} +
+ { reactFlowRef.current = instance; }} + nodes={styledNodes} + edges={styledEdges} + onNodesChange={handleNodesChange} + onEdgesChange={onEdgesChange} + onNodeClick={(_e, node) => { + if (node.type === "service") { + setSelectedNode(node.id); + const svc = filteredServices.find((s) => s.uid === node.id); + if (svc) setLogPanelService(svc); + } else { + setSelectedNode(null); + } + }} + onNodeDragStop={() => setSelectedNode(null)} + onPaneClick={() => { setSelectedNode(null); setLogPanelService(null); }} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + fitView + fitViewOptions={{ padding: 0.3 }} + minZoom={0.2} + maxZoom={2.5} + proOptions={{ hideAttribution: true }} + > + + + + {/* Edge legend */} +
+ Conexiones + {[ + { 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 }) => ( +
+
+ + {label} +
+ ))} +
+ + { + const state = (n.data as any)?.state; + if (state === "running") return "#22c55e"; + if (state === "exited" || state === "dead") return "#ef4444"; + return "#f59e0b"; + }} + style={{ background: "#0f172a" }} + /> + +
+ + {logPanelService && ( + { setLogPanelService(null); setSelectedNode(null); }} + sendMessage={sendMessage} + clearLogLines={clearLogLines} + /> + )} +
+ ); +} diff --git a/src/client/engine/layout.ts b/src/client/engine/layout.ts new file mode 100644 index 0000000..130aac1 --- /dev/null +++ b/src/client/engine/layout.ts @@ -0,0 +1,358 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { Service, Connection, Stats } from "../../shared/types"; + +const NODE_WIDTH = 240; +const NODE_HEIGHT = 160; +const NODE_GAP_X = 36; +const NODE_GAP_Y = 36; +const GROUP_PADDING = 28; +const GROUP_HEADER = 44; +const GROUP_GAP = 50; + +function getComposeKey(file: string): string { + if (!file) return "default"; + const match = file.match(/docker-compose\.?(.*)\.yml/); + const key = match?.[1] || ""; + if (key === "") return "prod"; + return key.replace(/^\./, ""); +} + +function getGroupKey(service: Service): string { + const compose = getComposeKey(service.compose_file); + return `${service.project}/${compose}`; +} + +function getGroupLabel(key: string): string { + // "ninjasagacw/infra" → "NINJASAGACW / INFRA" + const parts = key.split("/"); + return parts.map((p) => p.toUpperCase()).join(" / "); +} + +// Color per group for visual distinction +const GROUP_COLORS: Record = { + infra: "rgba(239, 68, 68, 0.08)", + dev: "rgba(59, 130, 246, 0.08)", + prod: "rgba(34, 197, 94, 0.08)", +}; + +const GROUP_BORDER_COLORS: Record = { + infra: "rgba(239, 68, 68, 0.3)", + dev: "rgba(59, 130, 246, 0.3)", + prod: "rgba(34, 197, 94, 0.3)", +}; + +// Dynamic colors for project groups not matching known names +const DYNAMIC_COLORS = [ + { bg: "rgba(139, 92, 246, 0.08)", border: "rgba(139, 92, 246, 0.3)" }, + { bg: "rgba(6, 182, 212, 0.08)", border: "rgba(6, 182, 212, 0.3)" }, + { bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.3)" }, + { bg: "rgba(236, 72, 153, 0.08)", border: "rgba(236, 72, 153, 0.3)" }, + { bg: "rgba(16, 185, 129, 0.08)", border: "rgba(16, 185, 129, 0.3)" }, +]; +let dynamicIdx = 0; +const dynamicAssigned = new Map(); + +function getDynamicColor(key: string) { + if (!dynamicAssigned.has(key)) { + dynamicAssigned.set(key, DYNAMIC_COLORS[dynamicIdx % DYNAMIC_COLORS.length]!); + dynamicIdx++; + } + return dynamicAssigned.get(key)!; +} + +export interface LayoutResult { + nodes: Node[]; + edges: Edge[]; +} + +export function buildLayout( + services: Service[], + connections: Connection[], + statsMap: Map +): LayoutResult { + if (services.length === 0) return { nodes: [], edges: [] }; + + // Group services by project/compose_file (always consistent) + const groups = new Map(); + for (const svc of services) { + const key = getGroupKey(svc); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(svc); + } + + const nodes: Node[] = []; + + // Layout groups side by side + const MAX_COLS_PER_GROUP = 3; + let groupX = 0; + let maxGroupHeight = 0; + let groupRow = 0; + const groupPositions = new Map(); + const groupEntries = Array.from(groups.entries()); + + // Sort: infra groups first, then dev, then prod, then others + const COMPOSE_ORDER = ["infra", "dev", "prod"]; + groupEntries.sort((a, b) => { + // Sort by project first, then by compose key order + const [projA, compA] = a[0].split("/"); + const [projB, compB] = b[0].split("/"); + if (projA !== projB) return projA.localeCompare(projB); + const ai = COMPOSE_ORDER.indexOf(compA); + const bi = COMPOSE_ORDER.indexOf(compB); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + }); + + for (const [groupKey, svcs] of groupEntries) { + const cols = Math.min(svcs.length, MAX_COLS_PER_GROUP); + const rows = Math.ceil(svcs.length / cols); + const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X; + const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y; + const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3); + const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_HEADER + GROUP_PADDING; + + groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight }); + + if (groupHeight > maxGroupHeight) maxGroupHeight = groupHeight; + + // Color based on compose part (infra/dev/prod), not full key + const composePart = groupKey.split("/")[1] || groupKey; + const knownBg = GROUP_COLORS[composePart]; + const knownBorder = GROUP_BORDER_COLORS[composePart]; + const dynamic = !knownBg ? getDynamicColor(groupKey) : null; + const bgColor = knownBg || dynamic!.bg; + const borderColor = knownBorder || dynamic!.border; + + // Compose file subtitle — show unique compose files in this group + const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))] + .map((f) => f.split("/").pop() || "") + .filter(Boolean); + const subtitle = composeFiles.join(", "); + + // Group node + nodes.push({ + id: `group-${groupKey}`, + type: "group", + position: { x: groupX, y: 0 }, + data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length }, + style: { + width: groupWidth, + height: groupHeight, + border: `1px dashed ${borderColor}`, + borderRadius: 16, + background: bgColor, + padding: 0, + fontSize: 13, + fontWeight: 600, + color: borderColor.replace("0.3", "0.8"), + }, + }); + + // Service nodes inside group (grid layout) + svcs.forEach((svc, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + const x = GROUP_PADDING + col * (NODE_WIDTH + NODE_GAP_X); + const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y); + + nodes.push({ + id: svc.uid, + type: "service", + parentId: `group-${groupKey}`, + position: { x, y }, + data: { + ...svc, + label: svc.name, + stats: statsMap.get(svc.uid) || null, + }, + }); + }); + + groupX += groupWidth + GROUP_GAP; + } + + return { nodes, edges: [] }; +} + +// ── Recompute edges + activeHandles based on current node positions ── + +const EDGE_COLORS: Record = { + database: "#336791", + cache: "#F59E0B", + broker: "#A855F7", + proxy: "#22C55E", +}; + +export function computeEdges( + currentNodes: Node[], + connections: Connection[] +): { edges: Edge[]; activeHandles: Map } { + // Build absolute positions from current nodes + const absPositions = new Map(); + for (const n of currentNodes) { + if (n.parentId) { + const parent = currentNodes.find((p) => p.id === n.parentId); + if (parent) { + absPositions.set(n.id, { + x: parent.position.x + n.position.x + NODE_WIDTH / 2, + y: parent.position.y + n.position.y + NODE_HEIGHT / 2, + }); + } + } + } + + function bestSide(fromId: string, toId: string): { sourceSide: string; targetSide: string } { + const from = absPositions.get(fromId); + const to = absPositions.get(toId); + if (!from || !to) return { sourceSide: "bottom", targetSide: "top" }; + + const dx = to.x - from.x; + const dy = to.y - from.y; + + if (Math.abs(dx) > Math.abs(dy)) { + return dx > 0 + ? { sourceSide: "right", targetSide: "left" } + : { sourceSide: "left", targetSide: "right" }; + } else { + return dy > 0 + ? { sourceSide: "bottom", targetSide: "top" } + : { sourceSide: "top", targetSide: "bottom" }; + } + } + + const nodeIds = new Set(currentNodes.map((n) => n.id)); + const validConnections = connections.filter((c) => nodeIds.has(c.from) && nodeIds.has(c.to)); + + // Group ALL connections (source + target) by node+side so edges on the + // same side never share the same handle slot, regardless of direction. + interface SlotEntry { conn: Connection; nodeId: string; side: string; role: "source" | "target" } + const nodeSlotGroups = new Map(); + + for (const c of validConnections) { + const { sourceSide, targetSide } = bestSide(c.from, c.to); + + // Both source and target go into the SAME group per node+side + const srcKey = `${c.from}:${sourceSide}`; + if (!nodeSlotGroups.has(srcKey)) nodeSlotGroups.set(srcKey, []); + nodeSlotGroups.get(srcKey)!.push({ conn: c, nodeId: c.from, side: sourceSide, role: "source" }); + + const tgtKey = `${c.to}:${targetSide}`; + if (!nodeSlotGroups.has(tgtKey)) nodeSlotGroups.set(tgtKey, []); + nodeSlotGroups.get(tgtKey)!.push({ conn: c, nodeId: c.to, side: targetSide, role: "target" }); + } + + // Sort each group by position of the other node so slots align spatially + const assignedSlots = new Map(); + + for (const [groupKey, entries] of nodeSlotGroups) { + const side = groupKey.split(":")[1]; // "left", "right", "top", "bottom" + + // For left/right sides, sort by Y of the other node (top→bottom = slot 0→2) + // For top/bottom sides, sort by X of the other node (left→right = slot 0→2) + entries.sort((a, b) => { + const otherA = absPositions.get(a.role === "source" ? a.conn.to : a.conn.from); + const otherB = absPositions.get(b.role === "source" ? b.conn.to : b.conn.from); + if (!otherA || !otherB) return 0; + if (side === "left" || side === "right") return otherA.y - otherB.y; + return otherA.x - otherB.x; + }); + + // If only 1 edge on this side, use middle slot (1) + // If 2, use slots 0 and 2. If 3, use 0, 1, 2 + const slotMap: number[][] = [ + [1], // 1 edge → middle + [0, 2], // 2 edges → top/left and bottom/right + [0, 1, 2], // 3 edges → all + ]; + const slots = slotMap[Math.min(entries.length, 3) - 1]; + + entries.forEach((entry, i) => { + const slot = slots[Math.min(i, slots.length - 1)]; + const key = `${entry.conn.from}-${entry.conn.to}:${entry.nodeId}:${entry.role}`; + assignedSlots.set(key, slot); + }); + } + + // Build edges first, then compute corridor offsets + interface EdgeInfo { + conn: Connection; + sourceSide: string; + targetSide: string; + srcSlot: number; + tgtSlot: number; + } + + const edgeInfos: EdgeInfo[] = validConnections.map((c) => { + const { sourceSide, targetSide } = bestSide(c.from, c.to); + const srcSlot = assignedSlots.get(`${c.from}-${c.to}:${c.from}:source`) ?? 1; + const tgtSlot = assignedSlots.get(`${c.from}-${c.to}:${c.to}:target`) ?? 1; + return { conn: c, sourceSide, targetSide, srcSlot, tgtSlot }; + }); + + // Assign unique offsets per edge so smoothstep turns don't overlap + // Each edge from same node+side gets a different turn distance + const sideGroups = new Map(); + for (const ei of edgeInfos) { + const srcKey = `${ei.conn.from}:${ei.sourceSide}`; + if (!sideGroups.has(srcKey)) sideGroups.set(srcKey, []); + sideGroups.get(srcKey)!.push(ei); + + const tgtKey = `${ei.conn.to}:${ei.targetSide}`; + if (!sideGroups.has(tgtKey)) sideGroups.set(tgtKey, []); + sideGroups.get(tgtKey)!.push(ei); + } + + const edgeOffsets = new Map(); + const BASE_OFFSET = 10; + const OFFSET_STEP = 15; + + for (const [, group] of sideGroups) { + if (group.length <= 1) continue; + group.forEach((ei, i) => { + const eid = `${ei.conn.from}-${ei.conn.to}`; + // Always positive: BASE + incremental step (never goes into node) + const newOffset = BASE_OFFSET + i * OFFSET_STEP; + const existing = edgeOffsets.get(eid); + if (existing === undefined || newOffset > existing) { + edgeOffsets.set(eid, newOffset); + } + }); + } + + const active = new Map>(); + const edges: Edge[] = edgeInfos.map((ei) => { + const c = ei.conn; + const color = EDGE_COLORS[c.type || ""] || "#475569"; + + const sourceHandle = `${ei.sourceSide}-${ei.srcSlot}`; + const targetHandle = `${ei.targetSide}-${ei.tgtSlot}-target`; + + if (!active.has(c.from)) active.set(c.from, new Set()); + if (!active.has(c.to)) active.set(c.to, new Set()); + active.get(c.from)!.add(sourceHandle); + active.get(c.to)!.add(`${ei.targetSide}-${ei.tgtSlot}`); + + const offset = edgeOffsets.get(`${c.from}-${c.to}`) ?? 0; + + return { + id: `${c.from}-${c.to}`, + source: c.from, + target: c.to, + sourceHandle, + targetHandle, + label: c.label || "", + type: "offsetSmooth", + animated: false, + data: { offset }, + style: { stroke: color, strokeWidth: 2, opacity: 0.7 }, + labelStyle: { fill: color, fontSize: 11, fontWeight: 500 }, + labelBgStyle: { fill: "#0f172a", fillOpacity: 1 }, + labelBgPadding: [6, 3] as [number, number], + labelBgBorderRadius: 4, + }; + }); + + const activeHandles = new Map(); + for (const [id, set] of active) activeHandles.set(id, [...set]); + + return { edges, activeHandles }; +} diff --git a/src/client/favicon.png b/src/client/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..66f08764ec3a69b04f0405b4316a627e0358abc5 GIT binary patch literal 2164 zcmV-)2#fcLP)Uf4p?ZaO@maYHY6(2gv2UsL&c=FW_F~d z(HbkzSRuAx(hzZ~5>eb5YuPllMqRO7*;=w(g=mpLZi$QS{+OBPA9LRBW7t`Cc4n5L z>`f*!Z|1!_=X=igob$c+J(wC(V`^N=!>j9TX%e(mm@`WO2&Y{Brer_{pDe||xGw;U zfjr>HB3=X@vRa)a#KUVICM4@Lk1rrs0xK$&2VOI!LVX-?^)R45iGE(@4uxeQ zP-w;!NH$=;WbAX?0se$DEQ8-s>BlUgR|BJxlT=f@TIx+UOEG(qFu)t*xM~N4;CT=R z#?1$214EKZ1xd3Ia8|3nT8h_Xb(QKF@RM|8-?xFk0tbPYfxiPiz+vD^0AVJTY~~cG zbreQOfd?dj=@P(}W=?^6ImbIYGlrc~b4#5}2l#dV53knZdR_;FAs+ilRJ3F%`7w-} ztn~03<^ZoGS@TVh0u8G>$x6Z_ThjJd0{4uf;(XwLM^0Vyu($MRe`*e1S7l?udQf#z zm0(8)WJ}6p9vWT7< zr2>!J0a->y1<#3u1~~R;Kx|e6e-Xs z2lz4IajlV<2(ua`BI@Y?zd@Se!aHEe*v3pmt6yXc*njaV!SOgCtl$i9BD8+>wKu#v zgKR(%>=-u#nt|3nz!oWt1i4(Hnj%V|0*pfhZkwlO`2%o=74)4OLS{J*4_INf zx(m1t*au9jLEJ>E#6IA2R;%?rJw1bgaZ7>UQ@S}aRJNFk9MLbod*s@}T9hE;P=ZQw zZK>62Z(!V&R;!~{tDAu?fU9#og$X!z4sZncnAPg3bvG@X9vJr!@GQ_KAX{XKeJAh- zu{!~5v|7DFDwQY~L3LXTbS$Ar1|Rs77}*^dw*@#57`Gb0YIO!OepytlYI^gZVj=;EWTygCT$QGYmcvDmu-zwJ^%l@w@(WGzItT0Tal2)so zR;v?%aT|c=fe%a6lhWe8YPDK!wR)LUCTX?WD=hdL-Q7cD1+x|@T#Oue+ih(vbaxNR zUEH4nAC&#Q7Rk-E!LRqV1FN6W>Tl_EI?)X~WrwVlkwV(~}zvjC&vO z7_eOaw;BE9HK!P*uMTGx*-feSyRBXJIU2QS=wsr#JtT zY_=E}_YvvCmq}En$EJ*q`+#*;s~<`0S_C`}EQ9R z6{l{rT5SRfq*6(`y7Ci=Vzo6O4*r2BPOUuVmB6o(f)oe8SAccOaUPfj>i`@z8mGO z(&+?be2LbLz%c>SFZus`VBBgC*na%@>w$4H)qo z142B{BMgkYjM9@Bvw?pApRro~B`|ItuuFCVqP#pKB4v+6XQa(Zv;yPa1#Aay5hg{V zWvish_kj0G0gZ&y7T|uX)tPhVodgHW1x^TEZ?#$-3yix4*e!;>A?Y#foQl+p$q2=EoFRcHD2^&ICbf-`}qfn@@I1@N4J?-%ed0N0NR9uVVwG8x}$ z^)RqZ9M>YC2j$FZzz=~BS*p$t!x0+q zD1jm{Zcbp_&tmf)3rYo$s^_f#7?G`kaht`!p}@Gje134WbH_bm-~!|B4~*L-DHr{A z0iP!riO^dD<9;E4`(sX6Qzt{u-BXz4sC%P=FIDUXlK&rwRr*uF?N+O2>(z(P0-ehh zndAqly>n@wEe6J=q|(1L2L2H6X<1s<5Bvolei`^MFzycEHv)b@z&|7M^GyZ*YzL%! zqFt05q!smx3fpG2l9g5c6j-x5!wZa?8yMFo1#g2$BX2^L{-PUFi~{5C8m{z_5V?eg q*CayZLXM1o34@;+Q)6meLgW93UqTs~GzXRd0000(a: T[], b: T[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if ((a[i] as any).uid !== (b[i] as any).uid) return false; + if ((a[i] as any).state !== (b[i] as any).state) return false; + } + return true; +} + +export function useDocker(token = "") { + const [services, setServices] = useState([]); + const [connections, setConnections] = useState([]); + const statsRef = useRef>(new Map()); + const [statsVersion, setStatsVersion] = useState(0); + const [events, setEvents] = useState([]); + const [logLines, setLogLines] = useState([]); + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + const reconnectTimer = useRef>(undefined); + + // Initial HTTP fetch so data loads even if WS is slow + useEffect(() => { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + Promise.all([ + fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []), + fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []), + ]).then(([svcs, conns]) => { + setServices((prev) => prev.length === 0 ? svcs : prev); + setConnections((prev) => prev.length === 0 ? conns : prev); + }).catch(() => {}); + }, [token]); + + const connect = useCallback(() => { + // Clean up any existing connection + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const params = token ? `?token=${encodeURIComponent(token)}` : ""; + const wsUrl = `${protocol}//${window.location.host}/ws${params}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => setConnected(true); + ws.onclose = () => { + setConnected(false); + reconnectTimer.current = setTimeout(connect, 3000); + }; + ws.onerror = () => ws.close(); + + ws.onmessage = (e) => { + try { + const msg: WSMessage = JSON.parse(e.data); + + switch (msg.type) { + case "services": + setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data); + break; + case "connections": + setConnections((prev) => { + if (prev.length === msg.data.length) return prev; + return msg.data; + }); + break; + case "stats": { + let changed = false; + for (const s of msg.data) { + const existing = statsRef.current.get(s.service); + if (!existing || existing.cpu !== s.cpu || existing.mem_mb !== s.mem_mb) { + statsRef.current.set(s.service, s); + changed = true; + } + } + if (changed) setStatsVersion((v) => v + 1); + break; + } + case "docker_event": + setEvents((prev) => { + if (prev.length >= 10) return [...prev.slice(-9), msg.data]; + return [...prev, msg.data]; + }); + break; + case "log_line": + setLogLines((prev) => { + const next = [...prev, msg.data]; + return next.length > 2000 ? next.slice(-1500) : next; + }); + break; + } + } catch {} + }; + }, [token]); + + useEffect(() => { + connect(); + + const onVisibility = () => { + if (document.hidden) { + clearTimeout(reconnectTimer.current); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + } + setConnected(false); + } else if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + connect(); + } + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + document.removeEventListener("visibilitychange", onVisibility); + clearTimeout(reconnectTimer.current); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + } + }; + }, [connect]); + + const sendMessage = useCallback((msg: WSMessage) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(msg)); + } + }, []); + + const clearLogLines = useCallback(() => setLogLines([]), []); + + return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines }; +} diff --git a/src/client/index.css b/src/client/index.css new file mode 100644 index 0000000..94bfc04 --- /dev/null +++ b/src/client/index.css @@ -0,0 +1,81 @@ +@import "tailwindcss"; + +@theme { + --color-node-running: #22c55e; + --color-node-stopped: #ef4444; + --color-node-paused: #f59e0b; +} + +/* React Flow overrides */ +.react-flow__background { + background-color: #020617 !important; +} + +.react-flow__minimap { + background-color: #0f172a !important; + border: 1px solid #1e293b !important; + border-radius: 8px !important; +} + +.react-flow__controls { + border: 1px solid #1e293b !important; + border-radius: 8px !important; + overflow: hidden; +} + +.react-flow__controls-button { + background-color: #1e293b !important; + color: #94a3b8 !important; + border-bottom: 1px solid #334155 !important; +} + +.react-flow__controls-button:hover { + background-color: #334155 !important; +} + +/* Pulse animation for running nodes */ +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); } + 70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} + +.node-pulse-running { + animation: pulse-ring 2s infinite; +} + +/* Flash animations for Docker events */ +@keyframes flash-green { + 0%, 100% { box-shadow: 0 0 0 0 transparent; } + 50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.6); } +} + +@keyframes flash-red { + 0%, 100% { box-shadow: 0 0 0 0 transparent; } + 50% { box-shadow: 0 0 20px 4px rgba(239, 68, 68, 0.6); } +} + +@keyframes flash-yellow { + 0%, 100% { box-shadow: 0 0 0 0 transparent; } + 50% { box-shadow: 0 0 20px 4px rgba(245, 158, 11, 0.6); } +} + +.flash-start { animation: flash-green 0.6s ease-out; } +.flash-stop { animation: flash-red 0.6s ease-out; } +.flash-restart { animation: flash-yellow 0.6s ease-out 2; } + +/* Log panel slide-up */ +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.log-panel { + animation: slideUp 0.25s ease-out; +} + +/* Login animations */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 0000000..8edb701 --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + DockerFlow AlteonX + + + +
+ + + diff --git a/src/client/main.tsx b/src/client/main.tsx new file mode 100644 index 0000000..696e0d2 --- /dev/null +++ b/src/client/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/src/client/nodes/GroupNode.tsx b/src/client/nodes/GroupNode.tsx new file mode 100644 index 0000000..49f5c28 --- /dev/null +++ b/src/client/nodes/GroupNode.tsx @@ -0,0 +1,73 @@ +import { memo } from "react"; +import type { NodeProps } from "@xyflow/react"; +import { Server, Wrench, Rocket, Box, Folder } from "lucide-react"; + +interface GroupNodeData { + label: string; + subtitle?: string; + count?: number; + [key: string]: unknown; +} + +const groupConfig: Record = { + INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" }, + DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" }, + PROD: { icon: Rocket, color: "#22c55e", borderColor: "rgba(34, 197, 94, 0.3)" }, +}; + +// Rotating colors for project-based groups that don't match known names +const projectColors = [ + { color: "#8b5cf6", borderColor: "rgba(139, 92, 246, 0.3)" }, + { color: "#06b6d4", borderColor: "rgba(6, 182, 212, 0.3)" }, + { color: "#f59e0b", borderColor: "rgba(245, 158, 11, 0.3)" }, + { color: "#ec4899", borderColor: "rgba(236, 72, 153, 0.3)" }, + { color: "#10b981", borderColor: "rgba(16, 185, 129, 0.3)" }, +]; + +let colorIndex = 0; +const assignedColors = new Map(); + +function getProjectColor(label: string) { + if (!assignedColors.has(label)) { + assignedColors.set(label, projectColors[colorIndex % projectColors.length]!); + colorIndex++; + } + return assignedColors.get(label)!; +} + +export const GroupNode = memo(function GroupNode({ data }: NodeProps) { + const d = data as unknown as GroupNodeData; + // Label is "PROJECT / COMPOSE" — match compose part for known colors + const parts = d.label.split(" / "); + const composePart = parts.length > 1 ? parts[parts.length - 1] : d.label; + const known = groupConfig[composePart]; + const proj = known ? null : getProjectColor(d.label); + const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor }; + const Icon = config.icon; + + return ( +
+ + + {d.label} + + {d.subtitle && ( + + {d.subtitle} + + )} +
+ {d.count != null && ( +
+ + + {d.count} + +
+ )} +
+ ); +}); diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx new file mode 100644 index 0000000..41e892f --- /dev/null +++ b/src/client/nodes/ServiceNode.tsx @@ -0,0 +1,214 @@ +import { memo } from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { + Database, + Zap, + Globe, + Server, + Container, + Shield, + Cog, + Timer, + Radar, + MonitorDot, + KeyRound, + FileCode, + Boxes, + Gem, + Coffee, + Bug, + Rabbit, + Mail, + BarChart3, + type LucideIcon, +} from "lucide-react"; +import type { Stats } from "../../shared/types"; + +interface ServiceNodeData { + label: string; + image: string; + state: string; + ports: { host: number; container: number }[]; + project: string; + stats: Stats | null; + flash?: string; + [key: string]: unknown; +} + +const stateStyles: Record = { + 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" }, + restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" }, + dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" }, +}; + +// Map image/name patterns to Lucide icons and colors +const iconMap: { pattern: string; icon: LucideIcon; color: string }[] = [ + { pattern: "postgres", icon: Database, color: "#336791" }, + { pattern: "mysql", icon: Database, color: "#4479A1" }, + { pattern: "mariadb", icon: Database, color: "#003545" }, + { pattern: "mongo", icon: Database, color: "#47A248" }, + { pattern: "redis", icon: Zap, color: "#DC382D" }, + { pattern: "memcached", icon: Zap, color: "#3B9C60" }, + { pattern: "nginx", icon: Globe, color: "#009639" }, + { pattern: "traefik", icon: Globe, color: "#24A1C1" }, + { pattern: "haproxy", icon: Globe, color: "#2E86C1" }, + { pattern: "caddy", icon: Globe, color: "#1F88E5" }, + { pattern: "node", icon: MonitorDot, color: "#339933" }, + { pattern: "python", icon: FileCode, color: "#3776AB" }, + { pattern: "golang", icon: Boxes, color: "#00ADD8" }, + { pattern: "ruby", icon: Gem, color: "#CC342D" }, + { pattern: "java", icon: Coffee, color: "#ED8B00" }, + { pattern: "rabbitmq", icon: Rabbit, color: "#FF6600" }, + { pattern: "kafka", icon: Mail, color: "#231F20" }, + { pattern: "grafana", icon: BarChart3, color: "#F46800" }, + { pattern: "prometheus", icon: BarChart3, color: "#E6522C" }, + { pattern: "certbot", icon: Shield, color: "#003A70" }, +]; + +// Name-based patterns (for custom-built images) +const nameIconMap: { pattern: string; icon: LucideIcon; color: string }[] = [ + { pattern: "collector", icon: Radar, color: "#f59e0b" }, + { pattern: "celery", icon: Cog, color: "#97C95F" }, + { pattern: "worker", icon: Cog, color: "#97C95F" }, + { pattern: "beat", icon: Timer, color: "#97C95F" }, + { pattern: "auth", icon: KeyRound, color: "#8b5cf6" }, + { pattern: "backend", icon: Server, color: "#3b82f6" }, + { pattern: "frontend", icon: MonitorDot, color: "#06b6d4" }, + { pattern: "api", icon: Server, color: "#3b82f6" }, +]; + +function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } { + const lowerImage = image.toLowerCase(); + const lowerName = name.toLowerCase(); + + // Check image first + for (const { pattern, icon, color } of iconMap) { + if (lowerImage.includes(pattern)) return { Icon: icon, color }; + } + + // Then check name + for (const { pattern, icon, color } of nameIconMap) { + if (lowerName.includes(pattern)) return { Icon: icon, color }; + } + + return { Icon: Container, color: "#64748b" }; +} + +export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { + const d = data as unknown as ServiceNodeData; + const s = stateStyles[d.state] || stateStyles.exited; + const { Icon, color: iconColor } = guessIcon(d.image, d.label); + const flashClass = d.flash || ""; + const activeHandles = new Set((d as any).activeHandles || []); + const highlighted = (d as any).highlighted; + const hdot = (id: string) => { + if (activeHandles.has(id)) { + return highlighted + ? "!bg-cyan-400 !w-2 !h-2 !border-0 !opacity-100" + : "!bg-slate-500 !w-1.5 !h-1.5 !border-0 !opacity-80"; + } + return "!bg-transparent !w-1.5 !h-1.5 !border-0 !opacity-0"; + }; + + // 3 slots per side at 25%, 50%, 75% + const offsets = ["25%", "50%", "75%"]; + + return ( +
`${p.host}:${p.container}`).join(", ") || "none"}`} + className={`relative rounded-xl border border-slate-700/80 ${s.bg} backdrop-blur-sm + shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring} + transition-all duration-500 ${flashClass} + ${d.state === "running" ? "node-pulse-running" : ""}`} + > + {/* Top handles — left offset, transform centered horizontally */} + {offsets.map((o, i) => ( + + ))} + {offsets.map((o, i) => ( + + ))} + {/* Left handles — top offset, transform centered vertically */} + {offsets.map((o, i) => ( + + ))} + {offsets.map((o, i) => ( + + ))} + {/* Right handles — top offset, transform centered */} + {offsets.map((o, i) => ( + + ))} + {offsets.map((o, i) => ( + + ))} + + {/* Header: icon + name + status dot */} +
+
+ +
+ {d.label} +
+
+
+ + {/* Image */} +
{d.image}
+ + {/* Ports */} + {d.ports?.length > 0 && ( +
+ {d.ports.map((p) => ( + + :{p.host} + + ))} +
+ )} + + {/* Stats */} + {d.stats && ( +
+
+ CPU {d.stats.cpu.toFixed(1)}% + MEM {d.stats.mem_mb.toFixed(0)}MB +
+
+
+
+
+
+
+
+
+
+ )} + + {/* Bottom handles — left offset, transform centered */} + {offsets.map((o, i) => ( + + ))} + {offsets.map((o, i) => ( + + ))} +
+ ); +}); diff --git a/src/client/panels/LogPanel.tsx b/src/client/panels/LogPanel.tsx new file mode 100644 index 0000000..568bd82 --- /dev/null +++ b/src/client/panels/LogPanel.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { X, Pause, Play, Terminal } from "lucide-react"; +import type { Service, LogLine, WSMessage } from "../../shared/types"; + +interface LogPanelProps { + service: Service; + logLines: LogLine[]; + token: string; + onClose: () => void; + sendMessage: (msg: WSMessage) => void; + clearLogLines: () => void; +} + +export function LogPanel({ service, logLines, token, onClose, sendMessage, clearLogLines }: LogPanelProps) { + const [initialLogs, setInitialLogs] = useState([]); + const [autoScroll, setAutoScroll] = useState(true); + const [loading, setLoading] = useState(true); + const scrollRef = useRef(null); + const subscribedRef = useRef(null); + + // Fetch initial logs + subscribe to streaming + useEffect(() => { + setInitialLogs([]); + setLoading(true); + clearLogLines(); + + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + fetch(`/api/logs/${service.id}?tail=200`, { headers }) + .then((r) => r.ok ? r.json() : []) + .then((lines: LogLine[]) => { + setInitialLogs(lines); + setLoading(false); + }) + .catch(() => setLoading(false)); + + // Subscribe to live logs + sendMessage({ type: "subscribe_logs", container: service.id }); + subscribedRef.current = service.id; + + return () => { + if (subscribedRef.current) { + sendMessage({ type: "unsubscribe_logs" }); + subscribedRef.current = null; + } + }; + }, [service.id, token, sendMessage, clearLogLines]); + + // Auto-scroll + useEffect(() => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [initialLogs, logLines, autoScroll]); + + // Detect manual scroll + const handleScroll = useCallback(() => { + if (!scrollRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const atBottom = scrollHeight - scrollTop - clientHeight < 40; + setAutoScroll(atBottom); + }, []); + + const allLines = [...initialLogs, ...logLines.filter((l) => l.container === service.id)]; + + const stateColor = + service.state === "running" ? "text-emerald-400" : + service.state === "exited" || service.state === "dead" ? "text-red-400" : + "text-yellow-400"; + + return ( +
+ {/* Header */} +
+
+ + {service.name} + {service.state} + {service.state === "running" && subscribedRef.current && ( + + + streaming + + )} +
+
+ + +
+
+ + {/* Log body */} +
+ {loading && ( +
Loading logs...
+ )} + {!loading && allLines.length === 0 && ( +
No logs available
+ )} + {allLines.map((l, i) => ( +
+ {l.timestamp && ( + + {formatTimestamp(l.timestamp)} + + )} + + {l.line} + +
+ ))} +
+
+ ); +} + +function formatTimestamp(ts: string): string { + try { + const d = new Date(ts); + return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + } catch { + return ts.slice(11, 19); + } +} diff --git a/src/client/public/alteonx-logo.png b/src/client/public/alteonx-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..967ef38d3cbddb9a587b47ac4dec447dcdc10f2c GIT binary patch literal 121741 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 O$^$Vtbltx$-1sjUILH71 literal 0 HcmV?d00001 diff --git a/src/server/docker.ts b/src/server/docker.ts new file mode 100644 index 0000000..b63108d --- /dev/null +++ b/src/server/docker.ts @@ -0,0 +1,245 @@ +import Docker from "dockerode"; +import type { Service, Connection, LogLine } from "../shared/types"; + +const docker = new Docker({ socketPath: "/var/run/docker.sock" }); + +export { docker }; + +export async function discoverServices(all: boolean, projects: string[]): Promise { + const containers = await docker.listContainers({ all: true }); + + let services: Service[] = containers.map((c) => { + const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown"; + const project = c.Labels["com.docker.compose.project"] || "standalone"; + return { + id: c.Id.slice(0, 12), + uid: `${project}/${name}`, + name, + image: c.Image, + state: c.State as Service["state"], + status: c.Status, + ports: [...new Map( + c.Ports.filter((p) => p.PublicPort).map((p) => [ + `${p.PublicPort}:${p.PrivatePort}`, + { host: p.PublicPort!, container: p.PrivatePort }, + ]) + ).values()], + networks: Object.keys(c.NetworkSettings?.Networks || {}), + project, + compose_file: c.Labels["com.docker.compose.project.config_files"] || "", + }; + }); + + if (!all && projects.length > 0) { + services = services.filter((s) => projects.includes(s.project)); + } + + return services; +} + +// ── Infrastructure service detection ── +// These are services that OTHER services connect TO (databases, caches, brokers, proxies) + +const INFRA_PATTERNS: { pattern: string; type: string; label: string; role: "target" }[] = [ + { pattern: "postgres", type: "database", label: "postgres", role: "target" }, + { pattern: "mysql", type: "database", label: "mysql", role: "target" }, + { pattern: "mariadb", type: "database", label: "mariadb", role: "target" }, + { pattern: "mongo", type: "database", label: "mongo", role: "target" }, + { pattern: "redis", type: "cache", label: "redis", role: "target" }, + { pattern: "memcached", type: "cache", label: "memcached", role: "target" }, + { pattern: "rabbitmq", type: "broker", label: "rabbitmq", role: "target" }, + { pattern: "kafka", type: "broker", label: "kafka", role: "target" }, + { pattern: "nats", type: "broker", label: "nats", role: "target" }, +]; + +const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"]; + +function isInfraService(svc: Service): { type: string; label: string } | null { + const img = svc.image.toLowerCase(); + const name = svc.name.toLowerCase(); + for (const p of INFRA_PATTERNS) { + if (img.includes(p.pattern) || name.includes(p.pattern)) { + return { type: p.type, label: p.label }; + } + } + return null; +} + +function isProxyService(svc: Service): boolean { + const img = svc.image.toLowerCase(); + const name = svc.name.toLowerCase(); + return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p)); +} + +function isWorkerService(svc: Service): boolean { + const name = svc.name.toLowerCase(); + return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron"); +} + +export async function discoverConnections(services: Service[]): Promise { + const connections: Connection[] = []; + const seen = new Set(); + + // Separate services by role + const infraServices = services.filter((s) => isInfraService(s)); + const proxyServices = services.filter((s) => isProxyService(s)); + const workerServices = services.filter((s) => isWorkerService(s)); + const appServices = services.filter( + (s) => !isInfraService(s) && !isProxyService(s) && !isWorkerService(s) + ); + + function addConnection(from: string, to: string, type: string, label: string) { + const key = `${from}:${to}`; + if (seen.has(key) || from === to) return; + seen.add(key); + connections.push({ from, to, network: "", type, label }); + } + + // 1. App services → infra services (backend→db, backend→redis, etc.) + for (const app of appServices) { + // Each app connects to DB and cache in the same network + for (const infra of infraServices) { + const hasSharedNetwork = app.networks.some((n) => infra.networks.includes(n)); + if (!hasSharedNetwork) continue; + const edge = isInfraService(infra)!; + addConnection(app.uid, infra.uid, edge.type, edge.label); + } + } + + // 2. Workers → infra (celery→redis as broker, celery→db) + for (const worker of workerServices) { + for (const infra of infraServices) { + const hasSharedNetwork = worker.networks.some((n) => infra.networks.includes(n)); + if (!hasSharedNetwork) continue; + const edge = isInfraService(infra)!; + const label = edge.type === "cache" ? "broker" : edge.label; + addConnection(worker.uid, infra.uid, edge.type, label); + } + } + + // 3. Proxy → app services (nginx→backend, nginx→frontend, nginx→auth) + for (const proxy of proxyServices) { + for (const app of appServices) { + const hasSharedNetwork = proxy.networks.some((n) => app.networks.includes(n)); + if (!hasSharedNetwork) continue; + addConnection(proxy.uid, app.uid, "proxy", "upstream"); + } + } + + // 4. Collector → infra (special: collector writes to db and redis) + for (const svc of services) { + if (svc.name.toLowerCase().includes("collector")) { + for (const infra of infraServices) { + const hasSharedNetwork = svc.networks.some((n) => infra.networks.includes(n)); + if (!hasSharedNetwork) continue; + const edge = isInfraService(infra)!; + addConnection(svc.uid, infra.uid, edge.type, edge.label); + } + } + } + + return connections; +} + +// ── Container logs ── + +export async function getContainerLogs(id: string, tail = 200): Promise { + const container = docker.getContainer(id); + const logBuffer = await container.logs({ + stdout: true, + stderr: true, + tail, + timestamps: true, + }); + + const lines: LogLine[] = []; + const raw = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer as any); + + let offset = 0; + while (offset < raw.length) { + if (offset + 8 > raw.length) break; + const streamType = raw[offset]; + const size = raw.readUInt32BE(offset + 4); + if (offset + 8 + size > raw.length) break; + + const payload = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd(); + offset += 8 + size; + + if (!payload) continue; + + // Timestamp is at the start: "2024-01-01T00:00:00.000000000Z rest of line" + const spaceIdx = payload.indexOf(" "); + const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : ""; + const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload; + + lines.push({ + container: id, + line, + timestamp, + stream: streamType === 2 ? "stderr" : "stdout", + }); + } + + return lines; +} + +export function streamContainerLogs( + id: string, + onLine: (line: LogLine) => void, +): { destroy: () => void } { + const container = docker.getContainer(id); + let stream: NodeJS.ReadableStream | null = null; + let destroyed = false; + + container.logs({ + stdout: true, + stderr: true, + follow: true, + since: Math.floor(Date.now() / 1000), + timestamps: true, + }).then((s) => { + if (destroyed) { + if (s && typeof (s as any).destroy === "function") (s as any).destroy(); + return; + } + stream = s as unknown as NodeJS.ReadableStream; + + // Docker multiplexed stream parsing for follow mode + let buffer = Buffer.alloc(0); + + stream.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 8) { + const streamType = buffer[0]; + const size = buffer.readUInt32BE(4); + if (buffer.length < 8 + size) break; + + const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd(); + buffer = buffer.slice(8 + size); + + if (!payload) continue; + + const spaceIdx = payload.indexOf(" "); + const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : ""; + const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload; + + onLine({ + container: id, + line, + timestamp, + stream: streamType === 2 ? "stderr" : "stdout", + }); + } + }); + }).catch(() => {}); + + return { + destroy() { + destroyed = true; + if (stream && typeof (stream as any).destroy === "function") { + (stream as any).destroy(); + } + }, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..737212b --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,202 @@ +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import path from "path"; +import fs from "fs"; +import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker"; +import { pollStats, watchDockerEvents } from "./watcher"; +import type { WSMessage } from "../shared/types"; + +const app = new Hono(); + +// ── CLI args ── +const args = process.argv.slice(2); +const ALL = args.includes("--all"); +const projectsFlag = args.find((a) => a.startsWith("--projects=")); +const PROJECTS = projectsFlag + ? projectsFlag.split("=")[1]!.split(",") + : ALL + ? [] + : [path.basename(process.cwd())]; + +// ── Config ── +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"; + +// ── Auth middleware ── +if (AUTH_TOKEN) { + app.use("*", async (c, next) => { + // Skip static assets and auth page + if (c.req.path === "/" || c.req.path.startsWith("/assets") || c.req.path.endsWith(".png") || c.req.path.endsWith(".ico")) return next(); + if (c.req.path === "/api/auth") return next(); + + const token = c.req.header("Authorization")?.replace("Bearer ", ""); + if (token !== AUTH_TOKEN) return c.json({ error: "Unauthorized" }, 401); + return next(); + }); +} + +// ── API ── +app.get("/api/services", async (c) => { + const services = await discoverServices(ALL, PROJECTS); + return c.json(services); +}); + +app.get("/api/connections", async (c) => { + const services = await discoverServices(ALL, PROJECTS); + const connections = await discoverConnections(services); + return c.json(connections); +}); + +app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS })); + +app.get("/api/logs/:id", async (c) => { + const id = c.req.param("id"); + const tail = parseInt(c.req.query("tail") || "200"); + try { + const lines = await getContainerLogs(id, tail); + return c.json(lines); + } catch (err) { + return c.json({ error: "Failed to fetch logs" }, 500); + } +}); + +// ── Node positions (persisted to file) ── +const POSITIONS_FILE = path.join(process.cwd(), ".dockerflow-positions.json"); + +app.get("/api/positions", (c) => { + try { + if (fs.existsSync(POSITIONS_FILE)) { + const data = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8")); + return c.json(data); + } + } catch {} + return c.json({}); +}); + +app.put("/api/positions", async (c) => { + try { + const body = await c.req.json(); + fs.writeFileSync(POSITIONS_FILE, JSON.stringify(body, null, 2)); + return c.json({ ok: true }); + } catch { + return c.json({ error: "Failed to save" }, 500); + } +}); + +// ── Serve frontend build ── +app.use("/*", serveStatic({ root: "./dist" })); +app.get("/*", serveStatic({ root: "./dist", path: "index.html" })); + +// ── WebSocket ── +const clients = new Set(); +const logStreams = new Map void }>(); + +function broadcast(msg: WSMessage) { + const data = JSON.stringify(msg); + for (const ws of clients) { + try { + ws.send(data); + } catch {} + } +} + +function cleanupLogStream(ws: WebSocket) { + const stream = logStreams.get(ws); + if (stream) { + stream.destroy(); + logStreams.delete(ws); + } +} + +// ── Docker events ── +watchDockerEvents((event) => { + broadcast({ type: "docker_event", data: event }); +}); + +// ── Stats polling ── +let lastServicesHash = ""; +let lastConnectionsHash = ""; + +setInterval(async () => { + try { + const services = await discoverServices(ALL, PROJECTS); + const connections = await discoverConnections(services); + const stats = await pollStats(services); + + // Only send services/connections if changed + const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|"); + if (svcHash !== lastServicesHash) { + lastServicesHash = svcHash; + broadcast({ type: "services", data: services }); + } + + const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|"); + if (connHash !== lastConnectionsHash) { + lastConnectionsHash = connHash; + broadcast({ type: "connections", data: connections }); + } + + // Stats always change (cpu/mem fluctuate) + broadcast({ type: "stats", data: stats }); + } catch (err) { + console.error("Poll error:", err); + } +}, 5000); + +// ── Start ── +const server = Bun.serve({ + hostname: HOST, + port: PORT, + fetch(req, server) { + const url = new URL(req.url); + + // WebSocket upgrade + if (url.pathname === "/ws") { + const token = url.searchParams.get("token") || ""; + if (AUTH_TOKEN && token !== AUTH_TOKEN) { + return new Response("Unauthorized", { status: 401 }); + } + if (server.upgrade(req)) return undefined; + return new Response("WebSocket upgrade failed", { status: 400 }); + } + + return app.fetch(req, server); + }, + websocket: { + open(ws) { + clients.add(ws as unknown as WebSocket); + }, + close(ws) { + const native = ws as unknown as WebSocket; + cleanupLogStream(native); + clients.delete(native); + }, + message(ws, message) { + try { + const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer)); + const native = ws as unknown as WebSocket; + + if (msg.type === "subscribe_logs" && msg.container) { + // Clean up any existing stream first + cleanupLogStream(native); + + const stream = streamContainerLogs(msg.container, (line) => { + try { + native.send(JSON.stringify({ type: "log_line", data: line })); + } catch {} + }); + logStreams.set(native, stream); + } else if (msg.type === "unsubscribe_logs") { + cleanupLogStream(native); + } + } catch {} + }, + }, +}); + +const mode = ALL ? "all projects" : `project(s): ${PROJECTS.join(", ")}`; +console.log(`\n Alteonx DockerFlow`); +console.log(` → http://${HOST}:${PORT}`); +console.log(` → Mode: ${mode}`); +console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`); diff --git a/src/server/watcher.ts b/src/server/watcher.ts new file mode 100644 index 0000000..6212108 --- /dev/null +++ b/src/server/watcher.ts @@ -0,0 +1,71 @@ +import { docker } from "./docker"; +import type { Service, Stats, DockerEvent } from "../shared/types"; + +export async function pollStats(services: Service[]): Promise { + 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; + + const cpu = + sysDelta > 0 + ? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100 + : 0; + + const memUsage = raw.memory_stats.usage || 0; + const memLimit = raw.memory_stats.limit || 1; + + results.push({ + service: svc.uid, + cpu: parseFloat(cpu.toFixed(2)), + mem_mb: parseFloat((memUsage / 1024 / 1024).toFixed(1)), + mem_percent: parseFloat(((memUsage / memLimit) * 100).toFixed(1)), + }); + } catch { + // Container may have stopped between discovery and stats + } + } + + return results; +} + +export function watchDockerEvents(onEvent: (event: DockerEvent) => void) { + docker.getEvents({}, (err, stream) => { + if (err || !stream) { + console.error("Failed to watch Docker events:", err); + return; + } + + stream.on("data", (chunk: Buffer) => { + try { + const event = JSON.parse(chunk.toString()); + if (event.Type !== "container") return; + + const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status" + if (!["start", "stop", "die", "restart", "health_status"].includes(action)) return; + + const svcName = + event.Actor?.Attributes?.["com.docker.compose.service"] || + event.Actor?.Attributes?.name || + "unknown"; + const svcProject = + event.Actor?.Attributes?.["com.docker.compose.project"] || + "standalone"; + onEvent({ + type: "docker", + action, + service: `${svcProject}/${svcName}`, + time: event.time || Date.now() / 1000, + }); + } catch {} + }); + }); +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..06b11e5 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,50 @@ +export interface Service { + id: string; + uid: string; + name: string; + image: string; + state: "running" | "exited" | "paused" | "restarting" | "dead"; + status: string; + ports: { host: number; container: number }[]; + networks: string[]; + project: string; + compose_file: string; +} + +export interface Connection { + from: string; + to: string; + network: string; + type?: string; + label?: string; +} + +export interface Stats { + service: string; + cpu: number; + mem_mb: number; + mem_percent: number; +} + +export interface DockerEvent { + type: "docker"; + action: string; + service: string; + time: number; +} + +export interface LogLine { + container: string; + line: string; + timestamp: string; + stream: "stdout" | "stderr"; +} + +export type WSMessage = + | { type: "services"; data: Service[] } + | { type: "connections"; data: Connection[] } + | { type: "stats"; data: Stats[] } + | { type: "docker_event"; data: DockerEvent } + | { type: "subscribe_logs"; container: string } + | { type: "unsubscribe_logs" } + | { type: "log_line"; data: LogLine }; diff --git a/tareas/completadas/01-project-setup.md b/tareas/completadas/01-project-setup.md new file mode 100644 index 0000000..db9debe --- /dev/null +++ b/tareas/completadas/01-project-setup.md @@ -0,0 +1,25 @@ +# 01 — Project Setup + +## Objetivo +Inicializar el proyecto con Bun, TypeScript, Vite, React y todas las dependencias. + +## Tareas +- [ ] `bun init` con TypeScript +- [ ] `package.json` con scripts: `dev`, `build`, `start` +- [ ] `tsconfig.json` para server (Node/Bun) y client (React) +- [ ] `vite.config.ts` con React plugin y proxy al server +- [ ] Instalar dependencias: + - Server: `hono`, `dockerode`, `yaml`, `zod` + - Client: `react`, `react-dom`, `@xyflow/react`, `@dagrejs/dagre` + - Dev: `typescript`, `vite`, `@vitejs/plugin-react`, `tailwindcss`, `@types/dockerode` +- [ ] Crear estructura de carpetas: + ``` + src/ + ├── server/ + ├── client/ + └── shared/ + ``` +- [ ] Verificar que `bun run dev` arranca sin errores + +## Criterio de completado +`bun run dev` levanta el server Hono en :9470 y sirve una página React vacía. diff --git a/tareas/completadas/02-docker-discovery.md b/tareas/completadas/02-docker-discovery.md new file mode 100644 index 0000000..5b24a01 --- /dev/null +++ b/tareas/completadas/02-docker-discovery.md @@ -0,0 +1,21 @@ +# 02 — Docker Auto-Discovery + +## Objetivo +Leer containers, redes y stats desde el Docker socket. Detectar conexiones automáticamente. + +## Tareas +- [ ] `src/server/docker.ts` — `discoverServices()`: + - Lee `docker.listContainers({ all: true })` + - Extrae: name, image, state, status, ports, networks, project, compose_file +- [ ] `src/server/docker.ts` — `discoverConnections()`: + - Lee redes y detecta qué containers comparten red + - Genera edges entre pares de containers en la misma red +- [ ] `src/server/docker.ts` — `inferEdgeType()`: + - Heurísticas: postgres/mysql → "database", redis → "cache", nginx/traefik → "proxy", rabbit/kafka → "broker" +- [ ] `src/shared/types.ts` — tipos compartidos: `Service`, `Connection`, `EdgeType`, `Stats` +- [ ] Endpoint REST: `GET /api/services` y `GET /api/connections` +- [ ] Probar que detecta correctamente los containers de ninjasagacw + +## Criterio de completado +`curl http://localhost:9470/api/services` retorna JSON con todos los containers corriendo. +`curl http://localhost:9470/api/connections` retorna las conexiones detectadas. diff --git a/tareas/completadas/03-websocket-stats.md b/tareas/completadas/03-websocket-stats.md new file mode 100644 index 0000000..9a7f558 --- /dev/null +++ b/tareas/completadas/03-websocket-stats.md @@ -0,0 +1,24 @@ +# 03 — WebSocket + Stats + Docker Events + +## Objetivo +Enviar datos en tiempo real al frontend via WebSocket: servicios, stats y eventos Docker. + +## Tareas +- [ ] `src/server/watcher.ts` — `pollStats()`: + - CPU y MEM por container (solo running) + - Calcula cpu_percent, mem_mb, mem_percent +- [ ] `src/server/watcher.ts` — `watchDockerEvents()`: + - Stream de Docker events API + - Filtra por Type: "container" + - Emite: start, stop, die, restart, health_status +- [ ] WebSocket en `src/server/index.ts`: + - Bun.serve con websocket handler + - `broadcast()` a todos los clients conectados + - Polling de services + connections + stats cada 3s + - Docker events en tiempo real +- [ ] `src/client/hooks/useDocker.ts`: + - Hook que conecta al WebSocket + - Mantiene estado de services, connections, stats, events + +## Criterio de completado +Abrir el dashboard, la consola del browser muestra datos llegando por WebSocket cada 3s. diff --git a/tareas/completadas/04-service-node.md b/tareas/completadas/04-service-node.md new file mode 100644 index 0000000..052f385 --- /dev/null +++ b/tareas/completadas/04-service-node.md @@ -0,0 +1,21 @@ +# 04 — ServiceNode (nodo visual de container) + +## Objetivo +Crear el componente visual que representa cada container en el grafo. + +## Tareas +- [ ] `src/client/nodes/ServiceNode.tsx`: + - Status dot con animate-pulse (running = verde, stopped = rojo, paused = amarillo) + - Icono auto-detectado por imagen (postgres=🐘, redis=⚡, nginx=🔀, node=💚, python=🐍, etc.) + - Nombre del servicio + - Imagen (truncada) + - Puertos como badges cyan + - Barras de CPU/MEM con porcentaje + - Badge del proyecto (compose project name) + - Handles top/bottom para edges + - Dark theme: bg slate-900, bordes según estado, backdrop-blur +- [ ] Registrar nodeTypes en React Flow +- [ ] Probar con datos mock primero + +## Criterio de completado +Se ven nodos bonitos con toda la info, pulso verde en running, rojo en stopped. diff --git a/tareas/completadas/05-react-flow-layout.md b/tareas/completadas/05-react-flow-layout.md new file mode 100644 index 0000000..7b2918f --- /dev/null +++ b/tareas/completadas/05-react-flow-layout.md @@ -0,0 +1,26 @@ +# 05 — React Flow + Auto-Layout + Agrupación + +## Objetivo +Montar el canvas de React Flow con auto-layout (dagre) y subgraphs por proyecto/compose file. + +## Tareas +- [ ] `src/client/App.tsx`: + - React Flow con Background, Controls, MiniMap + - Dark theme (bg-slate-950, minimap dark) + - fitView al cargar +- [ ] `src/client/engine/layout.ts`: + - Auto-layout con dagre respetando grupos + - Nodos dentro de su grupo (parentId) + - Posicionamiento que no se solape +- [ ] Agrupación automática: + - `detectGrouping()`: si hay 1 proyecto → agrupa por compose_file, si hay múltiples → agrupa por project + - Nodos "group" de React Flow con borde dashed, label, fondo semi-transparente + - Colores distintos por grupo +- [ ] Edges entre nodos: + - Usa connections del backend + - Label con tipo de conexión (postgres, cache, upstream, broker) + - Estilo: línea sólida gris con label +- [ ] Conectar useDocker hook → actualizar nodos/edges en tiempo real + +## Criterio de completado +Dashboard muestra todos los containers agrupados por proyecto/compose file, con edges entre ellos, auto-layout limpio, minimap, zoom y pan. diff --git a/tareas/completadas/06-project-filter.md b/tareas/completadas/06-project-filter.md new file mode 100644 index 0000000..6bccd72 --- /dev/null +++ b/tareas/completadas/06-project-filter.md @@ -0,0 +1,23 @@ +# 06 — Filtrado de Proyectos (CLI + Frontend) + +## Objetivo +Permitir filtrar qué proyectos se muestran, tanto por CLI como por dropdown en el frontend. + +## Tareas +- [ ] CLI args en `src/server/index.ts`: + - `--all` → carga todos los containers + - `--projects=name1,name2` → filtra por com.docker.compose.project + - Sin flags → auto-detecta por `path.basename(process.cwd())` + - Filtro aplicado en `discoverServices()` +- [ ] `src/client/panels/ProjectFilter.tsx`: + - Dropdown con checkboxes por proyecto + - "Mostrar todos" toggle + - Selección se guarda en localStorage + - Solo visible si hay más de 1 proyecto (si usó --all o --projects con varios) +- [ ] Filtro en el frontend: + - Los nodos/edges se filtran en el cliente según selección + - Transición suave al mostrar/ocultar nodos + +## Criterio de completado +`bunx alteonx-dockerflow --all` muestra todos los proyectos con dropdown para filtrar. +`bunx alteonx-dockerflow` sin flags muestra solo el proyecto del directorio actual, sin dropdown. diff --git a/tareas/completadas/07-security.md b/tareas/completadas/07-security.md new file mode 100644 index 0000000..7ae6141 --- /dev/null +++ b/tareas/completadas/07-security.md @@ -0,0 +1,23 @@ +# 07 — Seguridad (AUTH_TOKEN) + +## Objetivo +Proteger el dashboard con token cuando se expone en red. + +## Tareas +- [ ] Lógica de bind: + - Sin `AUTH_TOKEN` → bind a `127.0.0.1` (solo local) + - Con `AUTH_TOKEN` → bind a `0.0.0.0` (acceso remoto) +- [ ] Middleware Hono: + - Valida `Authorization: Bearer ` en toda request excepto `/` y assets + - 401 si token inválido +- [ ] WebSocket auth: + - Valida token en el handshake + - Cierra conexión si no es válido +- [ ] Pantalla de login en el frontend: + - Input de token, botón "Entrar" + - Guarda token en localStorage + - Lo envía en headers y WebSocket + +## Criterio de completado +Sin AUTH_TOKEN: funciona sin pedir nada en localhost. +Con AUTH_TOKEN: pide token al entrar, rechaza si es incorrecto, funciona si es correcto. diff --git a/tareas/completadas/08-polish-fase1.md b/tareas/completadas/08-polish-fase1.md new file mode 100644 index 0000000..744212e --- /dev/null +++ b/tareas/completadas/08-polish-fase1.md @@ -0,0 +1,29 @@ +# 08 — Polish Fase 1 + +## Objetivo +Pulir detalles visuales y funcionales para cerrar la Fase 1. + +## Tareas +- [ ] Header del dashboard: + - Logo/nombre "Alteonx DockerFlow" + - Indicador de conexión WebSocket (verde = conectado, rojo = desconectado) + - Dropdown de proyecto (de tarea 06) +- [ ] Docker events visuales: + - Container start → flash verde en el nodo + - Container stop/die → flash rojo en el nodo + - Container restart → flash amarillo +- [ ] Tooltips en nodos: + - Hover → muestra status completo, uptime, networks +- [ ] Edge labels legibles: + - No se solapen entre sí + - Se ocultan en zoom bajo +- [ ] Responsive básico: + - Funcione en pantallas desde 1280px +- [ ] Console log limpio (sin warnings de React/Vite) +- [ ] README.md básico con: + - Qué es + - Quickstart (3 comandos) + - Screenshot placeholder + +## Criterio de completado +Dashboard se ve profesional, sin bugs visuales, README funcional. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d213299 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a4b50d7 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + root: "src/client", + build: { + outDir: "../../dist", + emptyOutDir: true, + }, + server: { + port: 5174, + proxy: { + "/api": "http://localhost:9470", + "/ws": { + target: "http://localhost:9470", + ws: true, + }, + }, + }, +});