mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.21
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
@@ -68,91 +68,46 @@ bun run start
|
|||||||
- **Conexiones inteligentes** — detecta relaciones app→database, app→cache, proxy→app, worker→broker
|
- **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
|
- **Metricas en tiempo real** — CPU y memoria por container, actualizado cada 3 segundos
|
||||||
- **Eventos Docker** — flash visual cuando un container inicia, para o reinicia
|
- **Eventos Docker** — flash visual cuando un container inicia, para o reinicia
|
||||||
|
- **Panel de detalle** — click en un container para ver info, stats, variables de entorno y configuracion en tabs separados
|
||||||
|
- **Logs de containers** — logs en tiempo real con scroll automatico, filtro por stream (stdout/stderr) y opcion de copiar
|
||||||
|
- **Acciones sobre containers** — start, stop, restart, rebuild y remove directamente desde el panel
|
||||||
- **Filtro de proyectos** — dropdown para mostrar/ocultar proyectos, persiste entre sesiones
|
- **Filtro de proyectos** — dropdown para mostrar/ocultar proyectos, persiste entre sesiones
|
||||||
- **Autenticacion** — pantalla de login con AUTH_TOKEN para acceso remoto seguro
|
- **Autenticacion** — pantalla de login con AUTH_TOKEN para acceso remoto seguro
|
||||||
- **Simulacion de flujos** — particulas animadas que recorren los edges para visualizar como viajan los requests/datos entre servicios. Configurable via `flows.yaml`
|
|
||||||
- **Leyenda de conexiones** — colores por tipo: Database (azul), Cache (rojo), Broker (naranja), Proxy (verde)
|
- **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
|
- **Grupos visuales** — recuadros por proyecto/compose con titulo, archivo compose y conteo de containers
|
||||||
- **Logs de containers** — click en un container para ver sus logs en tiempo real, con scroll automatico y opcion de copiar
|
- **Menu contextual** — click derecho en un nodo para acciones rapidas
|
||||||
- **Tooltips** — hover sobre cada nodo para ver estado, imagen, ID y puertos
|
- **Pagina de monitoring** — vista de eventos Docker recientes
|
||||||
|
- **Pagina de settings** — configuracion de la aplicacion
|
||||||
|
|
||||||
## Simulacion de Flujos
|
## Tests
|
||||||
|
|
||||||
Crea un archivo `flows.yaml` en la raiz del proyecto para definir flujos de datos animados:
|
El proyecto usa [Vitest](https://vitest.dev/) para tests unitarios.
|
||||||
|
|
||||||
```yaml
|
|
||||||
flows:
|
|
||||||
api_request:
|
|
||||||
name: "API Request"
|
|
||||||
color: "#22d3ee"
|
|
||||||
speed: 1.4
|
|
||||||
path: [nginx, backend, db, backend, nginx]
|
|
||||||
|
|
||||||
background_job:
|
|
||||||
name: "Tarea en Background"
|
|
||||||
color: "#ec4899"
|
|
||||||
speed: 1.8
|
|
||||||
path: [celery-beat, ninja-redis, celery-worker, db]
|
|
||||||
|
|
||||||
settings:
|
|
||||||
particle_size: 2
|
|
||||||
trail: true
|
|
||||||
trail_opacity: 0.3
|
|
||||||
glow: true
|
|
||||||
max_particles: 50
|
|
||||||
```
|
|
||||||
|
|
||||||
- **path** usa los nombres de los servicios (como aparecen en `docker compose ps`)
|
|
||||||
- **speed** controla la velocidad (mayor = mas lento)
|
|
||||||
- Las particulas recorren los edges entre nodos, incluyendo caminos de ida y vuelta
|
|
||||||
- Al llegar a cada nodo, la particula hace una pausa y el nodo se ilumina con el color del flujo
|
|
||||||
- Si no existe `flows.yaml`, el panel de flujos no aparece
|
|
||||||
- **Modo Demo** — boton "Demo" en el panel que auto-simula flujos en loop cada ~3 segundos, ciclando entre todos los flujos definidos. Ideal para presentaciones
|
|
||||||
|
|
||||||
## MCP Server (Claude Code integration)
|
|
||||||
|
|
||||||
DockerFlow incluye un servidor MCP (Model Context Protocol) que permite gestionar flujos y monitorear servicios Docker desde Claude Code u otro LLM compatible, sin abrir el browser.
|
|
||||||
|
|
||||||
### Instalacion
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run setup:mcp
|
# Correr todos los tests
|
||||||
|
bun run test
|
||||||
|
|
||||||
|
# Correr en modo watch (re-ejecuta al guardar)
|
||||||
|
bun run test:watch
|
||||||
|
|
||||||
|
# Verificar tipos TypeScript
|
||||||
|
bun run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
Esto configura el MCP server de forma **global** en `~/.claude/settings.json`. Reinicia Claude Code y las herramientas quedan disponibles en **todos** tus proyectos.
|
Los tests cubren:
|
||||||
|
|
||||||
Para ejecutar el servidor MCP manualmente (debug):
|
- **Logica de processing** (`src/client/hooks/processing.test.ts`) — sincronizacion de estados cuando se ejecutan acciones sobre containers (start/stop/restart), incluyendo manejo de estados crashed/dead, timeouts y minDuration
|
||||||
|
- **Deteccion de conexiones** (`src/server/docker.test.ts`) — descubrimiento de relaciones entre servicios por red compartida, clasificacion de servicios (infra, proxy, worker) y deduplicacion
|
||||||
|
|
||||||
```bash
|
## CI
|
||||||
bun run mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### Herramientas disponibles
|
GitHub Actions ejecuta automaticamente en cada push/PR a `main`:
|
||||||
|
|
||||||
| Tool | Descripcion |
|
1. Typecheck (errores de tipos)
|
||||||
|---|---|
|
2. Tests (Vitest)
|
||||||
| `list_flows` | Lista flujos configurados y settings |
|
3. Build (produccion)
|
||||||
| `create_flow` | Crea un flujo nuevo en flows.yaml |
|
|
||||||
| `update_flow` | Modifica un flujo existente |
|
|
||||||
| `delete_flow` | Elimina un flujo de flows.yaml |
|
|
||||||
| `simulate_flow` | Info para disparar simulacion en clientes conectados |
|
|
||||||
| `list_services` | Servicios Docker con estado, imagen, puertos |
|
|
||||||
| `get_stats` | CPU/memoria por servicio |
|
|
||||||
| `get_logs` | Ultimas N lineas de logs de un container |
|
|
||||||
| `get_connections` | Conexiones detectadas entre servicios |
|
|
||||||
| `start_dashboard` | Arranca el servidor dev (Vite + backend) |
|
|
||||||
| `stop_dashboard` | Para el servidor dev |
|
|
||||||
|
|
||||||
### Uso desde Claude Code
|
Ver `.github/workflows/ci.yml`.
|
||||||
|
|
||||||
Una vez configurado, las herramientas estan disponibles directamente. Ejemplos:
|
|
||||||
|
|
||||||
- "Arranca el dashboard en modo dev"
|
|
||||||
- "Lista los servicios Docker corriendo"
|
|
||||||
- "Muestra los logs del container abc123"
|
|
||||||
- "Crea un flujo llamado api_request que pase por nginx, backend y db"
|
|
||||||
- "Cuanto CPU esta usando cada servicio?"
|
|
||||||
- "Para el dashboard"
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -166,6 +121,7 @@ Una vez configurado, las herramientas estan disponibles directamente. Ejemplos:
|
|||||||
| Iconos | Lucide React |
|
| Iconos | Lucide React |
|
||||||
| Docker API | dockerode |
|
| Docker API | dockerode |
|
||||||
| Comunicacion | WebSocket nativo |
|
| Comunicacion | WebSocket nativo |
|
||||||
|
| Tests | Vitest |
|
||||||
|
|
||||||
## Estructura
|
## Estructura
|
||||||
|
|
||||||
@@ -175,7 +131,6 @@ src/
|
|||||||
index.ts — servidor Hono + WebSocket + CLI args
|
index.ts — servidor Hono + WebSocket + CLI args
|
||||||
docker.ts — descubrimiento de servicios y conexiones
|
docker.ts — descubrimiento de servicios y conexiones
|
||||||
watcher.ts — polling de stats + stream de eventos Docker
|
watcher.ts — polling de stats + stream de eventos Docker
|
||||||
mcp.ts — servidor MCP (stdio) para Claude Code
|
|
||||||
client/
|
client/
|
||||||
App.tsx — dashboard principal + login screen
|
App.tsx — dashboard principal + login screen
|
||||||
main.tsx — entry point React
|
main.tsx — entry point React
|
||||||
@@ -185,17 +140,23 @@ src/
|
|||||||
GroupNode.tsx — header de grupo (proyecto/compose)
|
GroupNode.tsx — header de grupo (proyecto/compose)
|
||||||
hooks/
|
hooks/
|
||||||
useDocker.ts — hook WebSocket para datos en tiempo real
|
useDocker.ts — hook WebSocket para datos en tiempo real
|
||||||
|
processing.ts — logica pura de estados processing
|
||||||
engine/
|
engine/
|
||||||
layout.ts — layout de grupos + grid + edges
|
layout.ts — layout de grupos + grid + edges
|
||||||
particles.ts — motor de particulas (spawn, tick, pausa en nodos)
|
|
||||||
components/
|
components/
|
||||||
ParticleOverlay.tsx — renderizado SVG de particulas sobre edges
|
HeaderBar.tsx — barra superior con navegacion
|
||||||
|
EdgeLegend.tsx — leyenda de tipos de conexion
|
||||||
|
LoginScreen.tsx — pantalla de autenticacion
|
||||||
|
NodeContextMenu.tsx — menu contextual de nodos
|
||||||
|
OffsetEdge.tsx — edge custom con offset para evitar superposicion
|
||||||
panels/
|
panels/
|
||||||
LogPanel.tsx — panel de logs por container
|
DetailPanel.tsx — panel lateral con info, stats, env, config y logs
|
||||||
FlowPanel.tsx — panel de simulacion de flujos
|
LogPanel.tsx — panel de logs por container
|
||||||
|
pages/
|
||||||
|
MonitoringPage.tsx — vista de eventos Docker
|
||||||
|
SettingsPage.tsx — configuracion de la aplicacion
|
||||||
shared/
|
shared/
|
||||||
types.ts — tipos compartidos server/client
|
types.ts — tipos compartidos server/client
|
||||||
flows.yaml — configuracion de flujos (opcional)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Licencia
|
## Licencia
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dagrejs/dagre": "^1",
|
"@dagrejs/dagre": "^1",
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
|
"@types/bun": "^1.3.13",
|
||||||
"@types/dockerode": "^3",
|
"@types/dockerode": "^3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite": "^6",
|
"vite": "^6",
|
||||||
|
"vitest": "^4.1.5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -218,6 +220,8 @@
|
|||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@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/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": ["@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=="],
|
||||||
@@ -256,6 +260,10 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
"@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-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
|
||||||
@@ -268,6 +276,8 @@
|
|||||||
|
|
||||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
|
"@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/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
|
||||||
@@ -284,6 +294,20 @@
|
|||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
"@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
|
||||||
|
|
||||||
|
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
|
||||||
|
|
||||||
|
"@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
|
||||||
|
|
||||||
|
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
|
||||||
|
|
||||||
|
"@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
|
||||||
|
|
||||||
|
"@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
|
||||||
|
|
||||||
"@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/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=="],
|
"@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=="],
|
||||||
@@ -300,6 +324,8 @@
|
|||||||
|
|
||||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||||
|
|
||||||
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"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=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
|
||||||
@@ -316,6 +342,8 @@
|
|||||||
|
|
||||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
@@ -324,6 +352,8 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
|
||||||
|
|
||||||
|
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"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=="],
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
@@ -402,6 +432,8 @@
|
|||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
@@ -410,12 +442,16 @@
|
|||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||||
|
|
||||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
|
|
||||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||||
|
|
||||||
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||||
@@ -550,6 +586,8 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
@@ -560,6 +598,8 @@
|
|||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||||
|
|
||||||
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
@@ -626,14 +666,20 @@
|
|||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||||
|
|
||||||
"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-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=="],
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
@@ -650,8 +696,14 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||||
@@ -680,8 +732,12 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|||||||
+7
-2
@@ -12,7 +12,10 @@
|
|||||||
"start": "bun run src/server/index.ts --all",
|
"start": "bun run src/server/index.ts --all",
|
||||||
"preview": "bun run build && bun run start",
|
"preview": "bun run build && bun run start",
|
||||||
"mcp": "bun src/server/mcp.ts",
|
"mcp": "bun src/server/mcp.ts",
|
||||||
"setup:mcp": "bun scripts/install-mcp.ts"
|
"setup:mcp": "bun scripts/install-mcp.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dagrejs/dagre": "^1",
|
"@dagrejs/dagre": "^1",
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
|
"@types/bun": "^1.3.13",
|
||||||
"@types/dockerode": "^3",
|
"@types/dockerode": "^3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -35,6 +39,7 @@
|
|||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite": "^6"
|
"vite": "^6",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { applyProcessing, arraysEqual, type ProcessingEntry } from "./processing";
|
||||||
|
import type { Service } from "../../shared/types";
|
||||||
|
|
||||||
|
function makeSvc(overrides: Partial<Service> = {}): Service {
|
||||||
|
return {
|
||||||
|
id: "abc123",
|
||||||
|
uid: "proj/svc",
|
||||||
|
name: "svc",
|
||||||
|
image: "node:20",
|
||||||
|
state: "running",
|
||||||
|
status: "Up 5 minutes",
|
||||||
|
ports: [],
|
||||||
|
networks: ["default"],
|
||||||
|
network_ips: {},
|
||||||
|
project: "proj",
|
||||||
|
compose_file: "",
|
||||||
|
env: [],
|
||||||
|
restart_policy: "",
|
||||||
|
memory_limit: 0,
|
||||||
|
cpu_quota: 0,
|
||||||
|
health_status: "",
|
||||||
|
health_log: [],
|
||||||
|
exit_code: 0,
|
||||||
|
restart_count: 0,
|
||||||
|
oom_killed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("arraysEqual", () => {
|
||||||
|
it("returns true for identical arrays", () => {
|
||||||
|
const a = [makeSvc({ uid: "a", state: "running" })];
|
||||||
|
expect(arraysEqual(a, a)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for different lengths", () => {
|
||||||
|
const a = [makeSvc()];
|
||||||
|
expect(arraysEqual(a, [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when uid differs", () => {
|
||||||
|
const a = [makeSvc({ uid: "a" })];
|
||||||
|
const b = [makeSvc({ uid: "b" })];
|
||||||
|
expect(arraysEqual(a, b)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when state differs", () => {
|
||||||
|
const a = [makeSvc({ uid: "a", state: "running" })];
|
||||||
|
const b = [makeSvc({ uid: "a", state: "exited" })];
|
||||||
|
expect(arraysEqual(a, b)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when uid and state match despite other differences", () => {
|
||||||
|
const a = [makeSvc({ uid: "a", state: "running", image: "node:18" })];
|
||||||
|
const b = [makeSvc({ uid: "a", state: "running", image: "node:20" })];
|
||||||
|
expect(arraysEqual(a, b)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyProcessing", () => {
|
||||||
|
it("returns raw unchanged when no processing entries", () => {
|
||||||
|
const raw = [makeSvc()];
|
||||||
|
const processing = new Map<string, ProcessingEntry>();
|
||||||
|
expect(applyProcessing(raw, processing)).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows processing state when minDuration has not elapsed", () => {
|
||||||
|
const now = 10000;
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("processing");
|
||||||
|
// Entry should NOT be deleted yet
|
||||||
|
expect(processing.has("proj/svc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears processing when state matches expected after minDuration", () => {
|
||||||
|
const now = 12000;
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "running" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("running");
|
||||||
|
expect(processing.has("proj/svc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crashed matches expected exited (stop bug)", () => {
|
||||||
|
const now = 12000;
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "crashed" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "exited", startedAt: 9000, minDuration: 2000 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("crashed");
|
||||||
|
expect(processing.has("proj/svc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dead matches expected exited", () => {
|
||||||
|
const now = 12000;
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "dead" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "exited", startedAt: 9000, minDuration: 2000 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("dead");
|
||||||
|
expect(processing.has("proj/svc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crashed clears processing when expected is running (crash on start)", () => {
|
||||||
|
const now = 12000;
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "crashed" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("crashed");
|
||||||
|
expect(processing.has("proj/svc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("15s timeout clears processing as safety net", () => {
|
||||||
|
const startedAt = 1000;
|
||||||
|
const now = startedAt + 16000; // > 15s
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "running", startedAt, minDuration: 0 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("exited");
|
||||||
|
expect(processing.has("proj/svc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps processing when state does not match and within timeout", () => {
|
||||||
|
const now = 5000;
|
||||||
|
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/svc", { expected: "running", startedAt: 3000, minDuration: 0 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("processing");
|
||||||
|
expect(processing.has("proj/svc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect services without processing entries", () => {
|
||||||
|
const now = 5000;
|
||||||
|
const raw = [
|
||||||
|
makeSvc({ uid: "proj/a", state: "running" }),
|
||||||
|
makeSvc({ uid: "proj/b", state: "exited" }),
|
||||||
|
];
|
||||||
|
const processing = new Map<string, ProcessingEntry>([
|
||||||
|
["proj/a", { expected: "exited", startedAt: 3000, minDuration: 0 }],
|
||||||
|
]);
|
||||||
|
const result = applyProcessing(raw, processing, now);
|
||||||
|
expect(result[0].state).toBe("processing"); // a is processing
|
||||||
|
expect(result[1].state).toBe("exited"); // b unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Service } from "../../shared/types";
|
||||||
|
|
||||||
|
export interface ProcessingEntry {
|
||||||
|
expected: Service["state"];
|
||||||
|
startedAt: number;
|
||||||
|
minDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arraysEqual(a: Service[], b: Service[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].uid !== b[i].uid) return false;
|
||||||
|
if (a[i].state !== b[i].state) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProcessing(
|
||||||
|
raw: Service[],
|
||||||
|
processing: Map<string, ProcessingEntry>,
|
||||||
|
now = Date.now(),
|
||||||
|
): Service[] {
|
||||||
|
if (processing.size === 0) return raw;
|
||||||
|
return raw.map((s: Service) => {
|
||||||
|
const entry = processing.get(s.uid);
|
||||||
|
if (!entry) return s;
|
||||||
|
const elapsed = now - entry.startedAt;
|
||||||
|
if (elapsed < entry.minDuration) {
|
||||||
|
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
|
||||||
|
}
|
||||||
|
if (s.state === entry.expected) {
|
||||||
|
processing.delete(s.uid);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Stop/remove expects "exited" but Docker may report "crashed" or "dead" (non-zero exit from SIGTERM/SIGKILL)
|
||||||
|
if (entry.expected === "exited" && (s.state === "crashed" || s.state === "dead" || s.state === "exited")) {
|
||||||
|
processing.delete(s.uid);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (s.state === "crashed" && entry.expected === "running") {
|
||||||
|
processing.delete(s.uid);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (now - entry.startedAt > 15000) {
|
||||||
|
processing.delete(s.uid);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
||||||
import type { StatsStore } from "./useStatsStore";
|
import type { StatsStore } from "./useStatsStore";
|
||||||
|
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
|
||||||
function arraysEqual(a: Service[], b: Service[]): boolean {
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
if (a[i].uid !== b[i].uid) return false;
|
|
||||||
if (a[i].state !== b[i].state) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
@@ -19,6 +11,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||||
// Processing state: uid → { expected state, start time, min duration before clearing }
|
// Processing state: uid → { expected state, start time, min duration before clearing }
|
||||||
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
|
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
|
||||||
|
const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||||
const lastRawServicesRef = useRef<Service[]>([]);
|
const lastRawServicesRef = useRef<Service[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
@@ -26,30 +19,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
|
|
||||||
// Apply processing overlay to raw services data
|
// Apply processing overlay to raw services data
|
||||||
const applyProcessing = useCallback((raw: Service[]): Service[] => {
|
const applyProcessing = useCallback((raw: Service[]): Service[] => {
|
||||||
const processing = processingRef.current;
|
return applyProcessingPure(raw, processingRef.current);
|
||||||
if (processing.size === 0) return raw;
|
|
||||||
const now = Date.now();
|
|
||||||
return raw.map((s: Service) => {
|
|
||||||
const entry = processing.get(s.uid);
|
|
||||||
if (!entry) return s;
|
|
||||||
const elapsed = now - entry.startedAt;
|
|
||||||
if (elapsed < entry.minDuration) {
|
|
||||||
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
|
|
||||||
}
|
|
||||||
if (s.state === entry.expected) {
|
|
||||||
processing.delete(s.uid);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
if (s.state === "crashed" && entry.expected === "running") {
|
|
||||||
processing.delete(s.uid);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
if (now - entry.startedAt > 15000) {
|
|
||||||
processing.delete(s.uid);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Single init call: services + connections + positions
|
// Single init call: services + connections + positions
|
||||||
@@ -144,7 +114,15 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
break;
|
break;
|
||||||
case "action_error": {
|
case "action_error": {
|
||||||
processingRef.current.delete(msg.data.uid);
|
processingRef.current.delete(msg.data.uid);
|
||||||
setServices((prev) => [...prev]);
|
const clearInterval_ = processingIntervalsRef.current.get(msg.data.uid);
|
||||||
|
if (clearInterval_) { clearInterval(clearInterval_); processingIntervalsRef.current.delete(msg.data.uid); }
|
||||||
|
const raw = lastRawServicesRef.current;
|
||||||
|
if (raw.length > 0) {
|
||||||
|
const incoming = applyProcessing(raw);
|
||||||
|
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||||
|
} else {
|
||||||
|
setServices((prev) => prev.map((s) => s.uid === msg.data.uid ? { ...s, state: "exited" as any } : s));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +156,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
wsRef.current.onclose = null;
|
wsRef.current.onclose = null;
|
||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
|
// Clean up all processing intervals
|
||||||
|
for (const interval of processingIntervalsRef.current.values()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
processingIntervalsRef.current.clear();
|
||||||
};
|
};
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
@@ -196,25 +179,43 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
|
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
|
||||||
actionTimestamps.current.set(uid, Math.floor(startedAt / 1000));
|
actionTimestamps.current.set(uid, Math.floor(startedAt / 1000));
|
||||||
setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s));
|
setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s));
|
||||||
// Re-evaluate every 1s after minDuration until processing clears.
|
// Clear any existing interval for this uid (e.g. rapid re-clicks)
|
||||||
// Handles the case where the server stops broadcasting (hash unchanged).
|
const prevInterval = processingIntervalsRef.current.get(uid);
|
||||||
if (minDuration > 0) {
|
if (prevInterval) clearInterval(prevInterval);
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (!processingRef.current.has(uid)) { clearInterval(interval); return; }
|
// Re-evaluate every 1s until processing clears.
|
||||||
const elapsed = Date.now() - startedAt;
|
// Always create interval — even for minDuration=0 — so the 15s timeout safety net works
|
||||||
if (elapsed < minDuration) return;
|
// when the server stops broadcasting (hash unchanged).
|
||||||
const incoming = applyProcessing(lastRawServicesRef.current);
|
const interval = setInterval(() => {
|
||||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
if (!processingRef.current.has(uid)) {
|
||||||
// applyProcessing deletes the entry when resolved or timed out (15s)
|
clearInterval(interval);
|
||||||
if (!processingRef.current.has(uid)) clearInterval(interval);
|
processingIntervalsRef.current.delete(uid);
|
||||||
}, 1000);
|
return;
|
||||||
}
|
}
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
if (elapsed < minDuration) return;
|
||||||
|
const raw = lastRawServicesRef.current;
|
||||||
|
if (raw.length === 0) return;
|
||||||
|
const incoming = applyProcessing(raw);
|
||||||
|
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||||
|
if (!processingRef.current.has(uid)) {
|
||||||
|
clearInterval(interval);
|
||||||
|
processingIntervalsRef.current.delete(uid);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
processingIntervalsRef.current.set(uid, interval);
|
||||||
}, [applyProcessing]);
|
}, [applyProcessing]);
|
||||||
|
|
||||||
const clearProcessing = useCallback((uid: string) => {
|
const clearProcessing = useCallback((uid: string) => {
|
||||||
processingRef.current.delete(uid);
|
processingRef.current.delete(uid);
|
||||||
setServices((prev) => [...prev]);
|
const interval = processingIntervalsRef.current.get(uid);
|
||||||
}, []);
|
if (interval) { clearInterval(interval); processingIntervalsRef.current.delete(uid); }
|
||||||
|
const raw = lastRawServicesRef.current;
|
||||||
|
if (raw.length > 0) {
|
||||||
|
const incoming = applyProcessing(raw);
|
||||||
|
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||||
|
}
|
||||||
|
}, [applyProcessing]);
|
||||||
|
|
||||||
const getLogsSince = useCallback((uid: string): number | undefined => {
|
const getLogsSince = useCallback((uid: string): number | undefined => {
|
||||||
return actionTimestamps.current.get(uid);
|
return actionTimestamps.current.get(uid);
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
onAction(service.uid, expectedState, minDuration);
|
onAction(service.uid, expectedState, minDuration);
|
||||||
setInitialLogs([]);
|
setInitialLogs([]);
|
||||||
clearLogLines();
|
clearLogLines();
|
||||||
|
// Unsubscribe log stream to prevent stale lines during the action
|
||||||
|
sendMessage({ type: "unsubscribe_logs" });
|
||||||
|
subscribedRef.current = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -135,7 +138,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
setTimeout(() => setActionResult(null), 3000);
|
setTimeout(() => setActionResult(null), 3000);
|
||||||
}
|
}
|
||||||
}, [service.id, service.uid, token, onAction, clearProcessing]);
|
}, [service.id, service.uid, token, onAction, clearProcessing, sendMessage, clearLogLines]);
|
||||||
|
|
||||||
const runExec = useCallback(async () => {
|
const runExec = useCallback(async () => {
|
||||||
if (!execCmd.trim()) return;
|
if (!execCmd.trim()) return;
|
||||||
@@ -174,8 +177,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
const since = actionTimestampRef.current;
|
const since = actionTimestampRef.current;
|
||||||
const sinceParam = since ? `&since=${since}` : "";
|
fetch(`/api/logs/${service.id}?tail=200&since=${since || Math.floor(Date.now() / 1000)}`, { headers })
|
||||||
fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers })
|
|
||||||
.then((r) => r.ok ? r.json() : [])
|
.then((r) => r.ok ? r.json() : [])
|
||||||
.then((lines: LogLine[]) => setInitialLogs(lines))
|
.then((lines: LogLine[]) => setInitialLogs(lines))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -259,10 +261,11 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Docker events as special log lines
|
// Docker events as special log lines (only show events since last action, or all if no action)
|
||||||
const eventLogLines = useMemo(() => {
|
const eventLogLines = useMemo(() => {
|
||||||
|
const sinceTs = actionTimestampRef.current;
|
||||||
return events
|
return events
|
||||||
.filter((e) => e.service === service.uid)
|
.filter((e) => e.service === service.uid && (!sinceTs || e.time >= sinceTs))
|
||||||
.map((e): LogLine => ({
|
.map((e): LogLine => ({
|
||||||
container: service.id,
|
container: service.id,
|
||||||
line: `[DOCKER] Container ${e.action}`,
|
line: `[DOCKER] Container ${e.action}`,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { discoverConnections, isInfraService, isProxyService, isWorkerService } from "./docker";
|
||||||
|
import type { Service } from "../shared/types";
|
||||||
|
|
||||||
|
function makeSvc(overrides: Partial<Service> = {}): Service {
|
||||||
|
return {
|
||||||
|
id: "abc123",
|
||||||
|
uid: "proj/svc",
|
||||||
|
name: "svc",
|
||||||
|
image: "node:20",
|
||||||
|
state: "running",
|
||||||
|
status: "Up 5 minutes",
|
||||||
|
ports: [],
|
||||||
|
networks: ["default"],
|
||||||
|
network_ips: {},
|
||||||
|
project: "proj",
|
||||||
|
compose_file: "",
|
||||||
|
env: [],
|
||||||
|
restart_policy: "",
|
||||||
|
memory_limit: 0,
|
||||||
|
cpu_quota: 0,
|
||||||
|
health_status: "",
|
||||||
|
health_log: [],
|
||||||
|
exit_code: 0,
|
||||||
|
restart_count: 0,
|
||||||
|
oom_killed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isInfraService", () => {
|
||||||
|
it("detects postgres by image", () => {
|
||||||
|
expect(isInfraService(makeSvc({ image: "postgres:16" }))).toEqual({ type: "database", label: "postgres" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects redis by name", () => {
|
||||||
|
expect(isInfraService(makeSvc({ name: "redis-cache" }))).toEqual({ type: "cache", label: "redis" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for app service", () => {
|
||||||
|
expect(isInfraService(makeSvc({ name: "backend", image: "node:20" }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isProxyService", () => {
|
||||||
|
it("detects nginx", () => {
|
||||||
|
expect(isProxyService(makeSvc({ image: "nginx:latest" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects traefik by name", () => {
|
||||||
|
expect(isProxyService(makeSvc({ name: "traefik" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for app service", () => {
|
||||||
|
expect(isProxyService(makeSvc({ name: "api", image: "node:20" }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isWorkerService", () => {
|
||||||
|
it("detects celery worker", () => {
|
||||||
|
expect(isWorkerService(makeSvc({ name: "celery-worker" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects beat scheduler", () => {
|
||||||
|
expect(isWorkerService(makeSvc({ name: "celery-beat" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for app service", () => {
|
||||||
|
expect(isWorkerService(makeSvc({ name: "api" }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discoverConnections", () => {
|
||||||
|
it("connects app to infra in same network", async () => {
|
||||||
|
const services = [
|
||||||
|
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["backend"] }),
|
||||||
|
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||||
|
];
|
||||||
|
const conns = await discoverConnections(services);
|
||||||
|
expect(conns).toEqual([
|
||||||
|
{ from: "proj/api", to: "proj/db", network: "", type: "database", label: "postgres" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not connect services in different networks", async () => {
|
||||||
|
const services = [
|
||||||
|
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend"] }),
|
||||||
|
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||||
|
];
|
||||||
|
const conns = await discoverConnections(services);
|
||||||
|
expect(conns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects proxy to app in same network", async () => {
|
||||||
|
const services = [
|
||||||
|
makeSvc({ uid: "proj/nginx", name: "nginx", image: "nginx:latest", networks: ["frontend"] }),
|
||||||
|
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend"] }),
|
||||||
|
];
|
||||||
|
const conns = await discoverConnections(services);
|
||||||
|
expect(conns).toEqual([
|
||||||
|
{ from: "proj/nginx", to: "proj/api", network: "", type: "proxy", label: "upstream" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects worker to infra in same network", async () => {
|
||||||
|
const services = [
|
||||||
|
makeSvc({ uid: "proj/worker", name: "celery-worker", image: "app:latest", networks: ["backend"] }),
|
||||||
|
makeSvc({ uid: "proj/redis", name: "redis", image: "redis:7", networks: ["backend"] }),
|
||||||
|
];
|
||||||
|
const conns = await discoverConnections(services);
|
||||||
|
expect(conns).toEqual([
|
||||||
|
{ from: "proj/worker", to: "proj/redis", network: "", type: "cache", label: "broker" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates connections", async () => {
|
||||||
|
// A collector service that is also an app — should only get one connection to db
|
||||||
|
const services = [
|
||||||
|
makeSvc({ uid: "proj/collector", name: "collector", image: "node:20", networks: ["backend"] }),
|
||||||
|
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||||
|
];
|
||||||
|
const conns = await discoverConnections(services);
|
||||||
|
// collector is both an app (not infra/proxy/worker) AND matches the collector rule
|
||||||
|
// but deduplication should prevent duplicates
|
||||||
|
const keys = conns.map((c) => `${c.from}:${c.to}`);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles full stack with multiple service types", async () => {
|
||||||
|
const services = [
|
||||||
|
makeSvc({ uid: "proj/nginx", name: "nginx", image: "nginx:latest", networks: ["frontend", "backend"] }),
|
||||||
|
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend", "backend"] }),
|
||||||
|
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
|
||||||
|
makeSvc({ uid: "proj/redis", name: "redis", image: "redis:7", networks: ["backend"] }),
|
||||||
|
makeSvc({ uid: "proj/worker", name: "celery-worker", image: "app:latest", networks: ["backend"] }),
|
||||||
|
];
|
||||||
|
const conns = await discoverConnections(services);
|
||||||
|
|
||||||
|
const has = (from: string, to: string) => conns.some((c) => c.from === from && c.to === to);
|
||||||
|
|
||||||
|
expect(has("proj/api", "proj/db")).toBe(true);
|
||||||
|
expect(has("proj/api", "proj/redis")).toBe(true);
|
||||||
|
expect(has("proj/nginx", "proj/api")).toBe(true);
|
||||||
|
expect(has("proj/worker", "proj/redis")).toBe(true);
|
||||||
|
expect(has("proj/worker", "proj/db")).toBe(true);
|
||||||
|
// proxy should NOT connect to infra
|
||||||
|
expect(has("proj/nginx", "proj/db")).toBe(false);
|
||||||
|
expect(has("proj/nginx", "proj/redis")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-6
@@ -4,7 +4,9 @@ import type { Service, Connection, LogLine } from "../shared/types";
|
|||||||
|
|
||||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
|
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
|
||||||
|
|
||||||
if (!fs.existsSync(DOCKER_SOCKET)) {
|
const isTest = process.env.NODE_ENV === "test" || !!process.env.VITEST;
|
||||||
|
|
||||||
|
if (!isTest && !fs.existsSync(DOCKER_SOCKET)) {
|
||||||
console.error(`\n Error: Docker socket not found at ${DOCKER_SOCKET}`);
|
console.error(`\n Error: Docker socket not found at ${DOCKER_SOCKET}`);
|
||||||
console.error(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`);
|
console.error(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -107,7 +109,7 @@ const INFRA_PATTERNS: { pattern: string; type: string; label: string; role: "tar
|
|||||||
|
|
||||||
const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"];
|
const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"];
|
||||||
|
|
||||||
function isInfraService(svc: Service): { type: string; label: string } | null {
|
export function isInfraService(svc: Service): { type: string; label: string } | null {
|
||||||
const img = svc.image.toLowerCase();
|
const img = svc.image.toLowerCase();
|
||||||
const name = svc.name.toLowerCase();
|
const name = svc.name.toLowerCase();
|
||||||
for (const p of INFRA_PATTERNS) {
|
for (const p of INFRA_PATTERNS) {
|
||||||
@@ -118,13 +120,13 @@ function isInfraService(svc: Service): { type: string; label: string } | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProxyService(svc: Service): boolean {
|
export function isProxyService(svc: Service): boolean {
|
||||||
const img = svc.image.toLowerCase();
|
const img = svc.image.toLowerCase();
|
||||||
const name = svc.name.toLowerCase();
|
const name = svc.name.toLowerCase();
|
||||||
return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p));
|
return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWorkerService(svc: Service): boolean {
|
export function isWorkerService(svc: Service): boolean {
|
||||||
const name = svc.name.toLowerCase();
|
const name = svc.name.toLowerCase();
|
||||||
return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron");
|
return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron");
|
||||||
}
|
}
|
||||||
@@ -201,10 +203,14 @@ export async function getContainerLogs(id: string, tail = 200, since?: number):
|
|||||||
const opts: Record<string, any> = {
|
const opts: Record<string, any> = {
|
||||||
stdout: true,
|
stdout: true,
|
||||||
stderr: true,
|
stderr: true,
|
||||||
tail,
|
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
};
|
};
|
||||||
if (since) opts.since = since;
|
if (since) {
|
||||||
|
// When filtering by time, use since only (no tail limit) — Docker applies tail before since
|
||||||
|
opts.since = since;
|
||||||
|
} else {
|
||||||
|
opts.tail = tail;
|
||||||
|
}
|
||||||
const logBuffer = await container.logs(opts);
|
const logBuffer = await container.logs(opts);
|
||||||
|
|
||||||
const lines: LogLine[] = [];
|
const lines: LogLine[] = [];
|
||||||
|
|||||||
+12
-4
@@ -124,7 +124,7 @@ app.post("/api/containers/:id/stop", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const container = docker.getContainer(id);
|
const container = docker.getContainer(id);
|
||||||
await container.stop();
|
await container.stop();
|
||||||
// Docker events will trigger refresh automatically when state changes
|
immediateRefresh();
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already stopped" });
|
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already stopped" });
|
||||||
@@ -138,7 +138,7 @@ app.post("/api/containers/:id/start", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const container = docker.getContainer(id);
|
const container = docker.getContainer(id);
|
||||||
await container.start();
|
await container.start();
|
||||||
// Docker events will trigger refresh automatically when state changes
|
immediateRefresh();
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already running" });
|
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already running" });
|
||||||
@@ -152,7 +152,7 @@ app.post("/api/containers/:id/restart", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const container = docker.getContainer(id);
|
const container = docker.getContainer(id);
|
||||||
await container.restart();
|
await container.restart();
|
||||||
// Docker events will trigger refresh automatically when state changes
|
immediateRefresh();
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return c.json({ error: err?.message || "Failed to restart container" }, 500);
|
return c.json({ error: err?.message || "Failed to restart container" }, 500);
|
||||||
@@ -464,6 +464,14 @@ function scheduleRefresh() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immediate refresh after action endpoints (container already changed state)
|
||||||
|
function immediateRefresh() {
|
||||||
|
lastServicesHash = "";
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
refreshServices();
|
||||||
|
}
|
||||||
|
|
||||||
watchDockerEvents((event) => {
|
watchDockerEvents((event) => {
|
||||||
broadcast({ type: "docker_event", data: event });
|
broadcast({ type: "docker_event", data: event });
|
||||||
scheduleRefresh();
|
scheduleRefresh();
|
||||||
@@ -516,7 +524,7 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
message(ws, message) {
|
message(ws, message) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
|
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as unknown as ArrayBuffer));
|
||||||
const native = ws as unknown as WebSocket;
|
const native = ws as unknown as WebSocket;
|
||||||
|
|
||||||
// Handle authentication via first message
|
// Handle authentication via first message
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@shared": path.resolve(__dirname, "src/shared"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user