This commit is contained in:
RGJorge
2026-05-10 20:49:06 +00:00
parent 0ebe000a26
commit 713504dc65
34 changed files with 918 additions and 327 deletions
-6
View File
@@ -1,6 +0,0 @@
{
"/home/jorge/git/fidelizacion/docker-compose.prod.yml": ".env.prod",
"/home/jorge/git/fidelizacion/api/docker-compose.dev.yml": ".env",
"/home/jorge/git/ninjasagacw/docker-compose.infra.yml": ".env",
"/home/jorge/git/alteonx-dockerflow/docker-compose.yml": ".env"
}
+54
View File
@@ -1,6 +1,60 @@
# ──────────────────────────────────────────────────────────────
# Servidor
# ──────────────────────────────────────────────────────────────
# Puerto del servidor (por defecto: 9470) # Puerto del servidor (por defecto: 9470)
PORT=9470 PORT=9470
# Token de autenticacion — dejar vacio para acceso solo en localhost (sin login) # Token de autenticacion — dejar vacio para acceso solo en localhost (sin login)
# Poner un valor para activar auth + acceso remoto (0.0.0.0) # Poner un valor para activar auth + acceso remoto (0.0.0.0)
AUTH_TOKEN= AUTH_TOKEN=
# ──────────────────────────────────────────────────────────────
# Persistencia
# ──────────────────────────────────────────────────────────────
# Directorio donde se guardan archivos persistentes (SQLite de stats,
# config Discord, container settings, posiciones de nodos, env file overrides).
# Default nativo: ./data (relativo al cwd). En docker-compose.yml se setea
# a /app/data (montado en el volumen containerflow-data).
# El directorio se crea automaticamente al startup si no existe.
# DATA_DIR=/app/data
# ──────────────────────────────────────────────────────────────
# Acceso a compose files (rebuild / remove)
# ──────────────────────────────────────────────────────────────
# Path adicional a montar en el container de ContainerFlow para que
# pueda leer compose files fuera de los defaults (/home, /opt, /srv, /root).
# Solo necesario si tus proyectos viven en una ruta no estandar.
# Ejemplo: HOST_PROJECTS_DIR=/data/apps
# HOST_PROJECTS_DIR=
# ──────────────────────────────────────────────────────────────
# Control de acceso por path (multi-usuario)
# ──────────────────────────────────────────────────────────────
# Lista separada por ":" de prefijos donde se permiten acciones
# (start/stop/restart/rebuild/remove/exec). Visualizacion, stats y
# logs siempre disponibles para todos los containers.
#
# Vacio = modo permisivo (todas las acciones permitidas).
# Con valores = modo estricto (containers fuera de estas rutas
# aparecen con candado y acciones deshabilitadas).
#
# Ejemplo single-user:
# ALLOWED_PATHS=/home/jorge
#
# Ejemplo multi-path:
# ALLOWED_PATHS=/home/jorge:/srv/myapp:/opt/legacy
# ALLOWED_PATHS=
# Solo aplica cuando ALLOWED_PATHS esta activo. Si ALLOWED_PATHS esta
# vacio, esta variable no tiene efecto (todo es accionable por default).
#
# Cuando ALLOWED_PATHS esta activo, controla si containers no-compose
# (corridos con `docker run` directo, sin labels de compose) permiten
# acciones:
# false (default) = bloqueados, aparecen con candado
# true = permitidos (util para watchtower, traefik, etc.)
# ALLOW_NON_COMPOSE=false
+5
View File
@@ -2,6 +2,11 @@ node_modules/
dist/ dist/
*.log *.log
.env .env
# Persistent data (SQLite + JSON configs) — default location
data/
# Legacy location (cuando los archivos vivian en cwd directamente)
.dockerflow-*.json .dockerflow-*.json
.dockerflow-*.db .dockerflow-*.db
.dockerflow-*.db-wal .dockerflow-*.db-wal
+3 -2
View File
@@ -14,13 +14,14 @@ RUN bun run build
# ── Stage 2: runtime ── # ── Stage 2: runtime ──
FROM oven/bun:1-slim FROM oven/bun:1-slim
# Docker CLI needed for rebuild/remove via `docker compose` # Docker CLI + Compose plugin needed for rebuild/remove via `docker compose`
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \ && apt-get install -y --no-install-recommends ca-certificates curl \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \ && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& apt-get purge -y curl \ && apt-get purge -y curl \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+102 -10
View File
@@ -11,6 +11,10 @@ Real-time Docker architecture visualizer. Displays services, connections and met
![ContainerFlow demo](docs/demo.gif) ![ContainerFlow demo](docs/demo.gif)
## Documentación
- **[docker-containerflow.md](./docker-containerflow.md)** — Guía rápida de Docker explicado para usar ContainerFlow: qué hace cada acción (Start, Stop, Restart, Recreate, Rebuild, Remove, Exec), restart policies, resource limits, volúmenes, healthchecks y preguntas frecuentes.
## Requisitos ## Requisitos
- [Bun](https://bun.sh) >= 1.0 - [Bun](https://bun.sh) >= 1.0
@@ -38,7 +42,10 @@ Variables disponibles:
|---|---|---| |---|---|---|
| `PORT` | `9470` | Puerto del servidor | | `PORT` | `9470` | Puerto del servidor |
| `AUTH_TOKEN` | _(vacio)_ | Token de autenticacion. Vacio = sin auth, solo localhost. Con valor = auth activado, acceso remoto | | `AUTH_TOKEN` | _(vacio)_ | Token de autenticacion. Vacio = sin auth, solo localhost. Con valor = auth activado, acceso remoto |
| `DATA_DIR` | _(cwd)_ | Directorio para persistencia: SQLite de historial (`.dockerflow-stats.db`), config Discord (`.dockerflow-discord.json`) y overrides por contenedor (`.dockerflow-container-settings.json`) | | `DATA_DIR` | `./data` | Directorio para persistencia: SQLite de historial (`.dockerflow-stats.db`), config Discord (`.dockerflow-discord.json`), overrides por contenedor (`.dockerflow-container-settings.json`), posiciones de nodos y env file overrides. Se crea automaticamente al startup. En Docker se monta en `/app/data` via volumen `containerflow-data`. |
| `HOST_PROJECTS_DIR` | _(vacio)_ | Path adicional a montar para que `rebuild`/`remove` puedan leer compose files fuera de los defaults (`/home`, `/opt`, `/srv`, `/root`). Solo necesario para rutas no estandar (ej. `/data/apps`). |
| `ALLOWED_PATHS` | _(vacio)_ | **Vacio = todo accionable** (modo permisivo). Con valores = lista separada por `:` de prefijos; solo containers cuyo compose file este bajo alguno de estos paths pueden ejecutar acciones, el resto aparece con candado. Ver seccion [Seguridad](#seguridad). |
| `ALLOW_NON_COMPOSE` | `false` | **Solo aplica cuando `ALLOWED_PATHS` esta activo.** Si `ALLOWED_PATHS` esta vacio, esta variable no tiene efecto. Cuando aplica: `false` bloquea acciones sobre containers no-compose (corridos con `docker run` directo); `true` las permite. |
## Uso ## Uso
@@ -89,6 +96,9 @@ bun run start
- **Panel de detalle** — click en un container para ver info, stats, variables de entorno y configuracion en tabs separados - **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 - **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 - **Acciones sobre containers** — start, stop, restart, rebuild y remove directamente desde el panel
- **Ejecutar comandos** — terminal inline (`docker exec`) desde el DetailPanel con output, sin abrir SSH ni terminal externa
- **Toast de errores** — cuando una accion falla (rebuild que rompe, exec con exit code != 0, etc.) aparece un toast top-right con el error completo, copiable al clipboard
- **Control de acceso por path** — variable `ALLOWED_PATHS` permite restringir acciones a containers cuyo compose file este bajo rutas especificas. Ideal para servidores compartidos: ves todo, solo tocas lo tuyo. Los containers fuera de las rutas aparecen con candado
- **Filtro de proyectos** — dropdown para mostrar/ocultar proyectos, persiste entre sesiones - **Filtro de proyectos** — dropdown para mostrar/ocultar proyectos, persiste entre sesiones
- **Autenticacion** — pantalla de login con AUTH_TOKEN para acceso remoto seguro - **Autenticacion** — pantalla de login con AUTH_TOKEN para acceso remoto seguro
- **Leyenda de conexiones** — colores por tipo: Database (azul), Cache (rojo), Broker (naranja), Proxy (verde) - **Leyenda de conexiones** — colores por tipo: Database (azul), Cache (rojo), Broker (naranja), Proxy (verde)
@@ -177,10 +187,90 @@ Ver `.github/workflows/ci.yml`.
## Seguridad ## Seguridad
### Red y autenticación
- **HTTPS obligatorio en produccion** — el token de autenticacion viaja en headers HTTP. Sin HTTPS, es texto plano visible en la red. Usa un reverse proxy con TLS (nginx, Caddy, Cloudflare Tunnel) delante de ContainerFlow. - **HTTPS obligatorio en produccion** — el token de autenticacion viaja en headers HTTP. Sin HTTPS, es texto plano visible en la red. Usa un reverse proxy con TLS (nginx, Caddy, Cloudflare Tunnel) delante de ContainerFlow.
- **Rate limiting** — incluido por defecto: 5 intentos fallidos por minuto por IP. Despues del limite, retorna `429 Too Many Requests`. Aplica tanto a la API REST como a la autenticacion WebSocket. - **Rate limiting** — incluido por defecto: 5 intentos fallidos por minuto por IP. Despues del limite, retorna `429 Too Many Requests`. Aplica tanto a la API REST como a la autenticacion WebSocket.
- **Acceso local por defecto** — sin `AUTH_TOKEN`, el servidor solo escucha en `127.0.0.1`. Con `AUTH_TOKEN`, escucha en `0.0.0.0` para acceso remoto. - **Acceso local por defecto** — sin `AUTH_TOKEN`, el servidor solo escucha en `127.0.0.1`. Con `AUTH_TOKEN`, escucha en `0.0.0.0` para acceso remoto.
### Privilegios del container
ContainerFlow es una herramienta privilegiada por diseño:
- **Docker socket** (`/var/run/docker.sock`) — acceso completo al daemon Docker. Equivalente a root en el host: puede crear containers privilegiados, montar cualquier path, leer/escribir el filesystem completo. Si ContainerFlow se compromete, el host está comprometido.
- **Mounts read-only del host** — el `docker-compose.yml` monta `/home`, `/opt`, `/srv` y `/root` como `:ro` para que las acciones `rebuild` y `exec` puedan leer compose files. Permite **lectura** de archivos en esos directorios (incluyendo SSH keys, git credentials, etc. de cualquier usuario en el sistema).
**Implicaciones en servidor multi-usuario:** si varios usuarios (`/home/jorge`, `/home/israel`, `/home/pedro`) tienen sus proyectos en el mismo host, ContainerFlow puede leer los archivos de todos ellos. El acceso al socket Docker hace que esto sea ruido relativo (cualquiera con el socket ya tiene acceso total al host), pero conviene estar consciente.
### Setup recomendado para single-user
Defaults actuales — convenientes y suficientes:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- containerflow-data:/app/data
- /home:/home:ro
- /opt:/opt:ro
- /srv:/srv:ro
- /root:/root:ro
```
### Setup recomendado para multi-user / producción
Limita los mounts a directorios específicos donde tienes proyectos:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- containerflow-data:/app/data
# En vez de /home completo, solo tus proyectos
- /home/jorge/git:/home/jorge/git:ro
- /srv/apps:/srv/apps:ro
```
Esto reduce el blast radius si hay un bug que filtre paths.
### Setup recomendado para deploys compartidos: `ALLOWED_PATHS`
Si varios admins comparten un servidor y cada uno solo debe interactuar con sus propios containers, configura la variable `ALLOWED_PATHS` en `.env`:
```bash
# .env
ALLOWED_PATHS=/home/jorge:/srv/myapp # rutas separadas por ":"
ALLOW_NON_COMPOSE=false # opcional, default false
```
**Comportamiento:**
- `ALLOWED_PATHS` vacío (default) → modo permisivo: todas las acciones disponibles para todos los containers
- `ALLOWED_PATHS` con valores → modo estricto:
- **Visualización, stats y logs:** siempre disponibles para todos los containers (la visibilidad viene del Docker socket)
- **Acciones** (start/stop/restart/rebuild/remove/exec): solo permitidas si el compose file del container está bajo una ruta permitida
- Los containers fuera de las rutas aparecen con un **ícono de candado 🔒** y todas sus acciones quedan deshabilitadas
- El menú contextual y el panel de detalle muestran un badge "View-only"
**`ALLOW_NON_COMPOSE`** controla qué pasa con containers corridos manualmente (`docker run` sin labels de compose):
- `false` (default): bloquea acciones — view-only para containers no-compose
- `true`: permite acciones sobre containers no-compose (útil si tienes containers utilitarios como Portainer agent, Watchtower, etc.)
**Ejemplo multi-usuario:**
```bash
# Servidor compartido con jorge, israel, pedro, nayeli
# Cada uno corre su propia instancia de ContainerFlow en puerto distinto
# El de jorge:
ALLOWED_PATHS=/home/jorge
# El de israel:
ALLOWED_PATHS=/home/israel
```
Cada uno ve **todos** los containers del servidor, pero solo puede hacer rebuild/restart/exec sobre los suyos.
**Endpoint relevante:** `GET /api/config` devuelve la config activa (consumido por el frontend para deshabilitar botones).
## Stack ## Stack
| Componente | Tecnologia | | Componente | Tecnologia |
@@ -214,21 +304,23 @@ src/
ServiceNode.tsx — nodo visual por container ServiceNode.tsx — nodo visual por container
GroupNode.tsx — header de grupo (proyecto/compose) GroupNode.tsx — header de grupo (proyecto/compose)
hooks/ hooks/
useDocker.ts — hook WebSocket para datos en tiempo real useDocker.ts — hook WebSocket para datos en tiempo real + toast de errores de accion
useServerConfig.ts — fetch /api/config + helper canInteract() para ALLOWED_PATHS
useStatsHistory.ts — fetch del historial de stats por rango (1h/6h/24h/7d) useStatsHistory.ts — fetch del historial de stats por rango (1h/6h/24h/7d)
useStatsStore.ts — store en memoria para stats live useStatsStore.ts — store en memoria para stats live
processing.ts — logica pura de estados processing processing.ts — logica pura de estados processing
engine/ engine/
layout.ts — layout de grupos + grid + edges layout.ts — layout de grupos + grid + edges
components/ components/
HeaderBar.tsx — barra superior con navegacion HeaderBar.tsx — barra superior con navegacion
EdgeLegend.tsx — leyenda de tipos de conexion EdgeLegend.tsx — leyenda de tipos de conexion
LoginScreen.tsx — pantalla de autenticacion LoginScreen.tsx — pantalla de autenticacion
NodeContextMenu.tsx — menu contextual de nodos NodeContextMenu.tsx — menu contextual de nodos (con disable cuando locked)
OffsetEdge.tsx — edge custom con offset para evitar superposicion OffsetEdge.tsx — edge custom con offset para evitar superposicion
Sparkline.tsx — gráfica de línea ligera para historial de stats Sparkline.tsx — gráfica de línea ligera para historial de stats
StatsCard.tsx — tarjeta de métrica con sparkline, hover, promedio y umbral StatsCard.tsx — tarjeta de métrica con sparkline, hover, promedio y umbral
ThresholdBar.tsx — slider de umbral por contenedor con override/reset ThresholdBar.tsx — slider de umbral por contenedor con override/reset
ActionErrorToast.tsx — stack de toasts top-right para errores de acciones
panels/ panels/
DetailPanel.tsx — panel lateral con info, stats, env, config y logs DetailPanel.tsx — panel lateral con info, stats, env, config y logs
LogPanel.tsx — panel de logs por container LogPanel.tsx — panel de logs por container
+10
View File
@@ -6,6 +6,16 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- containerflow-data:/app/data - containerflow-data:/app/data
# Common project locations — read-only. Required for `rebuild` to read
# docker-compose.yml + .env files. Builds run in the Docker daemon
# (host fs), so :ro here is safe and sufficient.
- /home:/home:ro
- /opt:/opt:ro
- /srv:/srv:ro
- /root:/root:ro
# Override for non-standard project paths. Set in .env, e.g.:
# HOST_PROJECTS_DIR=/data/apps
- ${HOST_PROJECTS_DIR:-/var/empty}:${HOST_PROJECTS_DIR:-/var/empty}:ro
environment: environment:
- DATA_DIR=/app/data - DATA_DIR=/app/data
env_file: .env env_file: .env
+186
View File
@@ -0,0 +1,186 @@
# Docker explicado para usar ContainerFlow
Guía rápida: qué es Docker, conceptos clave, y qué hace cada acción de la app.
---
## ¿Qué es Docker?
Empaqueta tu app con todo lo que necesita (código, libs, configs) en una "caja" autocontenida que corre igual en cualquier máquina con Docker.
```
IMAGEN (plantilla) CONTAINERS (instancias)
┌──────────────┐ ┌──────────────┐
│ nginx:alpine │ ─────────► │ web-1 │
│ │ ─────────► │ web-2 │
│ │ ─────────► │ web-3 │
└──────────────┘ └──────────────┘
```
Una **imagen** es una plantilla congelada (receta + ingredientes). Un **container** es una instancia corriendo de esa imagen. Una imagen puede generar muchos containers.
---
## Conceptos clave
| Concepto | Qué es | Ejemplo |
|---|---|---|
| **Imagen** | Plantilla inmutable con código + deps | `nginx:alpine`, `postgres:15` |
| **Container** | Instancia corriendo de una imagen | `fidelizacion-prod-auth-1` |
| **Tag** | Etiqueta legible de la imagen | `:latest`, `:v1.2`, `:alpine` |
| **Image ID** | Hash sha256 (identidad real de la imagen) | `sha256:f4c41c...` |
| **Volumen** | Persiste datos fuera del container | `postgres-data`, bind mounts |
| **Red** | Conecta containers entre sí | Containers en la misma red se ven por nombre |
| **Compose** | YAML que describe múltiples servicios | `docker-compose.yml` |
---
## Acciones de ContainerFlow
Cada botón corresponde a un comando real de Docker. Lo que cambia es **qué destruye y qué reusa**:
```
Start: [exited] ─────► [running] (mismo container)
Stop: [running] ─────► [exited] (mismo container)
Restart: [running] ─────► STOP → START → [running] (mismo container)
Recreate: [running] ─────► REMOVE → CREATE → [running] (container NUEVO, misma imagen)
Rebuild: [running] ─────► BUILD IMAGE → REMOVE → CREATE → [running] (container NUEVO, imagen NUEVA)
Remove: [running] ─────► STOP → DELETE → ∅
Exec: [running] ─────► ejecuta comando dentro, container sigue igual
```
| Acción | Comando CLI | Cuándo lo usas |
|---|---|---|
| **Start** | `docker start <c>` | Arrancar un container detenido |
| **Stop** | `docker stop <c>` | Apagar limpio (SIGTERM, luego SIGKILL tras 10s) |
| **Restart** | `docker restart <c>` | Reiniciar el proceso sin recrear nada (rápido) |
| **Recreate** | `docker compose up -d --force-recreate <s>` | Aplicar cambios de compose (env, volumes, ports) sin rebuild |
| **Rebuild** | `docker compose up -d --build <s>` | Aplicar cambios de código (reconstruye imagen) |
| **Remove** | `docker compose rm -sf <s>` | Eliminar el container permanentemente (imagen queda) |
| **Exec** | `docker exec <c> <cmd>` | Correr comando dentro (migrate, seed, redis-cli, etc.) |
### Restart vs Recreate vs Rebuild
| | Reinicia proceso | Recrea container | Reconstruye imagen | Aplica cambios compose | Aplica cambios código |
|---|---|---|---|---|---|
| Restart | sí | no | no | no | no |
| Recreate | sí | sí | no | **sí** | no |
| Rebuild | sí | sí | sí | sí | **sí** |
**Regla mental:**
- Restart → algo cuelga, dale un reboot rápido
- Recreate → cambié compose (env var, volume), no toqué código
- Rebuild → cambié código, necesito la versión nueva
---
## Identidad de un container
Hay **dos cosas distintas** y conviene no confundirlas:
```
Container Name: fidelizacion-prod-auth-1 ← legible, derivado de compose
ESTABLE entre recreates
Container ID: b0ab634b6eb2878677d85f8998ce1162... ← hash sha256
CAMBIA con cada recreate/rebuild
```
Lo mismo con imágenes:
```
Image Tag: fidelizacion-prod-auth:latest ← "apodo" legible
ESTABLE como puntero
Image ID: sha256:111aaa222bbb... ← hash de la imagen real
CAMBIA con cada rebuild
```
Cuando haces rebuild, el tag se "mueve" para apuntar al nuevo Image ID. La imagen vieja queda huérfana hasta que `docker image prune` la limpia.
---
## Configuraciones comunes del compose
### Restart policy
```yaml
restart: unless-stopped
```
| Valor | Comportamiento |
|---|---|
| `no` (default) | Si muere, queda muerto |
| `always` | Lo reinicia siempre, **incluso si tú lo detuviste** |
| `on-failure` | Solo si murió con error (exit code != 0) |
| `unless-stopped` | Lo reinicia, **excepto si lo detuviste manualmente** (recomendado para prod) |
### Resource limits
```yaml
deploy:
resources:
limits:
cpus: "0.5" # máximo medio núcleo
memory: 256M # tope absoluto, el kernel lo mata si excede (OOM)
```
ContainerFlow muestra estos límites en el DetailPanel y los gráficos indican si te acercas al tope.
### Volumes
```yaml
volumes:
- postgres-data:/var/lib/postgresql/data # named volume (persiste)
- ./config.yml:/app/config.yml:ro # bind mount read-only
```
- **Named volumes** (`postgres-data`) persisten entre recreate/rebuild — perfectos para DBs
- **Bind mounts** conectan un directorio del host con el container
### Healthcheck
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
retries: 3
```
Docker corre el `test` periódicamente. Si falla `retries` veces, marca el container como `unhealthy`. ContainerFlow muestra el estado en el nodo.
---
## Estados de un container
| Estado | Significado |
|---|---|
| `running` | Corriendo normal |
| `exited` | Terminó (limpio o crash) |
| `paused` | Congelado con `docker pause` |
| `restarting` | En medio de reinicio (por restart policy) |
| `dead` | Falló mal, Docker no pudo limpiarlo |
| `crashed` *(label de ContainerFlow)* | Exit code != 0 |
ContainerFlow colorea los nodos: verde (healthy), rojo (exited/dead), amarillo (restarting/processing), naranja (unhealthy).
---
## Preguntas frecuentes
**¿Pierdo datos al hacer Rebuild?**
No si están en volumes (named volumes persisten siempre, bind mounts no se tocan). Sí si están solo en el filesystem del container — por eso las DBs siempre van en named volume.
**¿Por qué Rebuild es lento?**
Ejecuta `docker build` (descarga base, instala deps, compila). Recreate es segundos porque reusa la imagen existente.
**¿Qué pasa si dos containers tienen el mismo nombre?**
Docker rechaza. Por eso compose deriva nombres únicos: `{project}-{service}-{replica}`.
**¿Qué es OOM Killed?**
El container intentó usar más RAM que su `memory` limit y el kernel lo mató (out-of-memory). Visible en `docker inspect`.
**¿Dónde viven físicamente los volúmenes?**
- Named volumes: `/var/lib/docker/volumes/<nombre>/_data/`
- Bind mounts: el directorio del host que pusiste en compose
**¿Por qué algunos containers no tienen Rebuild en ContainerFlow?**
Solo containers de compose. Los corridos con `docker run` directo no tienen compose file asociado.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

+34 -7
View File
@@ -15,6 +15,7 @@ import "@xyflow/react/dist/style.css";
import { ServiceNode } from "./nodes/ServiceNode"; import { ServiceNode } from "./nodes/ServiceNode";
import { GroupNode } from "./nodes/GroupNode"; import { GroupNode } from "./nodes/GroupNode";
import { useDocker } from "./hooks/useDocker"; import { useDocker } from "./hooks/useDocker";
import { useServerConfig } from "./hooks/useServerConfig";
import { I18nProvider, useT } from "./i18n"; import { I18nProvider, useT } from "./i18n";
import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore"; import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore";
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout"; import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
@@ -24,6 +25,7 @@ import { LoginScreen } from "./components/LoginScreen";
import { OffsetEdge } from "./components/OffsetEdge"; import { OffsetEdge } from "./components/OffsetEdge";
import { HeaderBar, type Page } from "./components/HeaderBar"; import { HeaderBar, type Page } from "./components/HeaderBar";
import { EdgeLegend } from "./components/EdgeLegend"; import { EdgeLegend } from "./components/EdgeLegend";
import { ActionErrorToast } from "./components/ActionErrorToast";
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react"; import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
import { MonitoringPage } from "./pages/MonitoringPage"; import { MonitoringPage } from "./pages/MonitoringPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
@@ -82,7 +84,8 @@ function Dashboard({ token }: { token: string }) {
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => { const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
savedPositions.current = pos; savedPositions.current = pos;
}, []); }, []);
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince } = useDocker(token, statsStore, onPositions); const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError } = useDocker(token, statsStore, onPositions);
const { config: serverConfig, canInteract } = useServerConfig(token);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const initialLayoutDone = useRef(false); const initialLayoutDone = useRef(false);
@@ -325,6 +328,14 @@ function Dashboard({ token }: { token: string }) {
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections); const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
// Mark service nodes as locked when restricted mode is active
for (const n of newNodes) {
if (n.type === "service") {
const svc = filteredServices.find((s) => s.uid === n.id);
if (svc) (n.data as any).locked = !canInteract(svc);
}
}
if (!initialLayoutDone.current) { if (!initialLayoutDone.current) {
let positioned = newNodes.map((n) => { let positioned = newNodes.map((n) => {
const saved = savedPositions.current[n.id]; const saved = savedPositions.current[n.id];
@@ -405,7 +416,7 @@ function Dashboard({ token }: { token: string }) {
return result; return result;
}); });
} }
}, [filteredServices, filteredConnections]); }, [filteredServices, filteredConnections, canInteract]);
// Recompute edges + handles on drag end (not every pixel) // Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => { const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -509,6 +520,8 @@ function Dashboard({ token }: { token: string }) {
events={events} events={events}
/> />
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} />} {activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />} {activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
@@ -674,23 +687,35 @@ function Dashboard({ token }: { token: string }) {
<NodeContextMenu <NodeContextMenu
position={{ x: contextMenu.x, y: contextMenu.y }} position={{ x: contextMenu.x, y: contextMenu.y }}
service={contextMenu.service} service={contextMenu.service}
locked={!canInteract(contextMenu.service)}
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
onAction={(action) => { onAction={(action) => {
const svc = contextMenu.service; const svc = contextMenu.service;
// Optimistic processing — set BEFORE fetch // Optimistic processing — set BEFORE fetch
const expectedState: Service["state"] = const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" : action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" : action === "start" || action === "restart" || action === "rebuild" || action === "recreate" ? "running" :
svc.state; svc.state;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0; const minDuration = action === "restart" ? 2000 : (action === "rebuild" || action === "recreate") ? 3000 : 0;
setProcessing(svc.uid, expectedState, minDuration); setProcessing(svc.uid, expectedState, minDuration);
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers }) fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
.then((r) => { .then(async (r) => {
if (!r.ok) clearProcessing(svc.uid); if (!r.ok) {
clearProcessing(svc.uid);
try {
const data = await r.json();
if (data?.error) pushActionError(svc.uid, action, data.error);
} catch {
pushActionError(svc.uid, action, `HTTP ${r.status}`);
}
}
}) })
.catch(() => clearProcessing(svc.uid)); .catch((err) => {
clearProcessing(svc.uid);
pushActionError(svc.uid, action, err?.message || "Network error");
});
}} }}
onOpenLogs={() => { onOpenLogs={() => {
const svc = contextMenu.service; const svc = contextMenu.service;
@@ -731,9 +756,11 @@ function Dashboard({ token }: { token: string }) {
logLines={panelLogLines} logLines={panelLogLines}
token={token} token={token}
closing={panelClosing} closing={panelClosing}
locked={!canInteract(detailService)}
onClose={closeDetail} onClose={closeDetail}
onAction={setProcessing} onAction={setProcessing}
clearProcessing={clearProcessing} clearProcessing={clearProcessing}
pushActionError={pushActionError}
sendMessage={sendMessage} sendMessage={sendMessage}
clearLogLines={clearLogLines} clearLogLines={clearLogLines}
connections={filteredConnections} connections={filteredConnections}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from "react";
import { AlertCircle, X, Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ActionError } from "../../shared/types";
import { useT } from "../i18n";
const PREVIEW_CHAR_LIMIT = 180;
function ToastItem({ err, onDismiss }: { err: ActionError; onDismiss: (id: string) => void }) {
const { t } = useT();
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const shortName = err.uid.split("/").pop() || err.uid;
const project = err.uid.includes("/") ? err.uid.split("/")[0] : null;
const isLong = err.error.length > PREVIEW_CHAR_LIMIT;
const visibleError = expanded || !isLong ? err.error : err.error.slice(0, PREVIEW_CHAR_LIMIT) + "…";
const copy = async () => {
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(err.error);
ok = true;
} else {
const ta = document.createElement("textarea");
ta.value = err.error;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
ok = document.execCommand("copy");
document.body.removeChild(ta);
}
} catch {}
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<div className="bg-slate-800/95 backdrop-blur-sm border border-red-500/40 rounded-lg shadow-xl shadow-black/50 w-[380px] overflow-hidden">
<div className="flex items-start gap-2.5 px-3.5 py-2.5 border-b border-red-500/20 bg-red-500/10">
<AlertCircle size={16} className="text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-red-300 capitalize">
{t("toast.actionFailed").replace("{action}", err.action)}
</div>
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-xs text-slate-200 font-medium truncate">{shortName}</span>
{project && <span className="text-[10px] text-slate-500 truncate">{project}</span>}
</div>
</div>
<button
onClick={() => onDismiss(err.id)}
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
title={t("toast.dismiss")}
>
<X size={14} />
</button>
</div>
<div className="px-3.5 py-2.5">
<pre className="text-[11px] text-slate-300 font-mono whitespace-pre-wrap break-words leading-snug max-h-48 overflow-auto">
{visibleError}
</pre>
<div className="flex items-center justify-end gap-1 mt-2">
{isLong && (
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{expanded ? t("toast.collapse") : t("toast.expand")}
</button>
)}
<button
onClick={copy}
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
>
{copied ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
{copied ? t("toast.copied") : t("toast.copy")}
</button>
</div>
</div>
</div>
);
}
interface ActionErrorToastProps {
errors: ActionError[];
onDismiss: (id: string) => void;
onClearAll: () => void;
}
export function ActionErrorToast({ errors, onDismiss, onClearAll }: ActionErrorToastProps) {
const { t } = useT();
if (errors.length === 0) return null;
return (
<div className="fixed top-16 right-4 z-50 flex flex-col gap-2">
{errors.length > 1 && (
<button
onClick={onClearAll}
className="self-end text-[10px] text-slate-500 hover:text-slate-300 underline transition-colors"
>
{t("toast.dismissAll")} ({errors.length})
</button>
)}
{errors.map((err) => (
<ToastItem key={err.id} err={err} onDismiss={onDismiss} />
))}
</div>
);
}
+25 -12
View File
@@ -1,17 +1,18 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer } from "lucide-react"; import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer, Lock, RefreshCw } from "lucide-react";
import type { Service } from "../../shared/types"; import type { Service } from "../../shared/types";
import { useT } from "../i18n"; import { useT } from "../i18n";
interface NodeContextMenuProps { interface NodeContextMenuProps {
position: { x: number; y: number }; position: { x: number; y: number };
service: Service; service: Service;
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild") => void; locked?: boolean;
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild" | "recreate") => void;
onOpenLogs: () => void; onOpenLogs: () => void;
onClose: () => void; onClose: () => void;
} }
export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClose }: NodeContextMenuProps) { export function NodeContextMenu({ position, service, locked, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
const { t } = useT(); const { t } = useT();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -48,21 +49,31 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
className="fixed z-[10000] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/50 py-1.5 min-w-[180px]" className="fixed z-[10000] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/50 py-1.5 min-w-[180px]"
style={{ left: x, top: y }} style={{ left: x, top: y }}
> >
{locked && (
<>
<div className="flex items-center gap-2 px-3.5 py-1.5 text-[11px] text-slate-500 bg-slate-900/40">
<Lock size={11} className="text-slate-500" />
<span>{t("access.viewOnly")}</span>
</div>
<div className="border-t border-slate-700/50" />
</>
)}
{isRunning ? ( {isRunning ? (
<> <>
<MenuItem icon={RotateCw} label={t("actions.restart")} color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} /> <MenuItem icon={RotateCw} label={t("actions.restart")} tooltip={t("actions.restart.tooltip")} color="text-yellow-400" disabled={locked} onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label={t("actions.stop")} color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} /> <MenuItem icon={Square} label={t("actions.stop")} tooltip={t("actions.stop.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("stop"); onClose(); }} />
</> </>
) : ( ) : (
<> <>
<MenuItem icon={Play} label={t("actions.start")} color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} /> <MenuItem icon={Play} label={t("actions.start")} tooltip={t("actions.start.tooltip")} color="text-emerald-400" disabled={locked} onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label={t("actions.remove")} color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} /> <MenuItem icon={Trash2} label={t("actions.remove")} tooltip={t("actions.remove.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("remove"); onClose(); }} />
</> </>
)} )}
{service.compose_file && ( {service.compose_file && (
<> <>
<div className="border-t border-slate-700/50 my-1" /> <div className="border-t border-slate-700/50 my-1" />
<MenuItem icon={Hammer} label={t("actions.rebuild")} color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} /> <MenuItem icon={RefreshCw} label={t("actions.recreate")} tooltip={t("actions.recreate.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("recreate"); onClose(); }} />
<MenuItem icon={Hammer} label={t("actions.rebuild")} tooltip={t("actions.rebuild.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("rebuild"); onClose(); }} />
</> </>
)} )}
<div className="border-t border-slate-700/50 my-1" /> <div className="border-t border-slate-700/50 my-1" />
@@ -83,13 +94,15 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
); );
} }
function MenuItem({ icon: Icon, label, color, onClick }: { icon: typeof Play; label: string; color: string; onClick: () => void }) { function MenuItem({ icon: Icon, label, color, onClick, disabled, tooltip }: { icon: typeof Play; label: string; color: string; onClick: () => void; disabled?: boolean; tooltip?: string }) {
return ( return (
<button <button
onClick={onClick} onClick={disabled ? undefined : onClick}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm text-slate-300 hover:bg-slate-700/60 transition-colors" disabled={disabled}
title={tooltip}
className={`flex items-center gap-2.5 w-full px-3.5 py-2 text-sm transition-colors ${disabled ? "text-slate-600 cursor-not-allowed" : "text-slate-300 hover:bg-slate-700/60"}`}
> >
<Icon size={14} className={color} /> <Icon size={14} className={disabled ? "text-slate-600" : color} />
<span>{label}</span> <span>{label}</span>
</button> </button>
); );
+43
View File
@@ -0,0 +1,43 @@
import { useState } from "react";
import { HelpCircle } from "lucide-react";
interface TooltipProps {
text: string;
/** Width of the tooltip popover. Default: w-56 */
width?: string;
/** Icon size. Default: 13 */
size?: number;
/** Where the popover opens relative to the icon. Default: "top" */
placement?: "top" | "bottom";
}
export function Tooltip({ text, width = "w-56", size = 13, placement = "top" }: TooltipProps) {
const [show, setShow] = useState(false);
const popoverPos =
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2";
const arrowPos =
placement === "top"
? "top-full -mt-px border-t-slate-700"
: "bottom-full -mb-px border-b-slate-700";
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={(e) => { e.stopPropagation(); setShow((v) => !v); }}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={size} />
</button>
{show && (
<div className={`absolute ${popoverPos} left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${width} text-left shadow-xl z-50 leading-relaxed whitespace-normal`}>
{text}
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
</div>
)}
</span>
);
}
+22 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types"; import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError } from "../../shared/types";
import type { StatsStore } from "./useStatsStore"; import type { StatsStore } from "./useStatsStore";
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing"; import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
@@ -9,6 +9,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
const statsRef = useRef<Map<string, Stats>>(new Map()); const statsRef = useRef<Map<string, Stats>>(new Map());
const [events, setEvents] = useState<DockerEvent[]>([]); const [events, setEvents] = useState<DockerEvent[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]); const [logLines, setLogLines] = useState<LogLine[]>([]);
const [actionErrors, setActionErrors] = useState<ActionError[]>([]);
// Processing state: uid → { expected state, start time, min duration before clearing } // Processing state: uid → { expected state, start time, min duration before clearing }
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map()); const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map()); const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
@@ -123,6 +124,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
} else { } else {
setServices((prev) => prev.map((s) => s.uid === msg.data.uid ? { ...s, state: "exited" as any } : s)); setServices((prev) => prev.map((s) => s.uid === msg.data.uid ? { ...s, state: "exited" as any } : s));
} }
const errorId = `${msg.data.uid}:${msg.data.action}:${Date.now()}`;
setActionErrors((prev) => [
...prev.slice(-4), // keep at most 5 errors
{ id: errorId, uid: msg.data.uid, action: msg.data.action, error: msg.data.error, timestamp: Date.now() },
]);
break; break;
} }
} }
@@ -221,5 +227,19 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
return actionTimestamps.current.get(uid); return actionTimestamps.current.get(uid);
}, []); }, []);
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince }; const dismissActionError = useCallback((id: string) => {
setActionErrors((prev) => prev.filter((e) => e.id !== id));
}, []);
const clearActionErrors = useCallback(() => setActionErrors([]), []);
const pushActionError = useCallback((uid: string, action: string, error: string) => {
const errorId = `${uid}:${action}:${Date.now()}`;
setActionErrors((prev) => [
...prev.slice(-4),
{ id: errorId, uid, action, error, timestamp: Date.now() },
]);
}, []);
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError };
} }
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState, useCallback } from "react";
import type { Service, ServerConfig } from "../../shared/types";
const DEFAULT_CONFIG: ServerConfig = {
allowedPaths: [],
allowNonCompose: true,
restrictedMode: false,
};
export function useServerConfig(token: string) {
const [config, setConfig] = useState<ServerConfig>(DEFAULT_CONFIG);
useEffect(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch("/api/config", { headers })
.then((r) => (r.ok ? r.json() : DEFAULT_CONFIG))
.then((data: ServerConfig) => setConfig({ ...DEFAULT_CONFIG, ...data }))
.catch(() => {});
}, [token]);
/** Returns true if the service can be acted upon (start/stop/restart/rebuild/remove/exec).
* When restrictedMode is off, always returns true. */
const canInteract = useCallback(
(service: Pick<Service, "compose_file">): boolean => {
if (!config.restrictedMode) return true;
const cf = service.compose_file;
if (!cf) return config.allowNonCompose;
return config.allowedPaths.some(
(prefix) => cf === prefix || cf.startsWith(prefix.replace(/\/+$/, "") + "/")
);
},
[config]
);
return { config, canInteract };
}
+56 -8
View File
@@ -32,16 +32,39 @@ const en = {
"login.errorConnectionRefused": "Connection refused", "login.errorConnectionRefused": "Connection refused",
"login.errorConnectionFailed": "ERROR: Connection failed", "login.errorConnectionFailed": "ERROR: Connection failed",
// Context menu // Context menu — labels stay in English (match docker commands)
"actions.restart": "Restart", "actions.restart": "Restart",
"actions.stop": "Stop", "actions.stop": "Stop",
"actions.start": "Start", "actions.start": "Start",
"actions.remove": "Remove", "actions.remove": "Remove",
"actions.rebuild": "Rebuild", "actions.rebuild": "Rebuild",
"actions.recreate": "Recreate",
"actions.openLogs": "Open Logs", "actions.openLogs": "Open Logs",
"actions.open": "Open", "actions.open": "Open",
"actions.retry": "Retry", "actions.retry": "Retry",
// Action tooltips (hover descriptions)
"actions.start.tooltip": "Starts the container (docker start)",
"actions.stop.tooltip": "Stops the container with SIGTERM, then SIGKILL after timeout (docker stop)",
"actions.restart.tooltip": "Stops and starts the same container (docker restart). Keeps image and config.",
"actions.rebuild.tooltip": "Rebuilds the image from Dockerfile and creates a new container (docker compose up --build). Apply code changes.",
"actions.recreate.tooltip": "Recreates the container with current compose config, reusing existing image (docker compose up --force-recreate). Apply compose changes without rebuilding.",
"actions.remove.tooltip": "Stops and permanently removes the container (docker rm). For compose services, also cleans associated networks.",
"actions.retry.tooltip": "Retries starting a crashed container",
// Action error toast
"toast.actionFailed": "{action} failed",
"toast.dismiss": "Dismiss",
"toast.dismissAll": "Dismiss all",
"toast.copy": "Copy",
"toast.copied": "Copied",
"toast.expand": "Show more",
"toast.collapse": "Show less",
// Access control (ALLOWED_PATHS)
"access.viewOnly": "View-only",
"access.restricted": "Outside ALLOWED_PATHS — actions disabled",
// Edge legend // Edge legend
"legend.connections": "Connections", "legend.connections": "Connections",
@@ -138,6 +161,7 @@ const en = {
"detail.confirmRestart": "Restart this container? This will briefly interrupt the service.", "detail.confirmRestart": "Restart this container? This will briefly interrupt the service.",
"detail.confirmRemove": "Remove this container? This will stop and delete it.", "detail.confirmRemove": "Remove this container? This will stop and delete it.",
"detail.confirmRebuild": "Rebuild this container? This will rebuild the image and recreate the container.", "detail.confirmRebuild": "Rebuild this container? This will rebuild the image and recreate the container.",
"detail.confirmRecreate": "Recreate this container? Reuses the existing image and applies current compose config.",
// Detail panel - Crash // Detail panel - Crash
"detail.containerCrashed": "Container crashed", "detail.containerCrashed": "Container crashed",
@@ -249,15 +273,38 @@ const es: Record<TranslationKey, string> = {
"login.errorConnectionRefused": "Conexi\u00f3n rechazada", "login.errorConnectionRefused": "Conexi\u00f3n rechazada",
"login.errorConnectionFailed": "ERROR: Conexi\u00f3n fallida", "login.errorConnectionFailed": "ERROR: Conexi\u00f3n fallida",
// Context menu // Context menu — labels stay in English (match docker commands, evita confusion)
"actions.restart": "Reiniciar", "actions.restart": "Restart",
"actions.stop": "Detener", "actions.stop": "Stop",
"actions.start": "Iniciar", "actions.start": "Start",
"actions.remove": "Eliminar", "actions.remove": "Remove",
"actions.rebuild": "Reconstruir", "actions.rebuild": "Rebuild",
"actions.recreate": "Recreate",
"actions.openLogs": "Ver Logs", "actions.openLogs": "Ver Logs",
"actions.open": "Abrir", "actions.open": "Abrir",
"actions.retry": "Reintentar", "actions.retry": "Retry",
// Action tooltips (hover descriptions)
"actions.start.tooltip": "Inicia el contenedor (docker start)",
"actions.stop.tooltip": "Detiene el contenedor con SIGTERM, luego SIGKILL tras el timeout (docker stop)",
"actions.restart.tooltip": "Detiene y vuelve a iniciar el mismo contenedor (docker restart). Mantiene imagen y configuración.",
"actions.rebuild.tooltip": "Reconstruye la imagen desde el Dockerfile y crea un contenedor nuevo (docker compose up --build). Para aplicar cambios de código.",
"actions.recreate.tooltip": "Recrea el contenedor con la config actual del compose, reusando la imagen existente (docker compose up --force-recreate). Para aplicar cambios de compose sin rebuild.",
"actions.remove.tooltip": "Detiene y elimina el contenedor permanentemente (docker rm). Para servicios compose, también limpia networks asociadas.",
"actions.retry.tooltip": "Reintenta arrancar un contenedor que crasheó",
// Action error toast
"toast.actionFailed": "Error en {action}",
"toast.dismiss": "Descartar",
"toast.dismissAll": "Descartar todos",
"toast.copy": "Copiar",
"toast.copied": "Copiado",
"toast.expand": "Ver más",
"toast.collapse": "Ver menos",
// Access control (ALLOWED_PATHS)
"access.viewOnly": "Solo lectura",
"access.restricted": "Fuera de ALLOWED_PATHS — acciones deshabilitadas",
// Edge legend // Edge legend
"legend.connections": "Conexiones", "legend.connections": "Conexiones",
@@ -355,6 +402,7 @@ const es: Record<TranslationKey, string> = {
"detail.confirmRestart": "\u00bfReiniciar este contenedor? Esto interrumpir\u00e1 brevemente el servicio.", "detail.confirmRestart": "\u00bfReiniciar este contenedor? Esto interrumpir\u00e1 brevemente el servicio.",
"detail.confirmRemove": "\u00bfEliminar este contenedor? Esto lo detendr\u00e1 y eliminar\u00e1.", "detail.confirmRemove": "\u00bfEliminar este contenedor? Esto lo detendr\u00e1 y eliminar\u00e1.",
"detail.confirmRebuild": "\u00bfReconstruir este contenedor? Esto reconstruir\u00e1 la imagen y recrear\u00e1 el contenedor.", "detail.confirmRebuild": "\u00bfReconstruir este contenedor? Esto reconstruir\u00e1 la imagen y recrear\u00e1 el contenedor.",
"detail.confirmRecreate": "\u00bfRecrear este contenedor? Reusa la imagen existente y aplica la config actual del compose.",
// Detail panel - Crash // Detail panel - Crash
"detail.containerCrashed": "Contenedor crash\u00f3", "detail.containerCrashed": "Contenedor crash\u00f3",
+9 -2
View File
@@ -23,6 +23,7 @@ import {
Mail, Mail,
BarChart3, BarChart3,
AlertTriangle, AlertTriangle,
Lock,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -36,6 +37,7 @@ interface ServiceNodeData {
id?: string; id?: string;
activeHandles?: string[]; activeHandles?: string[];
highlighted?: boolean; highlighted?: boolean;
locked?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -136,11 +138,16 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
return ( return (
<div <div
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`} title={`${d.label} (${d.state})${d.locked ? " — view-only (outside ALLOWED_PATHS)" : ""}\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`}
className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring} shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring}
transition-[opacity,box-shadow] duration-300 ${flashClass}`} transition-[opacity,box-shadow] duration-300 ${flashClass} ${d.locked ? "opacity-70" : ""}`}
> >
{d.locked && (
<div className="absolute top-1.5 right-1.5 flex items-center justify-center w-5 h-5 rounded bg-slate-700/80 border border-slate-600/60 z-10" title={t("access.viewOnly")}>
<Lock size={11} className="text-slate-400" />
</div>
)}
{/* Top handles — left offset, transform centered horizontally */} {/* Top handles — left offset, transform centered horizontally */}
{offsets.map((o, i) => ( {offsets.map((o, i) => (
<Handle key={`t${i}`} type="source" position={Position.Top} id={`top-${i}`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} /> <Handle key={`t${i}`} type="source" position={Position.Top} id={`top-${i}`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
+2 -24
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Settings, Server, Bell, Info, Send, Save, Check, X, HelpCircle } from "lucide-react"; import { Settings, Server, Bell, Info, Send, Save, Check, X } from "lucide-react";
import type { DiscordConfig } from "../../shared/types"; import type { DiscordConfig } from "../../shared/types";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { Tooltip } from "../components/Tooltip";
interface SettingsPageProps { interface SettingsPageProps {
projects: string[]; projects: string[];
@@ -26,29 +27,6 @@ const DEFAULT_CONFIG: DiscordConfig = {
downReminderMinutes: 5, downReminderMinutes: 5,
}; };
function Tooltip({ text }: { text: string }) {
const [show, setShow] = useState(false);
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={() => setShow((v) => !v)}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={13} />
</button>
{show && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 w-56 text-left shadow-xl z-50 leading-relaxed">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-slate-700" />
</div>
)}
</span>
);
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return ( return (
<button <button
+58 -56
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react"; import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react";
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle, Save } from "lucide-react"; import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle, Save, Lock, RefreshCw } from "lucide-react";
import { Tooltip } from "../components/Tooltip";
import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings, DiscordConfig, StatsRange } from "../../shared/types"; import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings, DiscordConfig, StatsRange } from "../../shared/types";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStatsHistory } from "../hooks/useStatsHistory"; import { useStatsHistory } from "../hooks/useStatsHistory";
@@ -45,9 +46,11 @@ interface DetailPanelProps {
logLines: LogLine[]; logLines: LogLine[];
token: string; token: string;
closing?: boolean; closing?: boolean;
locked?: boolean;
onClose: () => void; onClose: () => void;
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void; onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
clearProcessing: (uid: string) => void; clearProcessing: (uid: string) => void;
pushActionError: (uid: string, action: string, error: string) => void;
sendMessage: (msg: WSMessage) => void; sendMessage: (msg: WSMessage) => void;
clearLogLines: () => void; clearLogLines: () => void;
connections: Connection[]; connections: Connection[];
@@ -59,7 +62,7 @@ interface DetailPanelProps {
events: DockerEvent[]; events: DockerEvent[];
} }
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, clearProcessing, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) { export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) {
const { t } = useT(); const { t } = useT();
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]); const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
@@ -163,9 +166,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
}, [isProcessing, processingStartedAt]); }, [isProcessing, processingStartedAt]);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{ type: "success" | "error"; message: string } | null>(null); const [actionResult, setActionResult] = useState<{ type: "success" | "error"; message: string } | null>(null);
const [confirmAction, setConfirmAction] = useState<"stop" | "restart" | "rebuild" | "remove" | null>(null); const [confirmAction, setConfirmAction] = useState<"stop" | "restart" | "rebuild" | "recreate" | "remove" | null>(null);
const executeAction = useCallback(async (action: "stop" | "start" | "restart" | "rebuild" | "remove") => { const executeAction = useCallback(async (action: "stop" | "start" | "restart" | "rebuild" | "recreate" | "remove") => {
setActionLoading(action); setActionLoading(action);
setActionResult(null); setActionResult(null);
setConfirmAction(null); setConfirmAction(null);
@@ -174,9 +177,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
// Optimistic processing — set BEFORE fetch // Optimistic processing — set BEFORE fetch
const expectedState: Service["state"] = const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" : action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" : action === "start" || action === "restart" || action === "rebuild" || action === "recreate" ? "running" :
service.state; service.state;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0; const minDuration = action === "restart" ? 2000 : (action === "rebuild" || action === "recreate") ? 3000 : 0;
onAction(service.uid, expectedState, minDuration); onAction(service.uid, expectedState, minDuration);
setInitialLogs([]); setInitialLogs([]);
clearLogLines(); clearLogLines();
@@ -196,16 +199,20 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
} }
} else { } else {
clearProcessing(service.uid); clearProcessing(service.uid);
setActionResult({ type: "error", message: data.error || `${t("detail.actionFailed")} ${action}` }); const errMsg = data.error || `${t("detail.actionFailed")} ${action}`;
setActionResult({ type: "error", message: errMsg });
pushActionError(service.uid, action, errMsg);
} }
} catch { } catch (err: any) {
clearProcessing(service.uid); clearProcessing(service.uid);
setActionResult({ type: "error", message: `${t("detail.actionFailed")} ${action}` }); const errMsg = err?.message || `${t("detail.actionFailed")} ${action}`;
setActionResult({ type: "error", message: errMsg });
pushActionError(service.uid, action, errMsg);
} finally { } finally {
setActionLoading(null); setActionLoading(null);
setTimeout(() => setActionResult(null), 3000); setTimeout(() => setActionResult(null), 3000);
} }
}, [service.id, service.uid, token, onAction, clearProcessing, sendMessage, clearLogLines]); }, [service.id, service.uid, token, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, t]);
const runExec = useCallback(async () => { const runExec = useCallback(async () => {
if (!execCmd.trim()) return; if (!execCmd.trim()) return;
@@ -378,6 +385,12 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${stateDot}`} /> <span className={`w-2 h-2 rounded-full ${stateDot}`} />
<span className="text-sm font-semibold text-white truncate">{service.name}</span> <span className="text-sm font-semibold text-white truncate">{service.name}</span>
{locked && (
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-slate-400 bg-slate-700/60 border border-slate-600/50 rounded" title={t("access.viewOnly")}>
<Lock size={10} />
{t("access.viewOnly")}
</span>
)}
{service.ports.length > 0 && service.state === "running" && ( {service.ports.length > 0 && service.state === "running" && (
<a <a
href={`http://${window.location.hostname}:${service.ports[0].host}`} href={`http://${window.location.hostname}:${service.ports[0].host}`}
@@ -406,32 +419,39 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
) : ( ) : (
<> <>
{service.compose_file && ( {service.compose_file && (
<button <>
onClick={() => setConfirmAction("rebuild")} <button
disabled={!!actionLoading} onClick={() => setConfirmAction("recreate")}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40" disabled={!!actionLoading || locked}
title={t("actions.rebuild")} className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
> >
{actionLoading === "rebuild" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />} {actionLoading === "recreate" ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
{t("actions.rebuild")} {t("actions.recreate")}
</button> </button>
<button
onClick={() => setConfirmAction("rebuild")}
disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
>
{actionLoading === "rebuild" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />}
{t("actions.rebuild")}
</button>
</>
)} )}
{service.state === "running" ? ( {service.state === "running" ? (
<> <>
<button <button
onClick={() => setConfirmAction("restart")} onClick={() => setConfirmAction("restart")}
disabled={!!actionLoading} disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-yellow-400 hover:bg-yellow-400/10 transition-colors disabled:opacity-40" className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-yellow-400 hover:bg-yellow-400/10 transition-colors disabled:opacity-40"
title={t("actions.restart")}
> >
{actionLoading === "restart" ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />} {actionLoading === "restart" ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
{t("actions.restart")} {t("actions.restart")}
</button> </button>
<button <button
onClick={() => setConfirmAction("stop")} onClick={() => setConfirmAction("stop")}
disabled={!!actionLoading} disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40" className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
title={t("actions.stop")}
> >
{actionLoading === "stop" ? <Loader2 size={12} className="animate-spin" /> : <Square size={12} />} {actionLoading === "stop" ? <Loader2 size={12} className="animate-spin" /> : <Square size={12} />}
{t("actions.stop")} {t("actions.stop")}
@@ -441,20 +461,18 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<> <>
<button <button
onClick={() => setConfirmAction("remove")} onClick={() => setConfirmAction("remove")}
disabled={!!actionLoading} disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40" className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
title={t("actions.remove")}
> >
{actionLoading === "remove" ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />} {actionLoading === "remove" ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
{t("actions.remove")} {t("actions.remove")}
</button> </button>
<button <button
onClick={() => executeAction("start")} onClick={() => executeAction("start")}
disabled={!!actionLoading} disabled={!!actionLoading || locked}
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors disabled:opacity-40 ${ className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors disabled:opacity-40 ${
isCrashed ? "text-orange-400 hover:bg-orange-400/10" : "text-emerald-400 hover:bg-emerald-400/10" isCrashed ? "text-orange-400 hover:bg-orange-400/10" : "text-emerald-400 hover:bg-emerald-400/10"
}`} }`}
title={isCrashed ? t("actions.retry") : t("actions.start")}
> >
{actionLoading === "start" ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />} {actionLoading === "start" ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{isCrashed ? t("actions.retry") : t("actions.start")} {isCrashed ? t("actions.retry") : t("actions.start")}
@@ -480,24 +498,30 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="px-4 py-2.5 bg-slate-800/90 border-b border-slate-700/60 flex items-center gap-3 shrink-0"> <div className="px-4 py-2.5 bg-slate-800/90 border-b border-slate-700/60 flex items-center gap-3 shrink-0">
<AlertTriangle size={14} className={`shrink-0 ${ <AlertTriangle size={14} className={`shrink-0 ${
confirmAction === "stop" || confirmAction === "remove" ? "text-red-400" : confirmAction === "stop" || confirmAction === "remove" ? "text-red-400" :
confirmAction === "rebuild" ? "text-cyan-400" : confirmAction === "rebuild" || confirmAction === "recreate" ? "text-cyan-400" :
"text-yellow-400" "text-yellow-400"
}`} /> }`} />
<span className="text-xs text-slate-300 flex-1"> <span className="text-xs text-slate-300 flex-1 flex items-center gap-1.5">
{confirmAction === "stop" ? t("detail.confirmStop") : {confirmAction === "stop" ? t("detail.confirmStop") :
confirmAction === "restart" ? t("detail.confirmRestart") : confirmAction === "restart" ? t("detail.confirmRestart") :
confirmAction === "remove" ? t("detail.confirmRemove") : confirmAction === "remove" ? t("detail.confirmRemove") :
confirmAction === "recreate" ? t("detail.confirmRecreate") :
t("detail.confirmRebuild")} t("detail.confirmRebuild")}
<Tooltip text={t(`actions.${confirmAction}.tooltip` as any)} width="w-72" placement="bottom" />
</span> </span>
<button <button
onClick={() => executeAction(confirmAction)} onClick={() => executeAction(confirmAction)}
className={`px-3 py-1 rounded text-[11px] font-medium text-white transition-colors ${ className={`px-3 py-1 rounded text-[11px] font-medium text-white transition-colors ${
confirmAction === "stop" || confirmAction === "remove" ? "bg-red-700 hover:bg-red-600" : confirmAction === "stop" || confirmAction === "remove" ? "bg-red-700 hover:bg-red-600" :
confirmAction === "rebuild" ? "bg-cyan-700 hover:bg-cyan-600" : confirmAction === "rebuild" || confirmAction === "recreate" ? "bg-cyan-700 hover:bg-cyan-600" :
"bg-yellow-700 hover:bg-yellow-600" "bg-yellow-700 hover:bg-yellow-600"
}`} }`}
> >
{confirmAction === "stop" ? t("actions.stop") : confirmAction === "restart" ? t("actions.restart") : confirmAction === "remove" ? t("actions.remove") : t("actions.rebuild")} {confirmAction === "stop" ? t("actions.stop") :
confirmAction === "restart" ? t("actions.restart") :
confirmAction === "remove" ? t("actions.remove") :
confirmAction === "recreate" ? t("actions.recreate") :
t("actions.rebuild")}
</button> </button>
<button <button
onClick={() => setConfirmAction(null)} onClick={() => setConfirmAction(null)}
@@ -1084,7 +1108,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{service.state === "running" && ( {service.state === "running" && !locked && (
<button <button
onClick={() => { setExecOpen((v) => { if (!v) setLogsExpanded(true); return !v; }); setExecResult(null); setExecError(null); }} onClick={() => { setExecOpen((v) => { if (!v) setLogsExpanded(true); return !v; }); setExecResult(null); setExecError(null); }}
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${execOpen ? "text-purple-300 bg-purple-400/10" : "text-purple-400 hover:bg-purple-400/10"}`} className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${execOpen ? "text-purple-300 bg-purple-400/10" : "text-purple-400 hover:bg-purple-400/10"}`}
@@ -1283,28 +1307,6 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
} }
function Tooltip({ text }: { text: string }) {
const [show, setShow] = useState(false);
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={() => setShow((v) => !v)}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={10} />
</button>
{show && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 w-48 text-left shadow-xl z-50 leading-relaxed">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-slate-700" />
</div>
)}
</span>
);
}
function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel, limitLabel, thresholdTooltip, limitTooltip }: { label: string; value: string; extra?: string; color: string; limit?: string; threshold?: string; thresholdLabel?: string; limitLabel?: string; thresholdTooltip?: string; limitTooltip?: string }) { function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel, limitLabel, thresholdTooltip, limitTooltip }: { label: string; value: string; extra?: string; color: string; limit?: string; threshold?: string; thresholdLabel?: string; limitLabel?: string; thresholdTooltip?: string; limitTooltip?: string }) {
return ( return (
@@ -1321,14 +1323,14 @@ function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] text-slate-500">{thresholdLabel}:</span> <span className="text-[10px] text-slate-500">{thresholdLabel}:</span>
<span className="text-[10px] text-slate-400 font-mono">{threshold}</span> <span className="text-[10px] text-slate-400 font-mono">{threshold}</span>
{thresholdTooltip && <Tooltip text={thresholdTooltip} />} {thresholdTooltip && <Tooltip text={thresholdTooltip} size={10} width="w-48" />}
</div> </div>
)} )}
{limit && ( {limit && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] text-slate-500">{limitLabel}:</span> <span className="text-[10px] text-slate-500">{limitLabel}:</span>
<span className="text-[10px] text-slate-400 font-mono">{limit}</span> <span className="text-[10px] text-slate-400 font-mono">{limit}</span>
{limitTooltip && <Tooltip text={limitTooltip} />} {limitTooltip && <Tooltip text={limitTooltip} size={10} width="w-48" />}
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import type { ContainerSettings } from "../shared/types"; import type { ContainerSettings } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd(); const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const SETTINGS_FILE = path.join(DATA_DIR, ".dockerflow-container-settings.json"); const SETTINGS_FILE = path.join(DATA_DIR, ".dockerflow-container-settings.json");
export function loadContainerSettings(): Record<string, ContainerSettings> { export function loadContainerSettings(): Record<string, ContainerSettings> {
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import type { DiscordConfig } from "../shared/types"; import type { DiscordConfig } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd(); const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json"); const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json");
const DEFAULT_CONFIG: DiscordConfig = { const DEFAULT_CONFIG: DiscordConfig = {
+141 -3
View File
@@ -11,8 +11,46 @@ import { loadContainerSettings, saveContainerSettings } from "./container-settin
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db"; import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
/** Directory for persistent data files (positions, env overrides) */ /** Directory for persistent data files (SQLite, JSON configs, positions).
const DATA_DIR = process.env.DATA_DIR || process.cwd(); * Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
/** Paths under which actions (start/stop/restart/rebuild/remove/exec) are allowed.
* Empty = permissive mode (all actions allowed on all containers).
* Set = strict mode (actions only allowed for compose files under these paths). */
const ALLOWED_PATHS = (process.env.ALLOWED_PATHS || "")
.split(":")
.map((p) => p.trim())
.filter(Boolean)
.map((p) => p.replace(/\/+$/, "")); // strip trailing slashes
/** When ALLOWED_PATHS is set, allow actions on non-compose containers (no compose label). */
const ALLOW_NON_COMPOSE = process.env.ALLOW_NON_COMPOSE === "true";
/** Strict mode is active when ALLOWED_PATHS has at least one entry. */
const RESTRICTED_MODE = ALLOWED_PATHS.length > 0;
/** Returns true if filePath is under one of the allowed prefixes. */
function isPathAllowed(filePath: string): boolean {
if (!RESTRICTED_MODE) return true;
const normalized = path.resolve(filePath);
return ALLOWED_PATHS.some((prefix) => normalized === prefix || normalized.startsWith(prefix + "/"));
}
/** Returns null if container can be acted upon, or an error message string if not. */
function checkContainerAccess(info: { Config?: { Labels?: Record<string, string> } }): string | null {
if (!RESTRICTED_MODE) return null;
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
if (!composeFile) {
if (ALLOW_NON_COMPOSE) return null;
return "This container has no compose file. Actions are restricted in this mode (ALLOWED_PATHS is set, ALLOW_NON_COMPOSE is false).";
}
if (!isPathAllowed(composeFile)) {
return `Container's compose file is outside ALLOWED_PATHS:\n ${composeFile}\n\nAllowed paths:\n${ALLOWED_PATHS.map((p) => ` ${p}`).join("\n")}`;
}
return null;
}
/** Env-file overrides per compose file (persisted to file) */ /** Env-file overrides per compose file (persisted to file) */
const ENV_FILES_FILE = path.join(DATA_DIR, ".dockerflow-env-files.json"); const ENV_FILES_FILE = path.join(DATA_DIR, ".dockerflow-env-files.json");
@@ -162,6 +200,15 @@ app.get("/api/init", async (c) => {
return c.json({ services, connections, positions }); return c.json({ services, connections, positions });
}); });
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
app.get("/api/config", (c) => {
return c.json({
allowedPaths: ALLOWED_PATHS,
allowNonCompose: ALLOW_NON_COMPOSE,
restrictedMode: RESTRICTED_MODE,
});
});
// ── Helper: get service uid from container inspect info ── // ── Helper: get service uid from container inspect info ──
function getContainerUid(info: any): string { function getContainerUid(info: any): string {
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
@@ -176,6 +223,8 @@ app.post("/api/containers/:id/stop", async (c) => {
try { try {
const container = docker.getContainer(id); const container = docker.getContainer(id);
const info = await container.inspect(); const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
await container.stop(); await container.stop();
immediateRefresh(); immediateRefresh();
const uid = getContainerUid(info); const uid = getContainerUid(info);
@@ -193,6 +242,8 @@ app.post("/api/containers/:id/start", async (c) => {
try { try {
const container = docker.getContainer(id); const container = docker.getContainer(id);
const info = await container.inspect(); const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
await container.start(); await container.start();
immediateRefresh(); immediateRefresh();
const uid = getContainerUid(info); const uid = getContainerUid(info);
@@ -210,6 +261,8 @@ app.post("/api/containers/:id/restart", async (c) => {
try { try {
const container = docker.getContainer(id); const container = docker.getContainer(id);
const info = await container.inspect(); const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
await container.restart(); await container.restart();
immediateRefresh(); immediateRefresh();
const uid = getContainerUid(info); const uid = getContainerUid(info);
@@ -226,12 +279,28 @@ app.post("/api/containers/:id/rebuild", async (c) => {
try { try {
const container = docker.getContainer(id); const container = docker.getContainer(id);
const info = await container.inspect(); const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
if (!composeFile || !serviceName) { if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400); return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
} }
if (!fs.existsSync(composeFile)) {
const dir = path.dirname(composeFile);
return c.json({
error:
`Compose file not accessible from ContainerFlow:\n` +
` ${composeFile}\n\n` +
`Este path existe en el host pero no está montado dentro del container de ContainerFlow.\n\n` +
`Fix: agrega este volumen a docker-compose.yml de ContainerFlow:\n` +
` - ${dir}:${dir}:ro\n\n` +
`O usa la variable HOST_PROJECTS_DIR en .env:\n` +
` HOST_PROJECTS_DIR=${dir}\n\n` +
`Luego: docker compose up -d --force-recreate containerflow`,
}, 400);
}
const uid = `${project}/${serviceName}`; const uid = `${project}/${serviceName}`;
const envArgs = findEnvFileArgs(composeFile); const envArgs = findEnvFileArgs(composeFile);
// Run rebuild in background — respond immediately // Run rebuild in background — respond immediately
@@ -260,12 +329,67 @@ app.post("/api/containers/:id/rebuild", async (c) => {
} }
}); });
app.post("/api/containers/:id/recreate", async (c) => {
const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400);
}
if (!fs.existsSync(composeFile)) {
const dir = path.dirname(composeFile);
return c.json({
error:
`Compose file not accessible from ContainerFlow:\n` +
` ${composeFile}\n\n` +
`Fix: agrega a docker-compose.yml de ContainerFlow:\n` +
` - ${dir}:${dir}:ro\n\n` +
`Luego: docker compose up -d --force-recreate containerflow`,
}, 400);
}
const uid = `${project}/${serviceName}`;
const envArgs = findEnvFileArgs(composeFile);
// Recreate uses existing image (no --build), only re-applies compose config
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--force-recreate", "-d", serviceName], {
stdout: "pipe",
stderr: "pipe",
});
proc.exited.then(async (exitCode) => {
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
const errorMsg = stderr || `Recreate failed with exit code ${exitCode}`;
broadcast({ type: "action_error", data: { uid, action: "recreate", error: errorMsg } });
try { notifyActionError(uid, "recreate", errorMsg, loadDiscordConfig()); } catch {}
} else {
try { notifyUIAction(uid, "recreate", loadDiscordConfig()); } catch {}
}
scheduleRefresh();
}).catch((err) => {
const errorMsg = err?.message || "Recreate failed";
broadcast({ type: "action_error", data: { uid, action: "recreate", error: errorMsg } });
try { notifyActionError(uid, "recreate", errorMsg, loadDiscordConfig()); } catch {}
});
return c.json({ ok: true });
} catch (err: any) {
return c.json({ error: err?.message || "Failed to recreate container" }, 500);
}
});
app.post("/api/containers/:id/remove", async (c) => { app.post("/api/containers/:id/remove", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400); if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
try { try {
const container = docker.getContainer(id); const container = docker.getContainer(id);
const info = await container.inspect(); const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
if (!composeFile || !serviceName) { if (!composeFile || !serviceName) {
@@ -274,6 +398,17 @@ app.post("/api/containers/:id/remove", async (c) => {
await container.remove({ force: true }); await container.remove({ force: true });
return c.json({ ok: true }); return c.json({ ok: true });
} }
if (!fs.existsSync(composeFile)) {
const dir = path.dirname(composeFile);
return c.json({
error:
`Compose file not accessible from ContainerFlow:\n` +
` ${composeFile}\n\n` +
`Fix: agrega a docker-compose.yml de ContainerFlow:\n` +
` - ${dir}:${dir}:ro\n\n` +
`Luego: docker compose up -d --force-recreate containerflow`,
}, 400);
}
const envArgs = findEnvFileArgs(composeFile); const envArgs = findEnvFileArgs(composeFile);
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "rm", "-sf", serviceName], { const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "rm", "-sf", serviceName], {
stdout: "pipe", stdout: "pipe",
@@ -294,6 +429,10 @@ app.post("/api/containers/:id/exec", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400); if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
try { try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
const body = await c.req.json(); const body = await c.req.json();
const cmd = body?.cmd; const cmd = body?.cmd;
if (!cmd || typeof cmd !== "string") return c.json({ error: "Missing cmd" }, 400); if (!cmd || typeof cmd !== "string") return c.json({ error: "Missing cmd" }, 400);
@@ -317,7 +456,6 @@ app.post("/api/containers/:id/exec", async (c) => {
if (current) parts.push(current); if (current) parts.push(current);
if (parts.length === 0) return c.json({ error: "Empty command" }, 400); if (parts.length === 0) return c.json({ error: "Empty command" }, 400);
const container = docker.getContainer(id);
const exec = await container.exec({ Cmd: parts, AttachStdout: true, AttachStderr: true }); const exec = await container.exec({ Cmd: parts, AttachStdout: true, AttachStderr: true });
const stream = await exec.start({}); const stream = await exec.start({});
+1 -1
View File
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import path from "path"; import path from "path";
import type { Stats, StatsHistoryPoint, StatsRange } from "../shared/types"; import type { Stats, StatsHistoryPoint, StatsRange } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd(); const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, ".dockerflow-stats.db"); const DB_PATH = path.join(DATA_DIR, ".dockerflow-stats.db");
let db: Database; let db: Database;
+14
View File
@@ -85,6 +85,20 @@ export interface StatsHistoryPoint {
export type StatsRange = "1h" | "6h" | "24h" | "7d"; export type StatsRange = "1h" | "6h" | "24h" | "7d";
export interface ActionError {
id: string;
uid: string;
action: string;
error: string;
timestamp: number;
}
export interface ServerConfig {
allowedPaths: string[];
allowNonCompose: boolean;
restrictedMode: boolean;
}
export type WSMessage = export type WSMessage =
| { type: "services"; data: Service[] } | { type: "services"; data: Service[] }
| { type: "connections"; data: Connection[] } | { type: "connections"; data: Connection[] }
-25
View File
@@ -1,25 +0,0 @@
# 01 — Project Setup
## Objetivo
Inicializar el proyecto con Bun, TypeScript, Vite, React y todas las dependencias.
## Tareas
- [ ] `bun init` con TypeScript
- [ ] `package.json` con scripts: `dev`, `build`, `start`
- [ ] `tsconfig.json` para server (Node/Bun) y client (React)
- [ ] `vite.config.ts` con React plugin y proxy al server
- [ ] Instalar dependencias:
- Server: `hono`, `dockerode`, `yaml`, `zod`
- Client: `react`, `react-dom`, `@xyflow/react`, `@dagrejs/dagre`
- Dev: `typescript`, `vite`, `@vitejs/plugin-react`, `tailwindcss`, `@types/dockerode`
- [ ] Crear estructura de carpetas:
```
src/
├── server/
├── client/
└── shared/
```
- [ ] Verificar que `bun run dev` arranca sin errores
## Criterio de completado
`bun run dev` levanta el server Hono en :9470 y sirve una página React vacía.
-21
View File
@@ -1,21 +0,0 @@
# 02 — Docker Auto-Discovery
## Objetivo
Leer containers, redes y stats desde el Docker socket. Detectar conexiones automáticamente.
## Tareas
- [ ] `src/server/docker.ts``discoverServices()`:
- Lee `docker.listContainers({ all: true })`
- Extrae: name, image, state, status, ports, networks, project, compose_file
- [ ] `src/server/docker.ts``discoverConnections()`:
- Lee redes y detecta qué containers comparten red
- Genera edges entre pares de containers en la misma red
- [ ] `src/server/docker.ts``inferEdgeType()`:
- Heurísticas: postgres/mysql → "database", redis → "cache", nginx/traefik → "proxy", rabbit/kafka → "broker"
- [ ] `src/shared/types.ts` — tipos compartidos: `Service`, `Connection`, `EdgeType`, `Stats`
- [ ] Endpoint REST: `GET /api/services` y `GET /api/connections`
- [ ] Probar que detecta correctamente los containers de ninjasagacw
## Criterio de completado
`curl http://localhost:9470/api/services` retorna JSON con todos los containers corriendo.
`curl http://localhost:9470/api/connections` retorna las conexiones detectadas.
-24
View File
@@ -1,24 +0,0 @@
# 03 — WebSocket + Stats + Docker Events
## Objetivo
Enviar datos en tiempo real al frontend via WebSocket: servicios, stats y eventos Docker.
## Tareas
- [ ] `src/server/watcher.ts``pollStats()`:
- CPU y MEM por container (solo running)
- Calcula cpu_percent, mem_mb, mem_percent
- [ ] `src/server/watcher.ts``watchDockerEvents()`:
- Stream de Docker events API
- Filtra por Type: "container"
- Emite: start, stop, die, restart, health_status
- [ ] WebSocket en `src/server/index.ts`:
- Bun.serve con websocket handler
- `broadcast()` a todos los clients conectados
- Polling de services + connections + stats cada 3s
- Docker events en tiempo real
- [ ] `src/client/hooks/useDocker.ts`:
- Hook que conecta al WebSocket
- Mantiene estado de services, connections, stats, events
## Criterio de completado
Abrir el dashboard, la consola del browser muestra datos llegando por WebSocket cada 3s.
-21
View File
@@ -1,21 +0,0 @@
# 04 — ServiceNode (nodo visual de container)
## Objetivo
Crear el componente visual que representa cada container en el grafo.
## Tareas
- [ ] `src/client/nodes/ServiceNode.tsx`:
- Status dot con animate-pulse (running = verde, stopped = rojo, paused = amarillo)
- Icono auto-detectado por imagen (postgres=🐘, redis=⚡, nginx=🔀, node=💚, python=🐍, etc.)
- Nombre del servicio
- Imagen (truncada)
- Puertos como badges cyan
- Barras de CPU/MEM con porcentaje
- Badge del proyecto (compose project name)
- Handles top/bottom para edges
- Dark theme: bg slate-900, bordes según estado, backdrop-blur
- [ ] Registrar nodeTypes en React Flow
- [ ] Probar con datos mock primero
## Criterio de completado
Se ven nodos bonitos con toda la info, pulso verde en running, rojo en stopped.
@@ -1,26 +0,0 @@
# 05 — React Flow + Auto-Layout + Agrupación
## Objetivo
Montar el canvas de React Flow con auto-layout (dagre) y subgraphs por proyecto/compose file.
## Tareas
- [ ] `src/client/App.tsx`:
- React Flow con Background, Controls, MiniMap
- Dark theme (bg-slate-950, minimap dark)
- fitView al cargar
- [ ] `src/client/engine/layout.ts`:
- Auto-layout con dagre respetando grupos
- Nodos dentro de su grupo (parentId)
- Posicionamiento que no se solape
- [ ] Agrupación automática:
- `detectGrouping()`: si hay 1 proyecto → agrupa por compose_file, si hay múltiples → agrupa por project
- Nodos "group" de React Flow con borde dashed, label, fondo semi-transparente
- Colores distintos por grupo
- [ ] Edges entre nodos:
- Usa connections del backend
- Label con tipo de conexión (postgres, cache, upstream, broker)
- Estilo: línea sólida gris con label
- [ ] Conectar useDocker hook → actualizar nodos/edges en tiempo real
## Criterio de completado
Dashboard muestra todos los containers agrupados por proyecto/compose file, con edges entre ellos, auto-layout limpio, minimap, zoom y pan.
-23
View File
@@ -1,23 +0,0 @@
# 06 — Filtrado de Proyectos (CLI + Frontend)
## Objetivo
Permitir filtrar qué proyectos se muestran, tanto por CLI como por dropdown en el frontend.
## Tareas
- [ ] CLI args en `src/server/index.ts`:
- `--all` → carga todos los containers
- `--projects=name1,name2` → filtra por com.docker.compose.project
- Sin flags → auto-detecta por `path.basename(process.cwd())`
- Filtro aplicado en `discoverServices()`
- [ ] `src/client/panels/ProjectFilter.tsx`:
- Dropdown con checkboxes por proyecto
- "Mostrar todos" toggle
- Selección se guarda en localStorage
- Solo visible si hay más de 1 proyecto (si usó --all o --projects con varios)
- [ ] Filtro en el frontend:
- Los nodos/edges se filtran en el cliente según selección
- Transición suave al mostrar/ocultar nodos
## Criterio de completado
`bunx alteonx-dockerflow --all` muestra todos los proyectos con dropdown para filtrar.
`bunx alteonx-dockerflow` sin flags muestra solo el proyecto del directorio actual, sin dropdown.
-23
View File
@@ -1,23 +0,0 @@
# 07 — Seguridad (AUTH_TOKEN)
## Objetivo
Proteger el dashboard con token cuando se expone en red.
## Tareas
- [ ] Lógica de bind:
- Sin `AUTH_TOKEN` → bind a `127.0.0.1` (solo local)
- Con `AUTH_TOKEN` → bind a `0.0.0.0` (acceso remoto)
- [ ] Middleware Hono:
- Valida `Authorization: Bearer <token>` en toda request excepto `/` y assets
- 401 si token inválido
- [ ] WebSocket auth:
- Valida token en el handshake
- Cierra conexión si no es válido
- [ ] Pantalla de login en el frontend:
- Input de token, botón "Entrar"
- Guarda token en localStorage
- Lo envía en headers y WebSocket
## Criterio de completado
Sin AUTH_TOKEN: funciona sin pedir nada en localhost.
Con AUTH_TOKEN: pide token al entrar, rechaza si es incorrecto, funciona si es correcto.
-29
View File
@@ -1,29 +0,0 @@
# 08 — Polish Fase 1
## Objetivo
Pulir detalles visuales y funcionales para cerrar la Fase 1.
## Tareas
- [ ] Header del dashboard:
- Logo/nombre "Alteonx DockerFlow"
- Indicador de conexión WebSocket (verde = conectado, rojo = desconectado)
- Dropdown de proyecto (de tarea 06)
- [ ] Docker events visuales:
- Container start → flash verde en el nodo
- Container stop/die → flash rojo en el nodo
- Container restart → flash amarillo
- [ ] Tooltips en nodos:
- Hover → muestra status completo, uptime, networks
- [ ] Edge labels legibles:
- No se solapen entre sí
- Se ocultan en zoom bajo
- [ ] Responsive básico:
- Funcione en pantallas desde 1280px
- [ ] Console log limpio (sin warnings de React/Vite)
- [ ] README.md básico con:
- Qué es
- Quickstart (3 comandos)
- Screenshot placeholder
## Criterio de completado
Dashboard se ve profesional, sin bugs visuales, README funcional.