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
|
||||
- **Metricas en tiempo real** — CPU y memoria por container, actualizado cada 3 segundos
|
||||
- **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
|
||||
- **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)
|
||||
- **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
|
||||
- **Tooltips** — hover sobre cada nodo para ver estado, imagen, ID y puertos
|
||||
- **Menu contextual** — click derecho en un nodo para acciones rapidas
|
||||
- **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:
|
||||
|
||||
```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
|
||||
El proyecto usa [Vitest](https://vitest.dev/) para tests unitarios.
|
||||
|
||||
```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
|
||||
bun run mcp
|
||||
```
|
||||
## CI
|
||||
|
||||
### Herramientas disponibles
|
||||
GitHub Actions ejecuta automaticamente en cada push/PR a `main`:
|
||||
|
||||
| Tool | Descripcion |
|
||||
|---|---|
|
||||
| `list_flows` | Lista flujos configurados y settings |
|
||||
| `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 |
|
||||
1. Typecheck (errores de tipos)
|
||||
2. Tests (Vitest)
|
||||
3. Build (produccion)
|
||||
|
||||
### Uso desde Claude Code
|
||||
|
||||
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"
|
||||
Ver `.github/workflows/ci.yml`.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -166,6 +121,7 @@ Una vez configurado, las herramientas estan disponibles directamente. Ejemplos:
|
||||
| Iconos | Lucide React |
|
||||
| Docker API | dockerode |
|
||||
| Comunicacion | WebSocket nativo |
|
||||
| Tests | Vitest |
|
||||
|
||||
## Estructura
|
||||
|
||||
@@ -175,7 +131,6 @@ src/
|
||||
index.ts — servidor Hono + WebSocket + CLI args
|
||||
docker.ts — descubrimiento de servicios y conexiones
|
||||
watcher.ts — polling de stats + stream de eventos Docker
|
||||
mcp.ts — servidor MCP (stdio) para Claude Code
|
||||
client/
|
||||
App.tsx — dashboard principal + login screen
|
||||
main.tsx — entry point React
|
||||
@@ -185,17 +140,23 @@ src/
|
||||
GroupNode.tsx — header de grupo (proyecto/compose)
|
||||
hooks/
|
||||
useDocker.ts — hook WebSocket para datos en tiempo real
|
||||
processing.ts — logica pura de estados processing
|
||||
engine/
|
||||
layout.ts — layout de grupos + grid + edges
|
||||
particles.ts — motor de particulas (spawn, tick, pausa en nodos)
|
||||
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/
|
||||
LogPanel.tsx — panel de logs por container
|
||||
FlowPanel.tsx — panel de simulacion de flujos
|
||||
DetailPanel.tsx — panel lateral con info, stats, env, config y logs
|
||||
LogPanel.tsx — panel de logs por container
|
||||
pages/
|
||||
MonitoringPage.tsx — vista de eventos Docker
|
||||
SettingsPage.tsx — configuracion de la aplicacion
|
||||
shared/
|
||||
types.ts — tipos compartidos server/client
|
||||
flows.yaml — configuracion de flujos (opcional)
|
||||
```
|
||||
|
||||
## Licencia
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@dagrejs/dagre": "^1",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/dockerode": "^3",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -26,6 +27,7 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"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=="],
|
||||
|
||||
"@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/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/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-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/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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -316,6 +342,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"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_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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"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=="],
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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",
|
||||
"preview": "bun run build && bun run start",
|
||||
"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": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
@@ -25,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@dagrejs/dagre": "^1",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/dockerode": "^3",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -35,6 +39,7 @@
|
||||
"react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"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 type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
||||
import type { StatsStore } from "./useStatsStore";
|
||||
|
||||
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;
|
||||
}
|
||||
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
|
||||
|
||||
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -19,6 +11,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||
// 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 processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||
const lastRawServicesRef = useRef<Service[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
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
|
||||
const applyProcessing = useCallback((raw: Service[]): Service[] => {
|
||||
const processing = 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;
|
||||
});
|
||||
return applyProcessingPure(raw, processingRef.current);
|
||||
}, []);
|
||||
|
||||
// Single init call: services + connections + positions
|
||||
@@ -144,7 +114,15 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
break;
|
||||
case "action_error": {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -178,6 +156,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
// Clean up all processing intervals
|
||||
for (const interval of processingIntervalsRef.current.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
processingIntervalsRef.current.clear();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
@@ -196,25 +179,43 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
||||
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
|
||||
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));
|
||||
// Re-evaluate every 1s after minDuration until processing clears.
|
||||
// Handles the case where the server stops broadcasting (hash unchanged).
|
||||
if (minDuration > 0) {
|
||||
const interval = setInterval(() => {
|
||||
if (!processingRef.current.has(uid)) { clearInterval(interval); return; }
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (elapsed < minDuration) return;
|
||||
const incoming = applyProcessing(lastRawServicesRef.current);
|
||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||
// applyProcessing deletes the entry when resolved or timed out (15s)
|
||||
if (!processingRef.current.has(uid)) clearInterval(interval);
|
||||
}, 1000);
|
||||
}
|
||||
// Clear any existing interval for this uid (e.g. rapid re-clicks)
|
||||
const prevInterval = processingIntervalsRef.current.get(uid);
|
||||
if (prevInterval) clearInterval(prevInterval);
|
||||
|
||||
// Re-evaluate every 1s until processing clears.
|
||||
// Always create interval — even for minDuration=0 — so the 15s timeout safety net works
|
||||
// when the server stops broadcasting (hash unchanged).
|
||||
const interval = setInterval(() => {
|
||||
if (!processingRef.current.has(uid)) {
|
||||
clearInterval(interval);
|
||||
processingIntervalsRef.current.delete(uid);
|
||||
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]);
|
||||
|
||||
const clearProcessing = useCallback((uid: string) => {
|
||||
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 => {
|
||||
return actionTimestamps.current.get(uid);
|
||||
|
||||
@@ -113,6 +113,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
onAction(service.uid, expectedState, minDuration);
|
||||
setInitialLogs([]);
|
||||
clearLogLines();
|
||||
// Unsubscribe log stream to prevent stale lines during the action
|
||||
sendMessage({ type: "unsubscribe_logs" });
|
||||
subscribedRef.current = null;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -135,7 +138,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
setActionLoading(null);
|
||||
setTimeout(() => setActionResult(null), 3000);
|
||||
}
|
||||
}, [service.id, service.uid, token, onAction, clearProcessing]);
|
||||
}, [service.id, service.uid, token, onAction, clearProcessing, sendMessage, clearLogLines]);
|
||||
|
||||
const runExec = useCallback(async () => {
|
||||
if (!execCmd.trim()) return;
|
||||
@@ -174,8 +177,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const since = actionTimestampRef.current;
|
||||
const sinceParam = since ? `&since=${since}` : "";
|
||||
fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers })
|
||||
fetch(`/api/logs/${service.id}?tail=200&since=${since || Math.floor(Date.now() / 1000)}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((lines: LogLine[]) => setInitialLogs(lines))
|
||||
.catch(() => {});
|
||||
@@ -259,10 +261,11 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
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 sinceTs = actionTimestampRef.current;
|
||||
return events
|
||||
.filter((e) => e.service === service.uid)
|
||||
.filter((e) => e.service === service.uid && (!sinceTs || e.time >= sinceTs))
|
||||
.map((e): LogLine => ({
|
||||
container: service.id,
|
||||
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";
|
||||
|
||||
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(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`);
|
||||
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"];
|
||||
|
||||
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 name = svc.name.toLowerCase();
|
||||
for (const p of INFRA_PATTERNS) {
|
||||
@@ -118,13 +120,13 @@ function isInfraService(svc: Service): { type: string; label: string } | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProxyService(svc: Service): boolean {
|
||||
export 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 {
|
||||
export function isWorkerService(svc: Service): boolean {
|
||||
const name = svc.name.toLowerCase();
|
||||
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> = {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
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 lines: LogLine[] = [];
|
||||
|
||||
+12
-4
@@ -124,7 +124,7 @@ app.post("/api/containers/:id/stop", async (c) => {
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.stop();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
immediateRefresh();
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
const container = docker.getContainer(id);
|
||||
await container.start();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
immediateRefresh();
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
const container = docker.getContainer(id);
|
||||
await container.restart();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
immediateRefresh();
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.message || "Failed to restart container" }, 500);
|
||||
@@ -464,6 +464,14 @@ function scheduleRefresh() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Immediate refresh after action endpoints (container already changed state)
|
||||
function immediateRefresh() {
|
||||
lastServicesHash = "";
|
||||
clearTimeout(refreshTimer);
|
||||
clearTimeout(retryTimer);
|
||||
refreshServices();
|
||||
}
|
||||
|
||||
watchDockerEvents((event) => {
|
||||
broadcast({ type: "docker_event", data: event });
|
||||
scheduleRefresh();
|
||||
@@ -516,7 +524,7 @@ const server = Bun.serve({
|
||||
},
|
||||
message(ws, message) {
|
||||
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;
|
||||
|
||||
// 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