commit 8703f2885630ef475db0b98d0b3fad47d3b7e405 Author: RGJorge Date: Sun Mar 22 09:53:08 2026 +0000 v0.0.1 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 0000000..66f0876 Binary files /dev/null and b/src/client/favicon.png differ diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts new file mode 100644 index 0000000..5b8ed7d --- /dev/null +++ b/src/client/hooks/useDocker.ts @@ -0,0 +1,138 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types"; + +function arraysEqual(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 0000000..967ef38 Binary files /dev/null and b/src/client/public/alteonx-logo.png differ 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, + }, + }, + }, +});