mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.1
This commit is contained in:
@@ -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=
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.dockerflow-positions.json
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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 };
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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 {}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user