This commit is contained in:
RGJorge
2026-03-22 09:53:08 +00:00
commit 8703f28856
31 changed files with 4543 additions and 0 deletions
+6
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.env
.dockerflow-positions.json
+115
View File
@@ -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
+558
View File
@@ -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=="],
}
}
+1255
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+744
View File
@@ -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 <SmoothStepEdge {...props} pathOptions={{ offset, borderRadius: 8 }} />;
}
const nodeTypes = { service: ServiceNode, group: GroupNode };
const edgeTypes = { offsetSmooth: OffsetEdge };
function loadFilter(): Set<string> {
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<string[]>([]);
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 (
<div className={`h-screen w-screen bg-slate-950 flex items-center justify-center transition-opacity duration-700 ${connected ? "opacity-0" : "opacity-100"}`}>
<div className="flex flex-col items-center gap-6 w-80">
{/* Logo + Title */}
<img
src="/alteonx-logo.png"
alt="Alteonx"
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<div className="text-center">
<h1 className="text-2xl font-bold text-white tracking-wide">DockerFlow</h1>
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
</div>
{/* Form */}
<form onSubmit={submit} className={`flex flex-col gap-3 w-full transition-opacity duration-300 ${connecting ? "opacity-50 pointer-events-none" : ""}`}>
<div className="relative">
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => { 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}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{error && <span className="text-red-400 text-xs font-mono">{error}</span>}
<button
type="submit"
disabled={connecting || !token}
className={`w-full flex items-center justify-center gap-2 text-sm font-medium py-2.5 rounded-lg transition-all duration-300 ${
connecting
? "bg-slate-800 text-slate-500 cursor-wait"
: "bg-cyan-600 hover:bg-cyan-500 text-white hover:shadow-lg hover:shadow-cyan-500/20"
}`}
>
<Terminal size={14} />
{connecting ? "Connecting..." : "Connect"}
</button>
</form>
{/* Terminal log */}
{logLines.length > 0 && (
<div className="w-full bg-slate-900/80 border border-slate-800 rounded-lg p-3 font-mono text-[11px] space-y-0.5 max-h-32 overflow-y-auto">
{logLines.map((line, i) => (
<div
key={i}
className={`${
line.includes("ERROR") ? "text-red-400" :
line.includes("accepted") || line.includes("established") ? "text-emerald-400" :
line.startsWith("$") ? "text-cyan-400" : "text-slate-400"
} animate-[fadeIn_0.15s_ease-out]`}
>
{line}
{i === logLines.length - 1 && !connected && (
<span className="inline-block w-1.5 h-3 bg-cyan-400 ml-1 animate-pulse" />
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function App() {
const [authToken, setAuthToken] = useState<string | null>(null);
const [needsAuth, setNeedsAuth] = useState<boolean | null>(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 <div className="h-screen w-screen bg-slate-950" />;
if (needsAuth) return <LoginScreen onAuth={(t) => { setAuthToken(t); setNeedsAuth(false); }} />;
return <Dashboard token={authToken || ""} />;
}
function Dashboard({ token }: { token: string }) {
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines } = useDocker(token);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const initialLayoutDone = useRef(false);
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
const [filterOpen, setFilterOpen] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [logPanelService, setLogPanelService] = useState<Service | null>(null);
const reactFlowRef = useRef<any>(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<string, string> = {};
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<ReturnType<typeof setTimeout>>(undefined);
const savePositions = useCallback((nodes: Node[]) => {
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const positions: Record<string, { x: number; y: number }> = {};
for (const n of nodes) {
positions[n.id] = { x: n.position.x, y: n.position.y };
}
savedPositions.current = positions;
const headers: Record<string, string> = { "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<Node>[]) => {
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<string>([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 (
<div className="h-screen w-screen bg-slate-950 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<img
src="/alteonx-logo.png"
alt="Alteonx"
className="w-7 h-7"
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
/>
<span className="text-base font-bold text-white tracking-wide">
DockerFlow
</span>
<span className="text-xs text-cyan-400 tracking-widest uppercase font-semibold">
AlteonX
</span>
</div>
<span className="text-xs text-slate-600 font-mono bg-slate-800 px-2 py-0.5 rounded">
v0.1
</span>
</div>
<div className="flex items-center gap-5">
{/* Project filter dropdown */}
{projects.length > 1 && (
<div className="relative" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 hover:bg-slate-700/80 px-3 py-1.5 rounded-md transition-colors"
>
Projects
<span className="text-cyan-400 font-medium">
{projects.length - hiddenProjects.size}/{projects.length}
</span>
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[200px] z-[9999]">
{projects.map((p) => {
const active = !hiddenProjects.has(p);
const count = services.filter((s) => s.project === p).length;
return (
<button
key={p}
onClick={() => toggleProject(p)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{active && <Check size={12} className="text-white" />}
</div>
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
<span className="text-slate-500 ml-auto">{count}</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Stats */}
<span className="text-sm text-slate-500">
<span className="text-emerald-400 font-medium">{runningCount}</span>
<span className="text-slate-600">/{filteredServices.length}</span>
<span className="text-slate-600 ml-1">containers</span>
</span>
{/* Connection status */}
<div className="flex items-center gap-2">
{connected ? (
<Wifi size={15} className="text-emerald-500" />
) : (
<WifiOff size={15} className="text-red-500" />
)}
<span className={`text-xs ${connected ? "text-emerald-500" : "text-red-500"}`}>
{connected ? "Live" : "Offline"}
</span>
</div>
{/* Logout (only if auth is active) */}
{token && (
<button
onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }}
className="text-slate-600 hover:text-slate-400 transition-colors"
title="Logout"
>
<LogOut size={16} />
</button>
)}
</div>
</div>
{/* Canvas */}
<div className="flex-1 min-h-0">
<ReactFlow
onInit={(instance) => { 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 }}
>
<Background color="#1e293b" gap={24} size={1} />
<Controls position="bottom-left" />
{/* Edge legend */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-slate-900/90 border border-slate-800 rounded-lg px-5 py-2.5 z-10">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Conexiones</span>
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-5 h-0.5 rounded-full" style={{ backgroundColor: color }} />
<Icon size={13} style={{ color }} />
<span className="text-xs" style={{ color }}>{label}</span>
</div>
))}
</div>
<MiniMap
position="bottom-right"
nodeColor={(n) => {
const state = (n.data as any)?.state;
if (state === "running") return "#22c55e";
if (state === "exited" || state === "dead") return "#ef4444";
return "#f59e0b";
}}
style={{ background: "#0f172a" }}
/>
</ReactFlow>
</div>
{logPanelService && (
<LogPanel
service={logPanelService}
logLines={logLines}
token={token}
onClose={() => { setLogPanelService(null); setSelectedNode(null); }}
sendMessage={sendMessage}
clearLogLines={clearLogLines}
/>
)}
</div>
);
}
+358
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, (typeof DYNAMIC_COLORS)[0]>();
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<string, Stats>
): LayoutResult {
if (services.length === 0) return { nodes: [], edges: [] };
// Group services by project/compose_file (always consistent)
const groups = new Map<string, Service[]>();
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<string, { x: number; y: number; width: number; height: number }>();
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<string, string> = {
database: "#336791",
cache: "#F59E0B",
broker: "#A855F7",
proxy: "#22C55E",
};
export function computeEdges(
currentNodes: Node[],
connections: Connection[]
): { edges: Edge[]; activeHandles: Map<string, string[]> } {
// Build absolute positions from current nodes
const absPositions = new Map<string, { x: number; y: number }>();
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<string, SlotEntry[]>();
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<string, number>();
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<string, EdgeInfo[]>();
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<string, number>();
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<string, Set<string>>();
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<string, string[]>();
for (const [id, set] of active) activeHandles.set(id, [...set]);
return { edges, activeHandles };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+138
View File
@@ -0,0 +1,138 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
function arraysEqual<T extends { uid?: string; name?: string }>(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<Service[]>([]);
const [connections, setConnections] = useState<Connection[]>([]);
const statsRef = useRef<Map<string, Stats>>(new Map());
const [statsVersion, setStatsVersion] = useState(0);
const [events, setEvents] = useState<DockerEvent[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Initial HTTP fetch so data loads even if WS is slow
useEffect(() => {
const headers: Record<string, string> = {};
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 };
}
+81
View File
@@ -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); }
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DockerFlow AlteonX</title>
<link rel="icon" type="image/png" href="/favicon.png" />
</head>
<body class="bg-slate-950 text-white">
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
+73
View File
@@ -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<string, { icon: typeof Server; color: string; borderColor: string }> = {
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<string, (typeof projectColors)[0]>();
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 (
<div className="absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
<Icon size={16} style={{ color: config.color }} />
<span
className="text-sm font-semibold tracking-wider uppercase"
style={{ color: config.color }}
>
{d.label}
</span>
{d.subtitle && (
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]">
{d.subtitle}
</span>
)}
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} />
{d.count != null && (
<div className="flex items-center gap-1.5">
<Box size={12} style={{ color: config.borderColor }} />
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
{d.count}
</span>
</div>
)}
</div>
);
});
+214
View File
@@ -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<string, { ring: string; dot: string; bg: string }> = {
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<string>((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 (
<div
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${(d as any).id || ""}\nPorts: ${d.ports?.map((p) => `${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) => (
<Handle key={`t${i}`} type="source" position={Position.Top} id={`top-${i}`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`tt${i}`} type="target" position={Position.Top} id={`top-${i}-target`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
))}
{/* Left handles — top offset, transform centered vertically */}
{offsets.map((o, i) => (
<Handle key={`l${i}`} type="source" position={Position.Left} id={`left-${i}`} className={hdot(`left-${i}`)} style={{ top: o, transform: "translate(-50%, -50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`lt${i}`} type="target" position={Position.Left} id={`left-${i}-target`} className={hdot(`left-${i}`)} style={{ top: o, transform: "translate(-50%, -50%)" }} />
))}
{/* Right handles — top offset, transform centered */}
{offsets.map((o, i) => (
<Handle key={`r${i}`} type="source" position={Position.Right} id={`right-${i}`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`rt${i}`} type="target" position={Position.Right} id={`right-${i}-target`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
))}
{/* Header: icon + name + status dot */}
<div className="flex items-center gap-2.5 mb-2">
<div
className="flex items-center justify-center w-8 h-8 rounded-lg"
style={{ backgroundColor: `${iconColor}22` }}
>
<Icon size={18} style={{ color: iconColor }} />
</div>
<span className="font-bold text-white text-sm truncate">{d.label}</span>
<div
className={`w-2 h-2 rounded-full shrink-0 ${s.dot}
${d.state === "running" ? "animate-pulse" : ""}`}
/>
<div className="flex-1" />
</div>
{/* Image */}
<div className="text-xs text-slate-500 truncate mb-2 pl-10">{d.image}</div>
{/* Ports */}
{d.ports?.length > 0 && (
<div className="flex gap-1.5 flex-wrap mb-2 pl-10">
{d.ports.map((p) => (
<span
key={`${p.host}:${p.container}`}
className="text-[11px] bg-slate-800/80 text-cyan-400 px-2 py-0.5 rounded font-mono"
>
:{p.host}
</span>
))}
</div>
)}
{/* Stats */}
{d.stats && (
<div className="mt-2 space-y-1.5">
<div className="flex justify-between text-[11px] text-slate-400">
<span>CPU {d.stats.cpu.toFixed(1)}%</span>
<span>MEM {d.stats.mem_mb.toFixed(0)}MB</span>
</div>
<div className="flex gap-1.5">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700"
style={{ width: `${Math.min(d.stats.cpu, 100)}%` }}
/>
</div>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500/60 rounded-full transition-all duration-700"
style={{ width: `${Math.min(d.stats.mem_percent, 100)}%` }}
/>
</div>
</div>
</div>
)}
{/* Bottom handles — left offset, transform centered */}
{offsets.map((o, i) => (
<Handle key={`b${i}`} type="source" position={Position.Bottom} id={`bottom-${i}`} className={hdot(`bottom-${i}`)} style={{ left: o, transform: "translate(-50%, 50%)" }} />
))}
{offsets.map((o, i) => (
<Handle key={`bt${i}`} type="target" position={Position.Bottom} id={`bottom-${i}-target`} className={hdot(`bottom-${i}`)} style={{ left: o, transform: "translate(-50%, 50%)" }} />
))}
</div>
);
});
+141
View File
@@ -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<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [loading, setLoading] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const subscribedRef = useRef<string | null>(null);
// Fetch initial logs + subscribe to streaming
useEffect(() => {
setInitialLogs([]);
setLoading(true);
clearLogLines();
const headers: Record<string, string> = {};
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 (
<div className="log-panel shrink-0 flex flex-col bg-slate-900/95 backdrop-blur-sm border-t border-slate-700/80" style={{ height: "320px" }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-800 bg-slate-900 shrink-0">
<div className="flex items-center gap-3">
<Terminal size={14} className="text-cyan-400" />
<span className="text-sm font-medium text-white">{service.name}</span>
<span className={`text-xs font-mono ${stateColor}`}>{service.state}</span>
{service.state === "running" && subscribedRef.current && (
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
streaming
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setAutoScroll((v) => !v)}
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll"}
>
{autoScroll ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
title="Close"
>
<X size={14} />
</button>
</div>
</div>
{/* Log body */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs leading-5 px-4 py-2"
>
{loading && (
<div className="text-slate-500 py-4 text-center">Loading logs...</div>
)}
{!loading && allLines.length === 0 && (
<div className="text-slate-500 py-4 text-center">No logs available</div>
)}
{allLines.map((l, i) => (
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
{l.timestamp && (
<span className="text-slate-600 shrink-0 select-none pr-3 whitespace-nowrap">
{formatTimestamp(l.timestamp)}
</span>
)}
<span className={`whitespace-pre-wrap break-all ${l.stream === "stderr" ? "text-red-400" : "text-slate-300"}`}>
{l.line}
</span>
</div>
))}
</div>
</div>
);
}
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);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+245
View File
@@ -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<Service[]> {
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<Connection[]> {
const connections: Connection[] = [];
const seen = new Set<string>();
// 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<LogLine[]> {
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();
}
},
};
}
+202
View File
@@ -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<WebSocket>();
const logStreams = new Map<WebSocket, { destroy: () => 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`);
+71
View File
@@ -0,0 +1,71 @@
import { docker } from "./docker";
import type { Service, Stats, DockerEvent } from "../shared/types";
export async function pollStats(services: Service[]): Promise<Stats[]> {
const running = services.filter((s) => s.state === "running");
const results: Stats[] = [];
for (const svc of running) {
try {
const container = docker.getContainer(svc.id);
const raw = await container.stats({ stream: false });
const cpuDelta =
raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
const sysDelta =
raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
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 {}
});
});
}
+50
View File
@@ -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 };
+25
View File
@@ -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.
+21
View File
@@ -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.
+24
View File
@@ -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.
+21
View File
@@ -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.
@@ -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.
+23
View File
@@ -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.
+23
View File
@@ -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 <token>` 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.
+29
View File
@@ -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.
+20
View File
@@ -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"]
}
+22
View File
@@ -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,
},
},
},
});