diff --git a/.dockerflow-env-files.json b/.dockerflow-env-files.json deleted file mode 100644 index e788fac..0000000 --- a/.dockerflow-env-files.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/.env.example b/.env.example index 69f5b22..62c26c8 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,60 @@ +# ────────────────────────────────────────────────────────────── +# Servidor +# ────────────────────────────────────────────────────────────── + # Puerto del servidor (por defecto: 9470) PORT=9470 # Token de autenticacion — dejar vacio para acceso solo en localhost (sin login) # Poner un valor para activar auth + acceso remoto (0.0.0.0) AUTH_TOKEN= + +# ────────────────────────────────────────────────────────────── +# 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 diff --git a/.gitignore b/.gitignore index 88fc811..c2e0e86 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ node_modules/ dist/ *.log .env + +# Persistent data (SQLite + JSON configs) — default location +data/ + +# Legacy location (cuando los archivos vivian en cwd directamente) .dockerflow-*.json .dockerflow-*.db .dockerflow-*.db-wal diff --git a/Dockerfile b/Dockerfile index b6fbe87..1bb8086 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,13 +14,14 @@ RUN bun run build # ── Stage 2: runtime ── 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 \ && 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 \ && 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 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 autoremove -y \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 5335195..16ad2e2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Real-time Docker architecture visualizer. Displays services, connections and met ![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 - [Bun](https://bun.sh) >= 1.0 @@ -38,7 +42,10 @@ Variables disponibles: |---|---|---| | `PORT` | `9470` | Puerto del servidor | | `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 @@ -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 - **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 +- **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 - **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) @@ -177,10 +187,90 @@ Ver `.github/workflows/ci.yml`. ## 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. - **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. +### 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 | Componente | Tecnologia | @@ -214,21 +304,23 @@ src/ ServiceNode.tsx — nodo visual por container GroupNode.tsx — header de grupo (proyecto/compose) 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) useStatsStore.ts — store en memoria para stats live processing.ts — logica pura de estados processing engine/ layout.ts — layout de grupos + grid + edges components/ - HeaderBar.tsx — barra superior con navegacion - EdgeLegend.tsx — leyenda de tipos de conexion - LoginScreen.tsx — pantalla de autenticacion - NodeContextMenu.tsx — menu contextual de nodos - OffsetEdge.tsx — edge custom con offset para evitar superposicion - Sparkline.tsx — gráfica de línea ligera para historial de stats - StatsCard.tsx — tarjeta de métrica con sparkline, hover, promedio y umbral - ThresholdBar.tsx — slider de umbral por contenedor con override/reset + HeaderBar.tsx — barra superior con navegacion + EdgeLegend.tsx — leyenda de tipos de conexion + LoginScreen.tsx — pantalla de autenticacion + NodeContextMenu.tsx — menu contextual de nodos (con disable cuando locked) + OffsetEdge.tsx — edge custom con offset para evitar superposicion + Sparkline.tsx — gráfica de línea ligera para historial de stats + StatsCard.tsx — tarjeta de métrica con sparkline, hover, promedio y umbral + ThresholdBar.tsx — slider de umbral por contenedor con override/reset + ActionErrorToast.tsx — stack de toasts top-right para errores de acciones panels/ DetailPanel.tsx — panel lateral con info, stats, env, config y logs LogPanel.tsx — panel de logs por container diff --git a/docker-compose.yml b/docker-compose.yml index 578b4f9..578b567 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,16 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - 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: - DATA_DIR=/app/data env_file: .env diff --git a/docker-containerflow.md b/docker-containerflow.md new file mode 100644 index 0000000..10204a5 --- /dev/null +++ b/docker-containerflow.md @@ -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 ` | Arrancar un container detenido | +| **Stop** | `docker stop ` | Apagar limpio (SIGTERM, luego SIGKILL tras 10s) | +| **Restart** | `docker restart ` | Reiniciar el proceso sin recrear nada (rápido) | +| **Recreate** | `docker compose up -d --force-recreate ` | Aplicar cambios de compose (env, volumes, ports) sin rebuild | +| **Rebuild** | `docker compose up -d --build ` | Aplicar cambios de código (reconstruye imagen) | +| **Remove** | `docker compose rm -sf ` | Eliminar el container permanentemente (imagen queda) | +| **Exec** | `docker exec ` | 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//_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. diff --git a/image-1200.jpg b/image-1200.jpg deleted file mode 100644 index 606d3ca..0000000 Binary files a/image-1200.jpg and /dev/null differ diff --git a/image-1200.png b/image-1200.png deleted file mode 100644 index 5711913..0000000 Binary files a/image-1200.png and /dev/null differ diff --git a/image-1600.png b/image-1600.png deleted file mode 100644 index 67a0667..0000000 Binary files a/image-1600.png and /dev/null differ diff --git a/image.png b/image.png deleted file mode 100644 index e5a88bc..0000000 Binary files a/image.png and /dev/null differ diff --git a/src/client/App.tsx b/src/client/App.tsx index e37f9e7..680f8b8 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -15,6 +15,7 @@ import "@xyflow/react/dist/style.css"; import { ServiceNode } from "./nodes/ServiceNode"; import { GroupNode } from "./nodes/GroupNode"; import { useDocker } from "./hooks/useDocker"; +import { useServerConfig } from "./hooks/useServerConfig"; import { I18nProvider, useT } from "./i18n"; import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore"; 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 { HeaderBar, type Page } from "./components/HeaderBar"; import { EdgeLegend } from "./components/EdgeLegend"; +import { ActionErrorToast } from "./components/ActionErrorToast"; import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react"; import { MonitoringPage } from "./pages/MonitoringPage"; import { SettingsPage } from "./pages/SettingsPage"; @@ -82,7 +84,8 @@ function Dashboard({ token }: { token: string }) { const onPositions = useCallback((pos: Record) => { 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([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const initialLayoutDone = useRef(false); @@ -325,6 +328,14 @@ function Dashboard({ token }: { token: string }) { 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) { let positioned = newNodes.map((n) => { const saved = savedPositions.current[n.id]; @@ -405,7 +416,7 @@ function Dashboard({ token }: { token: string }) { return result; }); } - }, [filteredServices, filteredConnections]); + }, [filteredServices, filteredConnections, canInteract]); // Recompute edges + handles on drag end (not every pixel) const recomputeEdges = useCallback((currentNodes: Node[]) => { @@ -509,6 +520,8 @@ function Dashboard({ token }: { token: string }) { events={events} /> + + {activePage === "monitoring" && } {activePage === "settings" && } @@ -674,23 +687,35 @@ function Dashboard({ token }: { token: string }) { setContextMenu(null)} onAction={(action) => { const svc = contextMenu.service; // Optimistic processing — set BEFORE fetch const expectedState: Service["state"] = action === "stop" || action === "remove" ? "exited" : - action === "start" || action === "restart" || action === "rebuild" ? "running" : + action === "start" || action === "restart" || action === "rebuild" || action === "recreate" ? "running" : 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); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers }) - .then((r) => { - if (!r.ok) clearProcessing(svc.uid); + .then(async (r) => { + 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={() => { const svc = contextMenu.service; @@ -731,9 +756,11 @@ function Dashboard({ token }: { token: string }) { logLines={panelLogLines} token={token} closing={panelClosing} + locked={!canInteract(detailService)} onClose={closeDetail} onAction={setProcessing} clearProcessing={clearProcessing} + pushActionError={pushActionError} sendMessage={sendMessage} clearLogLines={clearLogLines} connections={filteredConnections} diff --git a/src/client/components/ActionErrorToast.tsx b/src/client/components/ActionErrorToast.tsx new file mode 100644 index 0000000..277de88 --- /dev/null +++ b/src/client/components/ActionErrorToast.tsx @@ -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 ( +
+
+ +
+
+ {t("toast.actionFailed").replace("{action}", err.action)} +
+
+ {shortName} + {project && {project}} +
+
+ +
+
+
+          {visibleError}
+        
+
+ {isLong && ( + + )} + +
+
+
+ ); +} + +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 ( +
+ {errors.length > 1 && ( + + )} + {errors.map((err) => ( + + ))} +
+ ); +} diff --git a/src/client/components/NodeContextMenu.tsx b/src/client/components/NodeContextMenu.tsx index 0c430b0..0b0c4d2 100644 --- a/src/client/components/NodeContextMenu.tsx +++ b/src/client/components/NodeContextMenu.tsx @@ -1,17 +1,18 @@ 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 { useT } from "../i18n"; interface NodeContextMenuProps { position: { x: number; y: number }; service: Service; - onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild") => void; + locked?: boolean; + onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild" | "recreate") => void; onOpenLogs: () => 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 ref = useRef(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]" style={{ left: x, top: y }} > + {locked && ( + <> +
+ + {t("access.viewOnly")} +
+
+ + )} {isRunning ? ( <> - { onAction("restart"); onClose(); }} /> - { onAction("stop"); onClose(); }} /> + { onAction("restart"); onClose(); }} /> + { onAction("stop"); onClose(); }} /> ) : ( <> - { onAction("start"); onClose(); }} /> - { onAction("remove"); onClose(); }} /> + { onAction("start"); onClose(); }} /> + { onAction("remove"); onClose(); }} /> )} {service.compose_file && ( <>
- { onAction("rebuild"); onClose(); }} /> + { onAction("recreate"); onClose(); }} /> + { onAction("rebuild"); onClose(); }} /> )}
@@ -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 ( ); diff --git a/src/client/components/Tooltip.tsx b/src/client/components/Tooltip.tsx new file mode 100644 index 0000000..a910932 --- /dev/null +++ b/src/client/components/Tooltip.tsx @@ -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 ( + + + {show && ( +
+ {text} +
+
+ )} + + ); +} diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index 5096d96..dc85156 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -1,5 +1,5 @@ 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 { arraysEqual, applyProcessing as applyProcessingPure } from "./processing"; @@ -9,6 +9,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po const statsRef = useRef>(new Map()); const [events, setEvents] = useState([]); const [logLines, setLogLines] = useState([]); + const [actionErrors, setActionErrors] = useState([]); // Processing state: uid → { expected state, start time, min duration before clearing } const processingRef = useRef>(new Map()); const processingIntervalsRef = useRef>>(new Map()); @@ -123,6 +124,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po } else { 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; } } @@ -221,5 +227,19 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po 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 }; } diff --git a/src/client/hooks/useServerConfig.ts b/src/client/hooks/useServerConfig.ts new file mode 100644 index 0000000..45e7ee6 --- /dev/null +++ b/src/client/hooks/useServerConfig.ts @@ -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(DEFAULT_CONFIG); + + useEffect(() => { + const headers: Record = {}; + 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): 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 }; +} diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index c6181dd..5334aed 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -32,16 +32,39 @@ const en = { "login.errorConnectionRefused": "Connection refused", "login.errorConnectionFailed": "ERROR: Connection failed", - // Context menu + // Context menu — labels stay in English (match docker commands) "actions.restart": "Restart", "actions.stop": "Stop", "actions.start": "Start", "actions.remove": "Remove", "actions.rebuild": "Rebuild", + "actions.recreate": "Recreate", "actions.openLogs": "Open Logs", "actions.open": "Open", "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 "legend.connections": "Connections", @@ -138,6 +161,7 @@ const en = { "detail.confirmRestart": "Restart this container? This will briefly interrupt the service.", "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.confirmRecreate": "Recreate this container? Reuses the existing image and applies current compose config.", // Detail panel - Crash "detail.containerCrashed": "Container crashed", @@ -249,15 +273,38 @@ const es: Record = { "login.errorConnectionRefused": "Conexi\u00f3n rechazada", "login.errorConnectionFailed": "ERROR: Conexi\u00f3n fallida", - // Context menu - "actions.restart": "Reiniciar", - "actions.stop": "Detener", - "actions.start": "Iniciar", - "actions.remove": "Eliminar", - "actions.rebuild": "Reconstruir", + // Context menu — labels stay in English (match docker commands, evita confusion) + "actions.restart": "Restart", + "actions.stop": "Stop", + "actions.start": "Start", + "actions.remove": "Remove", + "actions.rebuild": "Rebuild", + "actions.recreate": "Recreate", "actions.openLogs": "Ver Logs", "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 "legend.connections": "Conexiones", @@ -355,6 +402,7 @@ const es: Record = { "detail.confirmRestart": "\u00bfReiniciar este contenedor? Esto interrumpir\u00e1 brevemente el servicio.", "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.confirmRecreate": "\u00bfRecrear este contenedor? Reusa la imagen existente y aplica la config actual del compose.", // Detail panel - Crash "detail.containerCrashed": "Contenedor crash\u00f3", diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 690f6f7..4db984f 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -23,6 +23,7 @@ import { Mail, BarChart3, AlertTriangle, + Lock, type LucideIcon, } from "lucide-react"; @@ -36,6 +37,7 @@ interface ServiceNodeData { id?: string; activeHandles?: string[]; highlighted?: boolean; + locked?: boolean; [key: string]: unknown; } @@ -136,11 +138,16 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) { return (
`${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 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 && ( +
+ +
+ )} {/* Top handles — left offset, transform centered horizontally */} {offsets.map((o, i) => ( diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index 7a844e9..ac16db7 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -1,7 +1,8 @@ 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 { useT } from "../i18n"; +import { Tooltip } from "../components/Tooltip"; interface SettingsPageProps { projects: string[]; @@ -26,29 +27,6 @@ const DEFAULT_CONFIG: DiscordConfig = { downReminderMinutes: 5, }; -function Tooltip({ text }: { text: string }) { - const [show, setShow] = useState(false); - return ( - - - {show && ( -
- {text} -
-
- )} - - ); -} - function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { return ( + <> + + + )} {service.state === "running" ? ( <>
- {service.state === "running" && ( + {service.state === "running" && !locked && ( - {show && ( -
- {text} -
-
- )} - - ); -} 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 ( @@ -1321,14 +1323,14 @@ function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel
{thresholdLabel}: {threshold} - {thresholdTooltip && } + {thresholdTooltip && }
)} {limit && (
{limitLabel}: {limit} - {limitTooltip && } + {limitTooltip && }
)}
diff --git a/src/server/container-settings.ts b/src/server/container-settings.ts index f071ead..870a6e5 100644 --- a/src/server/container-settings.ts +++ b/src/server/container-settings.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; 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"); export function loadContainerSettings(): Record { diff --git a/src/server/discord.ts b/src/server/discord.ts index c411187..0ce55d4 100644 --- a/src/server/discord.ts +++ b/src/server/discord.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; 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 DEFAULT_CONFIG: DiscordConfig = { diff --git a/src/server/index.ts b/src/server/index.ts index 33a308a..3821270 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,8 +11,46 @@ import { loadContainerSettings, saveContainerSettings } from "./container-settin import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db"; import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; -/** Directory for persistent data files (positions, env overrides) */ -const DATA_DIR = process.env.DATA_DIR || process.cwd(); +/** Directory for persistent data files (SQLite, JSON configs, positions). + * 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 | 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) */ 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 }); }); +// ── 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 ── function getContainerUid(info: any): string { const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; @@ -176,6 +223,8 @@ app.post("/api/containers/:id/stop", async (c) => { try { const container = docker.getContainer(id); const info = await container.inspect(); + const denied = checkContainerAccess(info); + if (denied) return c.json({ error: denied }, 403); await container.stop(); immediateRefresh(); const uid = getContainerUid(info); @@ -193,6 +242,8 @@ app.post("/api/containers/:id/start", async (c) => { try { const container = docker.getContainer(id); const info = await container.inspect(); + const denied = checkContainerAccess(info); + if (denied) return c.json({ error: denied }, 403); await container.start(); immediateRefresh(); const uid = getContainerUid(info); @@ -210,6 +261,8 @@ app.post("/api/containers/:id/restart", async (c) => { try { const container = docker.getContainer(id); const info = await container.inspect(); + const denied = checkContainerAccess(info); + if (denied) return c.json({ error: denied }, 403); await container.restart(); immediateRefresh(); const uid = getContainerUid(info); @@ -226,12 +279,28 @@ app.post("/api/containers/:id/rebuild", async (c) => { 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 — 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 envArgs = findEnvFileArgs(composeFile); // 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) => { 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"]; if (!composeFile || !serviceName) { @@ -274,6 +398,17 @@ app.post("/api/containers/:id/remove", async (c) => { await container.remove({ force: 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 proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "rm", "-sf", serviceName], { stdout: "pipe", @@ -294,6 +429,10 @@ app.post("/api/containers/:id/exec", 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 body = await c.req.json(); const cmd = body?.cmd; 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 (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 stream = await exec.start({}); diff --git a/src/server/stats-db.ts b/src/server/stats-db.ts index 7bcb872..3e5d57b 100644 --- a/src/server/stats-db.ts +++ b/src/server/stats-db.ts @@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"; import path from "path"; 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"); let db: Database; diff --git a/src/shared/types.ts b/src/shared/types.ts index 912ef7e..3867eee 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -85,6 +85,20 @@ export interface StatsHistoryPoint { 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 = | { type: "services"; data: Service[] } | { type: "connections"; data: Connection[] } diff --git a/tareas/completadas/01-project-setup.md b/tareas/completadas/01-project-setup.md deleted file mode 100644 index db9debe..0000000 --- a/tareas/completadas/01-project-setup.md +++ /dev/null @@ -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. diff --git a/tareas/completadas/02-docker-discovery.md b/tareas/completadas/02-docker-discovery.md deleted file mode 100644 index 5b24a01..0000000 --- a/tareas/completadas/02-docker-discovery.md +++ /dev/null @@ -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. diff --git a/tareas/completadas/03-websocket-stats.md b/tareas/completadas/03-websocket-stats.md deleted file mode 100644 index 9a7f558..0000000 --- a/tareas/completadas/03-websocket-stats.md +++ /dev/null @@ -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. diff --git a/tareas/completadas/04-service-node.md b/tareas/completadas/04-service-node.md deleted file mode 100644 index 052f385..0000000 --- a/tareas/completadas/04-service-node.md +++ /dev/null @@ -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. diff --git a/tareas/completadas/05-react-flow-layout.md b/tareas/completadas/05-react-flow-layout.md deleted file mode 100644 index 7b2918f..0000000 --- a/tareas/completadas/05-react-flow-layout.md +++ /dev/null @@ -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. diff --git a/tareas/completadas/06-project-filter.md b/tareas/completadas/06-project-filter.md deleted file mode 100644 index 6bccd72..0000000 --- a/tareas/completadas/06-project-filter.md +++ /dev/null @@ -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. diff --git a/tareas/completadas/07-security.md b/tareas/completadas/07-security.md deleted file mode 100644 index 7ae6141..0000000 --- a/tareas/completadas/07-security.md +++ /dev/null @@ -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 ` 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. diff --git a/tareas/completadas/08-polish-fase1.md b/tareas/completadas/08-polish-fase1.md deleted file mode 100644 index 744212e..0000000 --- a/tareas/completadas/08-polish-fase1.md +++ /dev/null @@ -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.