diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..138faea --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 67f69ee..314b661 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index aa919ed..ac451c2 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 9da573a..f48e04f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/client/hooks/processing.test.ts b/src/client/hooks/processing.test.ts new file mode 100644 index 0000000..1ba079a --- /dev/null +++ b/src/client/hooks/processing.test.ts @@ -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 { + 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(); + 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([ + ["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([ + ["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([ + ["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([ + ["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([ + ["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([ + ["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([ + ["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([ + ["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 + }); +}); diff --git a/src/client/hooks/processing.ts b/src/client/hooks/processing.ts new file mode 100644 index 0000000..ef7341c --- /dev/null +++ b/src/client/hooks/processing.ts @@ -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, + 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; + }); +} diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index 1d27741..5096d96 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -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) => void) { const [services, setServices] = useState([]); @@ -19,6 +11,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po const [logLines, setLogLines] = useState([]); // Processing state: uid → { expected state, start time, min duration before clearing } const processingRef = useRef>(new Map()); + const processingIntervalsRef = useRef>>(new Map()); const lastRawServicesRef = useRef([]); const [connected, setConnected] = useState(false); const wsRef = useRef(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); diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 867ba13..3498084 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -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 = {}; @@ -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 = {}; 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}`, diff --git a/src/server/docker.test.ts b/src/server/docker.test.ts new file mode 100644 index 0000000..6293a2a --- /dev/null +++ b/src/server/docker.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/server/docker.ts b/src/server/docker.ts index 59ed634..67cfca1 100644 --- a/src/server/docker.ts +++ b/src/server/docker.ts @@ -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 = { 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[] = []; diff --git a/src/server/index.ts b/src/server/index.ts index deec9d6..e0d4251 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7e83b5b --- /dev/null +++ b/vitest.config.ts @@ -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"), + }, + }, +});