7 Commits
Author SHA1 Message Date
RGJorge b079422de8 v0.1.6 — project aliases (closes #3)
Adds human-readable aliases for project groups (the wrapping label
around services), useful when orchestrators like Coolify or Dokploy
generate cryptic project keys.

- New server module `src/server/project-aliases.ts` with load/save
  helpers, a 64-char max, and control-char sanitization.
- New endpoints: GET /api/project-aliases, PUT /api/project-aliases,
  DELETE /api/project-aliases/:project. Aliases also returned in
  /api/init for first-paint hydration.
- GroupNode header is now click-to-edit: short names prefill so users
  can tweak them, long cryptic names start blank for a fresh alias.
  The compose suffix (PROD/DEV/etc) stays visible and is not part of
  the alias. Reset icon appears only when an alias differs from the
  original project key.
- App.tsx wires the alias state, the project filter dropdown shows
  the alias plus the compose suffix list joined by " - ".
- MonitoringPage uses the alias in: project filter dropdown, the
  totals card title, and per-service card subtitles (which show only
  THAT service's compose, not the whole project list).
- engine/layout.ts exports getComposeKey and stores the raw `project`
  key in group node data so the same alias scope matches the filter.
- i18n: 4 new keys (EN + ES) for rename / reset / save / cancel.
2026-05-16 03:16:43 +00:00
RGJorge 71b8f94e21 v0.1.5 — export graph as PNG (closes #4)
Adds a download button to the bottom-left React Flow controls that
exports the current canvas view as a PNG with the dot grid preserved.

- New util src/client/utils/exportPng.ts: html-to-image capture +
  manual canvas composition (solid bg + dot grid + captured layer).
  Works around html-to-image's SVG <pattern> limitations and produces
  a deterministic 3x retina PNG.
- During capture, temporary CSS disables ring-shadow halos and forces
  service node bodies to a solid dark fill so the dot grid doesn't
  bleed through. State is still indicated by border colors and the
  inner state dot. Group node headers are excluded so their outlines
  stay intact.
- New component src/client/components/ExportPngButton.tsx integrated
  as a custom ControlButton inside <Controls> (bottom-left).
- App.tsx: id="dashboard-canvas" + data-no-export on project filter.
- i18n: 4 keys (EN + ES) for the button and 3 error states.
- New dependency: html-to-image@1.11.13
2026-05-16 02:08:22 +00:00
RGJorge 2a660c1828 v0.1.4 — prebuilt Docker image on GHCR (closes #5)
Adds a GitHub Actions workflow that builds and pushes the Docker image
to ghcr.io/rgjorge/containerflow on every tag push, so end users can run
ContainerFlow without cloning the repo or building locally.

- New workflow: .github/workflows/docker-release.yml
- docker-compose.yml: uses the prebuilt image from GHCR by default
- New docker-compose.local.yml: override for building from source (devs)
- .env.example: simplified + adds COMPOSE_FILE toggle for local build
- README EN + ES: quick-start updated to no-clone flow
- .gitignore: ignore task/ planning notes dir
2026-05-16 01:26:28 +00:00
RGJorge dd6d2599f0 v0.1.3 — English README as primary, Spanish preserved as README.es.md
Addresses feedback from multiple reviewers on the launch thread asking
for non-Spanish primary documentation. Also adds a framing quote from
community feedback (u/dashingsauce on Reddit) that captures the project
positioning better than prior phrasing.

- README.md now in English with language switcher to Spanish version
- README.es.md preserves the original Spanish content with switcher to English
- Added missing references in structure section (i18n.tsx, watcher.test.ts, events-db.ts)
- Added Discussions link to community section
2026-05-12 17:28:22 +00:00
RGJorge 1273ff6f01 v0.1.2 — dynamic CI status badge in README
Swap the static "tests-37-passing" badge for the live GitHub Actions badge
now that CI is back online. README visitors see the real build status.
2026-05-12 02:46:11 +00:00
RGJorge 0038a7f20d v0.1.1 — fix UI overflow with long container/project names
Closes #1

- ServiceNode: cap card width at 240px, native tooltip on name and image
- App: project filter dropdown capped at 280px with scroll, names truncate
- DetailPanel header: proper flex layout (min-w-0 flex-1 + shrink-0),
  Tooltip with (?) shows full name on hover; same fix for logs modal header
- Re-enable CI workflow (was paused for GitHub billing)
2026-05-12 02:40:50 +00:00
RGJorge 396873ae87 v0.1.0 — Repositorio público 2026-05-11 03:02:14 +00:00
33 changed files with 1380 additions and 1628 deletions
+23 -37
View File
@@ -1,60 +1,46 @@
# ──────────────────────────────────────────────────────────────
# Servidor
# Server
# ──────────────────────────────────────────────────────────────
# 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. Empty = localhost only, no login.
# Set a value = login enabled + remote access (0.0.0.0).
AUTH_TOKEN=
# ──────────────────────────────────────────────────────────────
# Persistencia
# Persistence
# ──────────────────────────────────────────────────────────────
# 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.
# Where SQLite, configs, node positions and env file overrides are stored.
# Default native: ./data. In docker: /app/data (containerflow-data volume).
# DATA_DIR=/app/data
# ──────────────────────────────────────────────────────────────
# Acceso a compose files (rebuild / remove)
# Compose file access (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
# Extra host path to mount so rebuild/remove can read compose files
# outside /home, /opt, /srv, /root. Example: /data/apps
# HOST_PROJECTS_DIR=
# ──────────────────────────────────────────────────────────────
# Control de acceso por path (multi-usuario)
# Access control (multi-tenant)
# ──────────────────────────────────────────────────────────────
# 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
# Empty = permissive (all actions allowed).
# Set = strict (only containers under these paths are actionable; rest locked).
# Colon-separated. Example: /home/jorge:/srv/myapp
# 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.)
# Only applies when ALLOWED_PATHS is active.
# false = block actions on non-compose containers.
# true = allow them (useful for watchtower, traefik, etc.).
# ALLOW_NON_COMPOSE=false
# ──────────────────────────────────────────────────────────────
# Dev mode (build from source instead of pulling the image)
# ──────────────────────────────────────────────────────────────
# Uncomment, then run: docker compose up -d --build
# COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
+1 -1
View File
@@ -36,7 +36,7 @@ assignees: ''
## Are you willing to wait?
ContainerFlow is currently maintainer-driven (no external PRs accepted yet). Features will land based on roadmap priority. See [monitoreo.md](../../monitoreo.md) for what's planned.
ContainerFlow is currently maintainer-driven (no external PRs accepted yet). Features will land based on roadmap priority. See [roadmap](../../docs/roadmap.md) for what's planned.
- [ ] Yes, I'll wait — I just want to flag this
- [ ] I'd contribute a PR if/when PRs open
+48
View File
@@ -0,0 +1,48 @@
name: Docker Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/containerflow
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+10
View File
@@ -11,3 +11,13 @@ data/
.dockerflow-*.db
.dockerflow-*.db-wal
.dockerflow-*.db-shm
# Local notes / marketing — not part of the public repo
linkdin.md
recomendaciones.md
docs/reddit.md
# AI assistant context — internal, not for public repo
CLAUDE.md
# Planning notes — internal, not for public repo
task/
-40
View File
@@ -1,40 +0,0 @@
# Dockerflow (ContainerFlow)
## Nomenclatura UI
- **Nodo** — tarjeta de servicio en el canvas (`ServiceNode.tsx`)
- **Panel** — panel lateral izquierdo con detalles del servicio (`DetailPanel.tsx`)
- **Canvas** — mesa de trabajo donde se ven los nodos y conexiones (ReactFlow)
## Stack
- **Frontend:** React + ReactFlow + Tailwind CSS
- **Backend:** Hono + Bun
- **Docker:** dockerode para comunicacion con Docker API
## Estructura
- `src/client/` — frontend React
- `nodes/` — componentes de nodos (ServiceNode, GroupNode)
- `panels/` — paneles (DetailPanel)
- `hooks/` — hooks (useDocker)
- `engine/` — layout
- `components/` — componentes generales
- `src/server/` — backend Hono
- `index.ts` — servidor principal, WebSocket, API REST
- `docker.ts` — interaccion con Docker
- `watcher.ts` — polling de stats y eventos
- `src/shared/` — tipos compartidos
## Comandos
- `bun run dev` — desarrollo (servidor + cliente)
- `bun run build` — build de produccion
## i18n (Internacionalizacion)
- Todo texto visible en la UI debe usar el sistema de traducciones (`useT()` hook de `src/client/i18n.tsx`)
- Al agregar texto nuevo, agregar la key en ambos diccionarios (en + es) en `i18n.tsx`
- Keys usan formato `seccion.descripcion` (ej. `"settings.save"`, `"actions.restart"`)
- Nunca hardcodear strings de UI directamente en JSX
- El idioma se persiste en `localStorage("df:lang")`, default `"en"`
+1 -1
View File
@@ -41,7 +41,7 @@ Use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md). T
- Why existing functionality doesn't work
- A rough sketch of how you'd want it to work in the UI
We prioritize features that align with the [roadmap](monitoreo.md).
We prioritize features that align with the [roadmap](docs/roadmap.md).
## Reporting security vulnerabilities
-350
View File
@@ -1,350 +0,0 @@
# Roadmap para publicar DockerFlow como Open Source
Estado actual del proyecto: **v0.1.0** | ~1,774 lineas de codigo | 0 tests | 0 CI/CD | Sin licencia formal
---
## Fase 1 — Fundamentos legales y limpieza
> Sin esto, nadie puede usar tu codigo legalmente ni contribuir con confianza.
### 1.1 Crear archivo LICENSE
- [ ] Crear `LICENSE` en la raiz con el texto completo de MIT
- [ ] El README ya dice "MIT" al final, pero sin el archivo no tiene validez legal
- [ ] Opciones alternativas si cambias de opinion:
- **MIT** — maxima adopcion, cualquiera puede hacer lo que quiera
- **Apache 2.0** — como MIT pero protege contra demandas de patentes
- **GPL v3** — obliga a que los forks tambien sean open source
### 1.2 Auditar secretos en el historial de git
- [ ] Verificar que `.env` nunca fue commiteado (HECHO: confirmado limpio)
- [ ] Verificar que no hay tokens, passwords o claves hardcodeados en el codigo
- [ ] Buscar en el historial: `git log -p --all -S 'AUTH_TOKEN' -- '*.ts'`
- [ ] Buscar en el historial: `git log -p --all -S 'password' -- '*.ts'`
- [ ] Si se encuentra algo comprometido, considerar `git filter-branch` o `bfg` para limpiar
### 1.3 Eliminar archivos internos del repo publico
- [ ] Eliminar `tareas/completadas/` — son notas internas de desarrollo, no aportan al usuario final
- [ ] Eliminar `docker-project.md` — documento de planificacion interna
- [ ] Decidir sobre `PLAN-MULTI-HOST.md` — puede quedarse como roadmap publico o moverse a GitHub Issues/Projects
- [ ] Eliminar `.claude/settings.local.json` si contiene paths locales
- [ ] Actualizar `.gitignore` para excluir `tareas/` y documentos internos futuros
### 1.4 Limpiar configuracion local
- [ ] Verificar que `.dockerflow-positions.json` esta en `.gitignore` (esta)
- [ ] Verificar que `.env` esta en `.gitignore` (esta)
- [ ] Agregar a `.gitignore`: `PUBLICAR.md`, `tareas/`, `docker-project.md`
---
## Fase 2 — Documentacion para la comunidad
> La documentacion es la primera impresion. Un proyecto sin docs claras no recibe contribuciones.
### 2.1 README en ingles (idioma principal)
- [ ] Crear `README.md` en ingles como version principal
- [ ] Mover el README actual a `README.es.md` y linkear desde el principal
- [ ] Incluir en el README:
- [ ] **Hero section**: nombre, descripcion de una linea, badges (license, version, bun)
- [ ] **Screenshot/GIF** del dashboard funcionando (esto es CRITICO para adopcion)
- [ ] **Quick start** en 4 lineas o menos
- [ ] **Features** con iconos o emojis descriptivos
- [ ] **Configuration** (tabla de env vars)
- [ ] **Container actions** (start, stop, restart, rebuild, remove)
- [ ] **MCP integration** (esto es diferenciador, destacarlo)
- [ ] **Tech stack** (tabla limpia)
- [ ] **Contributing** link
- [ ] **License** badge + link
### 2.2 Captura de pantalla / GIF del dashboard
- [ ] Levantar el dashboard con containers de ejemplo
- [ ] Grabar un GIF de ~10 segundos mostrando:
- Vista general con servicios conectados
- Metricas en tiempo real (CPU/MEM)
- Acciones sobre containers
- [ ] Herramientas recomendadas: `peek` (Linux), `gifski`, o `Kap` (macOS)
- [ ] Guardar en `docs/assets/demo.gif` y referenciar desde README
- [ ] Alternativa: screenshot estatico como fallback
### 2.3 CONTRIBUTING.md
- [ ] Crear `CONTRIBUTING.md` con:
- [ ] Requisitos: Bun >= 1.0, Docker corriendo
- [ ] Setup del entorno de desarrollo (`bun install && bun run dev`)
- [ ] Estructura del proyecto (breve, linkear a README)
- [ ] Convenciones de codigo (TypeScript estricto, sin `any`, imports absolutos)
- [ ] Proceso de PRs: fork → branch → PR con descripcion
- [ ] Issues: como reportar bugs, como proponer features
- [ ] Commits: formato convencional (`feat:`, `fix:`, `docs:`)
### 2.4 CODE_OF_CONDUCT.md
- [ ] Adoptar Contributor Covenant v2.1 (estandar de la industria)
- [ ] Copiar de https://www.contributor-covenant.org/
- [ ] Personalizar email de contacto
### 2.5 CHANGELOG.md
- [ ] Crear `CHANGELOG.md` siguiendo formato Keep a Changelog
- [ ] Documentar retroactivamente las versiones existentes:
- v0.0.1 — Setup inicial, descubrimiento Docker
- v0.0.2 — WebSocket, metricas en tiempo real
- v0.0.3 — Nodos visuales, layout React Flow
- v0.0.4 — Filtro de proyectos, autenticacion
- v0.0.5 — MCP server, logs, acciones de containers, polish
- [ ] De aqui en adelante, actualizar con cada release
---
## Fase 3 — Calidad de codigo
> Da confianza a los contribuidores y previene regresiones.
### 3.1 Configurar linter + formatter
- [ ] Instalar Biome (rapido, todo-en-uno, compatible con Bun):
```bash
bun add -d @biomejs/biome
bunx biome init
```
- [ ] Configurar reglas en `biome.json`:
- Formatter: tabs/spaces, ancho de linea
- Linter: reglas recomendadas de TypeScript + React
- Organizar imports automaticamente
- [ ] Agregar scripts a `package.json`:
```json
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
"format": "biome format --write src/"
```
- [ ] Ejecutar `bun run lint:fix` una vez para normalizar todo el codigo
- [ ] Commit con mensaje: `chore: configure biome linter and format codebase`
### 3.2 Agregar tests minimos
- [ ] Usar `bun:test` (ya viene con Bun, zero config)
- [ ] Tests prioritarios:
- [ ] `src/server/__tests__/docker.test.ts` — parseo de conexiones, deteccion de tipos
- [ ] `src/server/__tests__/watcher.test.ts` — polling de stats, eventos Docker
- [ ] `src/shared/__tests__/types.test.ts` — validacion de tipos con Zod si aplica
- [ ] `src/client/engine/__tests__/layout.test.ts` — calculo de layout basico
- [ ] Agregar script: `"test": "bun test"`
- [ ] Meta inicial: cubrir la logica de negocio del server (docker.ts, watcher.ts)
- [ ] No hace falta 100% coverage, pero si que lo critico este cubierto
### 3.3 Type checking estricto
- [ ] Verificar que `bun run build` no genera errores de TypeScript
- [ ] Agregar script: `"typecheck": "tsc --noEmit"`
- [ ] Corregir cualquier error que aparezca
---
## Fase 4 — CI/CD con GitHub Actions
> Automatiza la verificacion. Cada PR debe pasar lint + tests + build.
### 4.1 Workflow de CI basico
- [ ] Crear `.github/workflows/ci.yml`:
```yaml
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install
- run: bun run lint
- run: bun run typecheck
- run: bun run test
- run: bun run build
```
- [ ] Verificar que pasa en la primera ejecucion
- [ ] Agregar badge de CI al README
### 4.2 (Opcional) Release automatizado
- [ ] Configurar workflow de release al pushear tags:
```yaml
on:
push:
tags: ['v*']
```
- [ ] Generar GitHub Release con changelog automatico
- [ ] Considerar `changesets` o `release-please` para automatizar versiones
---
## Fase 5 — Distribucion y Docker
> Facilitar que la gente lo pruebe sin clonar el repo.
### 5.1 Dockerfile
- [ ] Crear `Dockerfile` multi-stage:
```dockerfile
# Build
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
# Run
FROM oven/bun:1-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/server ./src/server
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
EXPOSE 9470
CMD ["bun", "run", "start"]
```
- [ ] Crear `.dockerignore` (node_modules, .git, tareas, etc.)
- [ ] Testear localmente: `docker build -t dockerflow . && docker run -v /var/run/docker.sock:/var/run/docker.sock -p 9470:9470 dockerflow`
### 5.2 docker-compose.yml de ejemplo
- [ ] Crear `docker-compose.yml` para que los usuarios levanten con un comando:
```yaml
services:
dockerflow:
image: ghcr.io/rgjorge/dockerflow:latest
ports:
- "9470:9470"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- AUTH_TOKEN=${AUTH_TOKEN:-}
```
### 5.3 Publicar imagen en GitHub Container Registry
- [ ] Crear workflow `.github/workflows/docker.yml` para build + push automatico
- [ ] Publicar en `ghcr.io/rgjorge/dockerflow`
- [ ] Tags: `latest`, `v0.1.0`, `v0.1`, `v0`
- [ ] Documentar en README el one-liner de Docker
---
## Fase 6 — Preparacion del repositorio
> Detalles finales antes de hacer el repo publico.
### 6.1 GitHub repo settings
- [ ] Descripcion del repo: "Real-time Docker architecture visualization dashboard"
- [ ] Topics: `docker`, `monitoring`, `dashboard`, `visualization`, `devtools`, `bun`, `react`, `mcp`
- [ ] Website: URL del repo o demo si la hay
- [ ] Habilitar Issues
- [ ] Habilitar Discussions (opcional, bueno para comunidad)
- [ ] Configurar branch protection en `main`:
- Require PR reviews
- Require status checks (CI)
- No force push
### 6.2 Issue templates
- [ ] Crear `.github/ISSUE_TEMPLATE/bug_report.md`
- [ ] Crear `.github/ISSUE_TEMPLATE/feature_request.md`
- [ ] Crear `.github/PULL_REQUEST_TEMPLATE.md`
### 6.3 Issues iniciales como roadmap publico
- [ ] Crear issues con label `good first issue` para atraer contribuidores:
- "Add dark/light theme toggle"
- "Support podman as alternative to Docker"
- "Add container restart/stop actions from UI"
- "Export dashboard as PNG/SVG"
- [ ] Crear issues con label `enhancement` del roadmap:
- "Multi-host Docker monitoring (TCP/TLS)"
- "Container health check visualization"
- "Custom node colors/icons per service type"
- [ ] Convertir `PLAN-MULTI-HOST.md` en un issue detallado
### 6.4 Crear GitHub Release v0.1.0
- [ ] Tag: `v0.1.0`
- [ ] Titulo: "DockerFlow v0.1.0 — Initial Public Release"
- [ ] Body: features principales, screenshot, instrucciones de instalacion
- [ ] Esto reemplaza los tags internos v0.0.x
---
## Fase 7 — Lanzamiento y difusion
> El codigo listo no sirve si nadie lo ve.
### 7.1 Preparar assets de lanzamiento
- [ ] GIF/screenshot de alta calidad del dashboard
- [ ] Descripcion corta (1 parrafo) para copiar/pegar en redes
- [ ] Lista de features destacadas (3-5 bullet points)
### 7.2 Publicar en comunidades
- [ ] **Reddit**: r/selfhosted, r/docker, r/devops, r/opensource
- Titulo sugerido: "I built a real-time Docker architecture visualizer with live metrics"
- Incluir GIF y link al repo
- [ ] **Hacker News**: Show HN post
- Titulo: "Show HN: ContainerFlow Real-time Docker architecture visualization"
- [ ] **Twitter/X**: Thread con GIF y features
- [ ] **Dev.to**: Articulo sobre como se construyo
- [ ] **Discord**: Servidores de Docker, Bun, React
- [ ] **Product Hunt**: Si quieres traccion con publico mas amplio
### 7.3 Post-lanzamiento
- [ ] Monitorear issues y PRs las primeras 48-72 horas
- [ ] Responder rapidamente a las primeras contribuciones (esto define la cultura)
- [ ] Agregar un "Star History" badge al README despues de ganar traccion
- [ ] Considerar crear un sitio web/landing page si hay interes
---
## Orden de ejecucion recomendado
| Prioridad | Tarea | Esfuerzo | Impacto |
|-----------|-------|----------|---------|
| 1 | Licencia MIT | 5 min | Critico |
| 2 | Limpiar archivos internos | 10 min | Alto |
| 3 | Screenshot/GIF del dashboard | 20 min | Critico |
| 4 | README en ingles | 1-2 hrs | Critico |
| 5 | CONTRIBUTING + CODE_OF_CONDUCT | 30 min | Alto |
| 6 | Biome linter + format | 30 min | Medio |
| 7 | Tests minimos (bun:test) | 2-3 hrs | Alto |
| 8 | GitHub Actions CI | 30 min | Alto |
| 9 | Dockerfile + compose | 1 hr | Alto |
| 10 | CHANGELOG retroactivo | 30 min | Medio |
| 11 | Issue templates + good first issues | 30 min | Medio |
| 12 | GitHub Release v0.1.0 | 15 min | Alto |
| 13 | Difusion en comunidades | 1-2 hrs | Critico |
**Tiempo total estimado: 8-12 horas de trabajo**
---
## Checklist final antes de hacer publico
- [ ] `LICENSE` existe y es MIT
- [ ] No hay secretos en el codigo ni en el historial de git
- [ ] No hay archivos internos/personales en el repo
- [ ] README en ingles con screenshot/GIF
- [ ] CONTRIBUTING.md existe
- [ ] Al menos 1 test pasa
- [ ] `bun run build` funciona sin errores
- [ ] CI pasa en verde
- [ ] GitHub Release creada
- [ ] Listo para compartir el link
+446
View File
@@ -0,0 +1,446 @@
# ContainerFlow
[![CI](https://github.com/RGJorge/ContainerFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/RGJorge/ContainerFlow/actions/workflows/ci.yml)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Release](https://img.shields.io/github/v/tag/RGJorge/containerflow?label=version&color=green)](https://github.com/RGJorge/containerflow/tags)
![Docker Required](https://img.shields.io/badge/Docker-required-blue?logo=docker)
![Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun)
[![Last Commit](https://img.shields.io/github/last-commit/RGJorge/containerflow)](https://github.com/RGJorge/containerflow/commits/main)
**Léelo en otros idiomas**: [English](./README.md)
Real-time Docker architecture visualizer. Displays services, connections and metrics from all your Docker Compose projects in an interactive dashboard.
![ContainerFlow demo](docs/demo.gif)
> *"Build what docker doesn't have the vision to build, and that Railway won't bring to local, without having to become either."*
>
> — u/dashingsauce, [on the launch thread](https://www.reddit.com/r/coolgithubprojects/comments/1ta8kak/comment/olecbxl/)
## Por qué ContainerFlow
Las herramientas existentes te muestran números. ContainerFlow además:
- **Visualiza arquitectura** — grafo interactivo con conexiones (app→db, app→cache, proxy→app) detectadas automáticamente, no solo una lista plana
- **Detecta config sub-óptima** — banners cuando un container corre sin límite de memoria, sin límite de CPU, o sin `restart: unless-stopped`. Te enseña buenas prácticas mientras lo usas
- **Mide memoria real** — resta page cache (active + inactive), no solo inactive como `docker stats`. Tu DB con buffers Postgres no muestra 98% falso
- **Multi-usuario seguro** — variable `ALLOWED_PATHS` para servidores compartidos: ves todo, solo tocas lo tuyo
- **80 MB de RAM, startup en 500ms** — Bun + Hono. Pesa una fracción de Portainer y arranca antes que Grafana
## Quick start
No necesitas clonar el repo. Descarga la imagen pre-built desde GHCR:
```bash
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/.env.example
cp .env.example .env
docker compose up -d
```
Abre `http://localhost:9470`. Listo.
### Build desde source
Si clonaste el repo y querés buildear local (porque modificaste el código):
```bash
git clone https://github.com/RGJorge/containerflow.git
cd containerflow
cp .env.example .env
# En .env, descomenta: COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
docker compose up -d --build
```
Para desarrollo nativo (hot reload, sin Docker): `bun install && bun run dev`.
## Documentación
- **[docs/docker-guide.md](./docs/docker-guide.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.
- **[docs/roadmap.md](./docs/roadmap.md)** — Roadmap del proyecto: qué está completo, qué viene, qué se descartó y por qué.
## Requisitos
- [Bun](https://bun.sh) >= 1.0
- Docker corriendo con acceso al socket (`/var/run/docker.sock`)
## Instalacion
```bash
git clone https://github.com/RGJorge/containerflow.git
cd containerflow
bun install
```
## Configuracion
Copiar el archivo de ejemplo y editar:
```bash
cp .env.example .env
```
Variables disponibles:
| Variable | Default | Descripcion |
|---|---|---|
| `PORT` | `9470` | Puerto del servidor |
| `AUTH_TOKEN` | _(vacio)_ | Token de autenticacion. Vacio = sin auth, solo localhost. Con valor = auth activado, acceso remoto |
| `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
### Desarrollo (hot reload)
```bash
bun run dev
```
Abre `http://localhost:9420` (Vite dev con hot reload, proxea API al backend en puerto 9470).
### Produccion (Docker)
```bash
docker compose up -d
```
Abre `http://localhost:9470`.
### Produccion (manual)
```bash
bun run build
bun run start
```
Abre `http://localhost:9470`.
### Modos de visualizacion
```bash
# Ver TODOS los containers Docker
bun run start -- --all
# Ver solo proyectos especificos
bun run start -- --projects=mi-proyecto,otro-proyecto
# Auto-detectar desde el directorio actual
bun run start
```
## Funcionalidades
- **Descubrimiento automatico** — detecta servicios via Docker socket, agrupa por proyecto o compose file
- **Conexiones inteligentes** — detecta relaciones app→database, app→cache, proxy→app, worker→broker
- **Metricas en tiempo real** — CPU y memoria por container, actualizado cada 3 segundos
- **Eventos Docker** — flash visual cuando un container inicia, para o reinicia
- **Panel de detalle** — click en un container para ver info, stats, variables de entorno y configuracion en tabs separados
- **Logs de containers** — logs en tiempo real con scroll automatico, filtro por stream (stdout/stderr) y opcion de copiar
- **Acciones sobre containers** — start, stop, restart, rebuild y remove directamente desde el panel
- **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
- **Recomendaciones de configuracion Docker** — banners de aviso en el DetailPanel cuando un container tiene config sub-optima: sin limite de memoria, sin limite de CPU, sin restart policy (`unless-stopped` recomendado). Ayuda al usuario a adoptar mejores practicas de Docker sin tener que recordarlas
- **Volumenes y mounts** — DetailPanel lista cada mount del container: tipo (bind / volume / tmpfs), source en el host, destination en el container, modo rw/ro. Util para debugging ("donde estan mis datos?", "es read-only?", "es persistente?")
- **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)
- **Grupos visuales** — recuadros por proyecto/compose con titulo, archivo compose y conteo de containers
- **Menu contextual** — click derecho en un nodo para acciones rapidas
- **Pagina de monitoring** — historial de CPU/RAM por servicio (1h, 6h, 24h, 7d) persistido en SQLite, gráficas con sparkline, expand por contenedor, filtros por proyecto/servicio y feed de eventos Docker
- **Notificaciones Discord** — webhook configurable que avisa cambios de estado, alertas de recursos, acciones manuales y errores
- **Umbrales por contenedor** — overrides personalizados de CPU/MEM (con fallback a umbrales globales) y toggle de notificaciones por servicio
- **Pagina de settings** — configuracion de la aplicacion (auth, Discord, hosts Docker)
## Mejores prácticas
ContainerFlow no solo monitorea: detecta configuración sub-óptima de Docker y la marca con un banner ámbar en el DetailPanel del container afectado. La idea es ayudarte a adoptar buenas prácticas sin tener que recordarlas tú.
### Recomendaciones activas (warnings automáticos)
| Detección | Por qué importa | Cómo se ve en ContainerFlow |
|---|---|---|
| **Sin `memory_limit`** | Un container sin tope de RAM puede acaparar toda la memoria del host y tumbar a los demás (incluido el daemon). El kernel hace OOM kill aleatorio bajo presión. | Banner: "Sin límite de memoria configurado en Docker" |
| **Sin `cpu_quota`** | Similar al de memoria — un container puede saturar todos los núcleos. En multi-tenant esto es crítico, en single-tenant degrada la responsividad del host. | Banner: "Sin límite de CPU configurado en Docker" |
| **`restart: no` o vacío** | Si el proceso muere, el container queda muerto. En producción casi siempre quieres `unless-stopped` (reinicia si crashea, **NO** si lo paraste manualmente). | Banner: "Restart policy: none — el contenedor no se reiniciará automáticamente si se detiene" |
### Configuración recomendada (template)
```yaml
# docker-compose.yml — buenas prácticas
services:
mi-app:
image: mi-app:latest
restart: unless-stopped # ← reinicia tras crashes, respeta stops manuales
deploy:
resources:
limits:
cpus: "0.5" # ← máximo medio núcleo
memory: 256M # ← tope absoluto, evita OOM del host
healthcheck: # ← detecta apps "vivas pero rotas"
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
```
### Por qué ContainerFlow hace esto
La mayoría de tutoriales de Docker no mencionan estas configuraciones porque "funciona sin ellas". Pero en producción son la diferencia entre:
- **Sin límites**: un memory leak en un servicio tumba a TODO el servidor
- **Con límites**: el container se mata a sí mismo, el resto sigue vivo, las restart policies lo reviven
ContainerFlow te lo recuerda visualmente cada vez que abres el DetailPanel — no es spam, es contexto educativo solo donde aplica.
### En roadmap
- **Healthcheck recommendations**: detectar containers sin `HEALTHCHECK` y sugerir uno contextual según la imagen (postgres → `pg_isready`, redis → `redis-cli ping`, http app → `curl /health`, etc.)
- **Mounts no persistentes**: warning cuando una DB usa `tmpfs` o bind a directorio efímero
- **Versión latest**: warning cuando un container usa `image:latest` (no reproducible)
## Monitoreo e historial
ContainerFlow guarda un historial de métricas y notifica eventos importantes a Discord.
### Historial de métricas
- **Persistencia** — stats de CPU y memoria se almacenan en SQLite (`.dockerflow-stats.db`) cada vez que se hace polling de Docker (~3s)
- **Rangos** — `1h`, `6h`, `24h`, `7d` con buckets agregados (30s / 60s / 5min / 30min) para rendimiento
- **Retención** — auto-limpieza horaria descarta datos con más de 7 días y compacta la base con `VACUUM`
- **API** —
- `GET /api/stats/history?range=1h` — historial de todos los servicios
- `GET /api/stats/history/:uid?range=1h` — historial de un servicio específico
- **UI** — la página de monitoring (`MonitoringPage.tsx`) muestra una tarjeta por servicio con sparkline de CPU y MEM, valor actual, promedio y línea de umbral. Cada tarjeta puede expandirse para ver una gráfica más grande, y se filtra por proyecto y/o servicio (los filtros son acumulativos).
### Notificaciones Discord
Configurables desde **Settings → Discord Notifications**. Requiere un webhook URL que empiece por `https://discord.com/api/webhooks/`.
Eventos soportados (cada uno se puede activar/desactivar):
| Evento | Cuándo dispara |
|---|---|
| **Container State Changes** | `start`, `stop`, `die` (crash), `restart`, `health_status`. Los eventos `stop`/`die` se debouncean 15s para detectar reinicios y enviar un solo mensaje "Container Restarted" en lugar de stop+start separados |
| **Resource Alerts** | CPU o memoria de un contenedor supera el umbral (global o por-container) |
| **UI Actions** | Acción manual disparada desde el panel: start/stop/restart/rebuild/remove |
| **Action Errors** | Falló una acción ejecutada desde la UI (incluye el mensaje de error) |
Mecanismos anti-spam:
- **Cooldown global** — minutos mínimos entre alertas del mismo tipo+servicio (default `5 min`, configurable `1-60`)
- **Down reminder** — si un contenedor sigue caído, reenvía un recordatorio "Container Still Down" cada N minutos (default `5 min`)
- **Cola con rate limit** — 500ms mínimo entre webhooks; si Discord responde `429`, respeta el `Retry-After` y reintenta
- **Debounce de stop/die** — buffer de 15s para colapsar restart/redeploy en una sola notificación
Umbrales:
- **Globales** — CPU% y MEM% en Settings (default 50% / 60%)
- **Por contenedor** — desde la página de monitoring, click en el ícono ⚙️ de un servicio para abrir el panel inline. Permite:
- Activar/desactivar notificaciones para ese contenedor
- Override del umbral de CPU (drag del slider)
- Override del umbral de memoria
- Reset al valor global (X)
- Los overrides se persisten en `.dockerflow-container-settings.json` y se auto-guardan con debounce de 400ms
Botón **Test** en Settings envía un embed de prueba al webhook para verificar que funciona antes de habilitarlo.
## Tests
El proyecto usa [Vitest](https://vitest.dev/) para tests unitarios.
```bash
# Correr todos los tests
bun run test
# Correr en modo watch (re-ejecuta al guardar)
bun run test:watch
# Verificar tipos TypeScript
bun run typecheck
```
Los tests cubren:
- **Logica de processing** (`src/client/hooks/processing.test.ts`) — sincronizacion de estados cuando se ejecutan acciones sobre containers (start/stop/restart), incluyendo manejo de estados crashed/dead, timeouts y minDuration
- **Deteccion de conexiones** (`src/server/docker.test.ts`) — descubrimiento de relaciones entre servicios por red compartida, clasificacion de servicios (infra, proxy, worker) y deduplicacion
## CI
GitHub Actions ejecuta automaticamente en cada push/PR a `main`:
1. Typecheck (errores de tipos)
2. Tests (Vitest)
3. Build (produccion)
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 |
|---|---|
| Runtime | Bun |
| Server | Hono |
| Frontend | React 19 + Vite 6 |
| Grafos | @xyflow/react 12 |
| Estilos | Tailwind CSS 4 |
| Iconos | Lucide React |
| Docker API | dockerode |
| Comunicacion | WebSocket nativo |
| Tests | Vitest |
## Estructura
```
src/
server/
index.ts — servidor Hono + WebSocket + CLI args + REST API
docker.ts — descubrimiento de servicios y conexiones
watcher.ts — polling de stats + stream de eventos Docker
stats-db.ts — SQLite de historial de stats (insert, query por rango, cleanup 7d)
discord.ts — webhooks Discord (state changes, resource alerts, cooldown, debounce, queue)
container-settings.ts — overrides por contenedor (umbrales y toggle de notificaciones)
client/
App.tsx — dashboard principal + login screen
main.tsx — entry point React
index.css — Tailwind + animaciones custom
nodes/
ServiceNode.tsx — nodo visual por container
GroupNode.tsx — header de grupo (proyecto/compose)
hooks/
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 (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
pages/
MonitoringPage.tsx — historial de CPU/RAM, eventos Docker y umbrales por contenedor
SettingsPage.tsx — configuracion (auth, Discord webhook, eventos, umbrales globales)
shared/
types.ts — tipos compartidos server/client
```
## Comunidad y contribuciones
ContainerFlow está en desarrollo activo (`v0.x`).
- 🐛 **Bug?** Abre un [issue](https://github.com/RGJorge/containerflow/issues/new?template=bug_report.md)
- 💡 **Idea?** Abre un [feature request](https://github.com/RGJorge/containerflow/issues/new?template=feature_request.md)
- 🔒 **Vulnerabilidad de seguridad?** Reporta privadamente — ver [SECURITY.md](SECURITY.md)
- 📜 **Code of Conduct** — ver [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
- 🛠 **Quiero contribuir código** — ver [CONTRIBUTING.md](CONTRIBUTING.md). Actualmente solo aceptamos issues; PRs se abrirán cuando el proyecto madure.
Si ContainerFlow te resulta útil, una ⭐ en GitHub ayuda a la visibilidad del proyecto.
## Licencia
Copyright (C) 2026 Jorge Gonzalez D. (RGJorge)
Este proyecto esta licenciado bajo **GNU Affero General Public License v3.0** (AGPL-3.0). Ver el archivo [LICENSE](LICENSE) para los terminos completos.
Para uso comercial con codigo cerrado, contactar para una licencia comercial: alteonx.servicios@gmail.com
+255 -194
View File
@@ -1,26 +1,70 @@
# ContainerFlow
[![CI](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml/badge.svg)](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml)
[![CI](https://github.com/RGJorge/ContainerFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/RGJorge/ContainerFlow/actions/workflows/ci.yml)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
![Version](https://img.shields.io/badge/version-v0.1.0-green)
[![Release](https://img.shields.io/github/v/tag/RGJorge/containerflow?label=version&color=green)](https://github.com/RGJorge/containerflow/tags)
![Docker Required](https://img.shields.io/badge/Docker-required-blue?logo=docker)
![Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun)
[![Last Commit](https://img.shields.io/github/last-commit/RGJorge/containerflow)](https://github.com/RGJorge/containerflow/commits/main)
**Read this in other languages**: [Español](./README.es.md)
Real-time Docker architecture visualizer. Displays services, connections and metrics from all your Docker Compose projects in an interactive dashboard.
![ContainerFlow demo](docs/demo.gif)
## Documentación
> *"Build what docker doesn't have the vision to build, and that Railway won't bring to local, without having to become either."*
>
> — u/dashingsauce, [on the launch thread](https://www.reddit.com/r/coolgithubprojects/comments/1ta8kak/comment/olecbxl/)
- **[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.
## Why ContainerFlow
## Requisitos
Existing tools show you numbers. ContainerFlow also:
- **Visualizes architecture** — interactive graph with connections (app→db, app→cache, proxy→app) auto-detected, not just a flat list
- **Detects sub-optimal config** — banners when a container runs without memory limits, CPU limits, or `restart: unless-stopped`. Teaches good practices while you use it
- **Measures real memory** — subtracts full page cache (active + inactive), not just inactive like `docker stats`. Your Postgres DB with hot buffers no longer reports a false 98%
- **Multi-tenant via path scoping** — `ALLOWED_PATHS` env var for shared servers: see everything, only touch what's yours
- **80 MB RAM, ~500ms startup** — Bun + Hono. A fraction of Portainer's footprint, starts faster than Grafana
## Quick start
No clone needed. Pull the prebuilt image from GHCR:
```bash
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/.env.example
cp .env.example .env
docker compose up -d
```
Open `http://localhost:9470`. Done.
### Build from source instead
If you cloned the repo and want to build locally (e.g. you modified the code):
```bash
git clone https://github.com/RGJorge/containerflow.git
cd containerflow
cp .env.example .env
# In .env, uncomment: COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
docker compose up -d --build
```
For native development (hot reload, no Docker): `bun install && bun run dev`.
## Documentation
- **[docs/docker-guide.md](./docs/docker-guide.md)** — Docker quick guide for using ContainerFlow: what each action does (Start, Stop, Restart, Recreate, Rebuild, Remove, Exec), restart policies, resource limits, volumes, healthchecks and FAQs. (Currently in Spanish; English translation in progress.)
- **[docs/roadmap.md](./docs/roadmap.md)** — Project roadmap: what's done, what's coming, what was discarded and why.
## Requirements
- [Bun](https://bun.sh) >= 1.0
- Docker corriendo con acceso al socket (`/var/run/docker.sock`)
- Docker running with socket access (`/var/run/docker.sock`)
## Instalacion
## Installation
```bash
git clone https://github.com/RGJorge/containerflow.git
@@ -28,115 +72,115 @@ cd containerflow
bun install
```
## Configuracion
## Configuration
Copiar el archivo de ejemplo y editar:
Copy the example file and edit:
```bash
cp .env.example .env
```
Variables disponibles:
Available variables:
| Variable | Default | Descripcion |
| Variable | Default | Description |
|---|---|---|
| `PORT` | `9470` | Puerto del servidor |
| `AUTH_TOKEN` | _(vacio)_ | Token de autenticacion. Vacio = sin auth, solo localhost. Con valor = auth activado, acceso remoto |
| `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. |
| `PORT` | `9470` | Server port |
| `AUTH_TOKEN` | _(empty)_ | Auth token. Empty = no auth, localhost only. Set = auth enabled, remote access allowed |
| `DATA_DIR` | `./data` | Persistence directory: stats history (`.dockerflow-stats.db`), Discord config (`.dockerflow-discord.json`), per-container overrides (`.dockerflow-container-settings.json`), node positions and env file overrides. Auto-created on startup. In Docker, mounted at `/app/data` via the `containerflow-data` volume. |
| `HOST_PROJECTS_DIR` | _(empty)_ | Additional path to mount so `rebuild`/`remove` can read compose files outside the defaults (`/home`, `/opt`, `/srv`, `/root`). Only needed for non-standard paths (e.g. `/data/apps`). |
| `ALLOWED_PATHS` | _(empty)_ | **Empty = everything actionable** (permissive mode). With values = `:`-separated list of prefixes; only containers whose compose file lives under one of these paths can execute actions, the rest appear with a lock icon. See [Security](#security) section. |
| `ALLOW_NON_COMPOSE` | `false` | **Only applies when `ALLOWED_PATHS` is active.** If `ALLOWED_PATHS` is empty, this has no effect. When applicable: `false` blocks actions on non-compose containers (started with `docker run` directly); `true` allows them. |
## Uso
## Usage
### Desarrollo (hot reload)
### Development (hot reload)
```bash
bun run dev
```
Abre `http://localhost:9420` (Vite dev con hot reload, proxea API al backend en puerto 9470).
Opens `http://localhost:9420` (Vite dev with hot reload, proxies API to the backend on port 9470).
### Produccion (Docker)
### Production (Docker)
```bash
docker compose up -d
```
Abre `http://localhost:9470`.
Opens `http://localhost:9470`.
### Produccion (manual)
### Production (manual)
```bash
bun run build
bun run start
```
Abre `http://localhost:9470`.
Opens `http://localhost:9470`.
### Modos de visualizacion
### Visualization modes
```bash
# Ver TODOS los containers Docker
# View ALL Docker containers
bun run start -- --all
# Ver solo proyectos especificos
bun run start -- --projects=mi-proyecto,otro-proyecto
# View only specific projects
bun run start -- --projects=my-project,another-project
# Auto-detectar desde el directorio actual
# Auto-detect from current directory
bun run start
```
## Funcionalidades
## Features
- **Descubrimiento automatico** — detecta servicios via Docker socket, agrupa por proyecto o compose file
- **Conexiones inteligentes** — detecta relaciones app→database, app→cache, proxy→app, worker→broker
- **Metricas en tiempo real** — CPU y memoria por container, actualizado cada 3 segundos
- **Eventos Docker** — flash visual cuando un container inicia, para o reinicia
- **Panel de detalle** — click en un container para ver info, stats, variables de entorno y configuracion en tabs separados
- **Logs de containers** — logs en tiempo real con scroll automatico, filtro por stream (stdout/stderr) y opcion de copiar
- **Acciones sobre containers** — start, stop, restart, rebuild y remove directamente desde el panel
- **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
- **Recomendaciones de configuracion Docker** — banners de aviso en el DetailPanel cuando un container tiene config sub-optima: sin limite de memoria, sin limite de CPU, sin restart policy (`unless-stopped` recomendado). Ayuda al usuario a adoptar mejores practicas de Docker sin tener que recordarlas
- **Volumenes y mounts** — DetailPanel lista cada mount del container: tipo (bind / volume / tmpfs), source en el host, destination en el container, modo rw/ro. Util para debugging ("donde estan mis datos?", "es read-only?", "es persistente?")
- **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)
- **Grupos visuales** — recuadros por proyecto/compose con titulo, archivo compose y conteo de containers
- **Menu contextual** — click derecho en un nodo para acciones rapidas
- **Pagina de monitoring** — historial de CPU/RAM por servicio (1h, 6h, 24h, 7d) persistido en SQLite, gráficas con sparkline, expand por contenedor, filtros por proyecto/servicio y feed de eventos Docker
- **Notificaciones Discord** — webhook configurable que avisa cambios de estado, alertas de recursos, acciones manuales y errores
- **Umbrales por contenedor** — overrides personalizados de CPU/MEM (con fallback a umbrales globales) y toggle de notificaciones por servicio
- **Pagina de settings** — configuracion de la aplicacion (auth, Discord, hosts Docker)
- **Automatic discovery** — detects services via Docker socket, groups by project or compose file
- **Smart connections** — detects app→database, app→cache, proxy→app, worker→broker relationships
- **Real-time metrics** — CPU and memory per container, refreshed every 3 seconds
- **Docker events** — visual flash when a container starts, stops or restarts
- **Detail panel** — click a container to see info, stats, env vars and config in separate tabs
- **Container logs** — real-time logs with auto-scroll, stream filter (stdout/stderr) and copy option
- **Container actions** — start, stop, restart, rebuild, recreate and remove directly from the panel
- **Execute commands** — inline terminal (`docker exec`) from the DetailPanel with output, no SSH or external terminal needed
- **Error toasts** — when an action fails (broken rebuild, exec with non-zero exit code, etc.) a top-right toast shows the full error, copyable to clipboard
- **Path-based access control** — `ALLOWED_PATHS` env var lets you restrict actions to containers whose compose file lives under specific paths. Ideal for shared servers: see everything, only touch what's yours. Containers outside the paths appear with a lock icon
- **Docker config recommendations** — warning banners in the DetailPanel when a container has sub-optimal config: no memory limit, no CPU limit, no restart policy (`unless-stopped` recommended). Helps users adopt good Docker practices without having to remember them
- **Volumes and mounts** — DetailPanel lists each mount on the container: type (bind / volume / tmpfs), source on the host, destination in the container, rw/ro mode. Useful for debugging ("where's my data?", "is this read-only?", "is it persistent?")
- **Project filter** — dropdown to show/hide projects, persists across sessions
- **Authentication** — login screen with AUTH_TOKEN for secure remote access
- **Connection legend** — color-coded by type: Database (blue), Cache (red), Broker (orange), Proxy (green)
- **Visual groups** — boxes per project/compose with title, compose file and container count
- **Context menu** — right-click on a node for quick actions
- **Monitoring page** — CPU/RAM history per service (1h, 6h, 24h, 7d) persisted in SQLite, sparkline charts, expand per container, filters by project/service, and a Docker events feed
- **Discord notifications** — configurable webhook for state changes, resource alerts, manual actions and errors
- **Per-container thresholds** — custom CPU/MEM overrides (with fallback to global thresholds) and notification toggle per service
- **Settings page** — application configuration (auth, Discord, Docker hosts)
## Mejores prácticas
## Best practices
ContainerFlow no solo monitorea: detecta configuración sub-óptima de Docker y la marca con un banner ámbar en el DetailPanel del container afectado. La idea es ayudarte a adoptar buenas prácticas sin tener que recordarlas tú.
ContainerFlow doesn't just monitor: it detects sub-optimal Docker configuration and flags it with an amber banner in the affected container's DetailPanel. The idea is to help you adopt good practices without having to remember them.
### Recomendaciones activas (warnings automáticos)
### Active recommendations (automatic warnings)
| Detección | Por qué importa | Cómo se ve en ContainerFlow |
| Detection | Why it matters | How it shows in ContainerFlow |
|---|---|---|
| **Sin `memory_limit`** | Un container sin tope de RAM puede acaparar toda la memoria del host y tumbar a los demás (incluido el daemon). El kernel hace OOM kill aleatorio bajo presión. | Banner: "Sin límite de memoria configurado en Docker" |
| **Sin `cpu_quota`** | Similar al de memoriaun container puede saturar todos los núcleos. En multi-tenant esto es crítico, en single-tenant degrada la responsividad del host. | Banner: "Sin límite de CPU configurado en Docker" |
| **`restart: no` o vacío** | Si el proceso muere, el container queda muerto. En producción casi siempre quieres `unless-stopped` (reinicia si crashea, **NO** si lo paraste manualmente). | Banner: "Restart policy: none — el contenedor no se reiniciará automáticamente si se detiene" |
| **No `memory_limit`** | A container without a RAM cap can hog all host memory and take down everything else (including the daemon). The kernel does random OOM kills under pressure. | Banner: "No memory limit configured in Docker" |
| **No `cpu_quota`** | Similar to memorya container can saturate all cores. Critical in multi-tenant, degrades host responsiveness in single-tenant. | Banner: "No CPU limit configured in Docker" |
| **`restart: no` or empty** | If the process dies, the container stays dead. In production you almost always want `unless-stopped` (restarts on crash, does **NOT** if you stopped it manually). | Banner: "Restart policy: none — the container will not restart automatically if stopped" |
### Configuración recomendada (template)
### Recommended config (template)
```yaml
# docker-compose.yml — buenas prácticas
# docker-compose.yml — best practices
services:
mi-app:
image: mi-app:latest
restart: unless-stopped # ← reinicia tras crashes, respeta stops manuales
my-app:
image: my-app:latest
restart: unless-stopped # ← restarts on crash, respects manual stops
deploy:
resources:
limits:
cpus: "0.5" # ← máximo medio núcleo
memory: 256M # ← tope absoluto, evita OOM del host
healthcheck: # ← detecta apps "vivas pero rotas"
cpus: "0.5" # ← maximum half a core
memory: 256M # ← absolute cap, prevents host OOM
healthcheck: # ← detects "alive but broken" apps
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
@@ -144,117 +188,118 @@ services:
start_period: 30s
```
### Por qué ContainerFlow hace esto
### Why ContainerFlow does this
La mayoría de tutoriales de Docker no mencionan estas configuraciones porque "funciona sin ellas". Pero en producción son la diferencia entre:
Most Docker tutorials don't mention these settings because "it works without them". But in production they're the difference between:
- **Sin límites**: un memory leak en un servicio tumba a TODO el servidor
- **Con límites**: el container se mata a sí mismo, el resto sigue vivo, las restart policies lo reviven
- **No limits**: a memory leak in one service takes down the ENTIRE server
- **With limits**: the container kills itself, the rest stays alive, restart policies revive it
ContainerFlow te lo recuerda visualmente cada vez que abres el DetailPanel — no es spam, es contexto educativo solo donde aplica.
ContainerFlow reminds you visually each time you open the DetailPanel — not spam, just educational context where it applies.
### En roadmap
### On the roadmap
- **Healthcheck recommendations**: detectar containers sin `HEALTHCHECK` y sugerir uno contextual según la imagen (postgres → `pg_isready`, redis → `redis-cli ping`, http app → `curl /health`, etc.)
- **Mounts no persistentes**: warning cuando una DB usa `tmpfs` o bind a directorio efímero
- **Versión latest**: warning cuando un container usa `image:latest` (no reproducible)
- **Healthcheck recommendations**: detect containers without `HEALTHCHECK` and suggest a contextual one based on the image (postgres → `pg_isready`, redis → `redis-cli ping`, http app → `curl /health`, etc.)
- **Non-persistent mounts**: warning when a DB uses `tmpfs` or binds to an ephemeral directory
- **`:latest` tag**: warning when a container uses `image:latest` (not reproducible)
## Monitoreo e historial
## Monitoring and history
ContainerFlow guarda un historial de métricas y notifica eventos importantes a Discord.
ContainerFlow keeps a metrics history and notifies important events to Discord.
### Historial de métricas
### Metrics history
- **Persistencia** — stats de CPU y memoria se almacenan en SQLite (`.dockerflow-stats.db`) cada vez que se hace polling de Docker (~3s)
- **Rangos** — `1h`, `6h`, `24h`, `7d` con buckets agregados (30s / 60s / 5min / 30min) para rendimiento
- **Retención** — auto-limpieza horaria descarta datos con más de 7 días y compacta la base con `VACUUM`
- **Persistence** — CPU and memory stats stored in SQLite (`.dockerflow-stats.db`) on every Docker polling cycle (~3s)
- **Ranges** — `1h`, `6h`, `24h`, `7d` with aggregated buckets (30s / 60s / 5min / 30min) for performance
- **Retention** — hourly auto-cleanup drops data older than 7 days and compacts the database with `VACUUM`
- **API** —
- `GET /api/stats/history?range=1h` — historial de todos los servicios
- `GET /api/stats/history/:uid?range=1h` — historial de un servicio específico
- **UI** — la página de monitoring (`MonitoringPage.tsx`) muestra una tarjeta por servicio con sparkline de CPU y MEM, valor actual, promedio y línea de umbral. Cada tarjeta puede expandirse para ver una gráfica más grande, y se filtra por proyecto y/o servicio (los filtros son acumulativos).
- `GET /api/stats/history?range=1h` — history of all services
- `GET /api/stats/history/:uid?range=1h` — history of a specific service
- **UI** — the monitoring page (`MonitoringPage.tsx`) shows a card per service with CPU and MEM sparklines, current value, average and threshold line. Each card can be expanded for a larger chart, and filtered by project and/or service (filters are cumulative).
### Notificaciones Discord
### Discord notifications
Configurables desde **Settings → Discord Notifications**. Requiere un webhook URL que empiece por `https://discord.com/api/webhooks/`.
Configured from **Settings → Discord Notifications**. Requires a webhook URL starting with `https://discord.com/api/webhooks/`.
Eventos soportados (cada uno se puede activar/desactivar):
Supported events (each can be toggled on/off):
| Evento | Cuándo dispara |
| Event | When it fires |
|---|---|
| **Container State Changes** | `start`, `stop`, `die` (crash), `restart`, `health_status`. Los eventos `stop`/`die` se debouncean 15s para detectar reinicios y enviar un solo mensaje "Container Restarted" en lugar de stop+start separados |
| **Resource Alerts** | CPU o memoria de un contenedor supera el umbral (global o por-container) |
| **UI Actions** | Acción manual disparada desde el panel: start/stop/restart/rebuild/remove |
| **Action Errors** | Falló una acción ejecutada desde la UI (incluye el mensaje de error) |
| **Container State Changes** | `start`, `stop`, `die` (crash), `restart`, `health_status`. `stop`/`die` events are debounced 15s to detect restarts and send a single "Container Restarted" message instead of separate stop+start |
| **Resource Alerts** | A container's CPU or memory exceeds the threshold (global or per-container) |
| **UI Actions** | Manual action triggered from the panel: start/stop/restart/rebuild/remove |
| **Action Errors** | An action executed from the UI failed (includes the error message) |
Mecanismos anti-spam:
Anti-spam mechanisms:
- **Cooldown global** — minutos mínimos entre alertas del mismo tipo+servicio (default `5 min`, configurable `1-60`)
- **Down reminder** — si un contenedor sigue caído, reenvía un recordatorio "Container Still Down" cada N minutos (default `5 min`)
- **Cola con rate limit** — 500ms mínimo entre webhooks; si Discord responde `429`, respeta el `Retry-After` y reintenta
- **Debounce de stop/die** — buffer de 15s para colapsar restart/redeploy en una sola notificación
- **Global cooldown** — minimum minutes between alerts of the same type+service (default `5 min`, configurable `1-60`)
- **Down reminder** — if a container stays down, resends a "Container Still Down" reminder every N minutes (default `5 min`)
- **Queue with rate limit** — 500ms minimum between webhooks; if Discord responds `429`, respects `Retry-After` and retries
- **Stop/die debounce** — 15s buffer to collapse restart/redeploy into a single notification
Umbrales:
Thresholds:
- **Globales** — CPU% y MEM% en Settings (default 50% / 60%)
- **Por contenedor** — desde la página de monitoring, click en el ícono ⚙️ de un servicio para abrir el panel inline. Permite:
- Activar/desactivar notificaciones para ese contenedor
- Override del umbral de CPU (drag del slider)
- Override del umbral de memoria
- Reset al valor global (X)
- Los overrides se persisten en `.dockerflow-container-settings.json` y se auto-guardan con debounce de 400ms
- **Globals** — CPU% and MEM% in Settings (default 50% / 60%)
- **Per container** — from the monitoring page, click the ⚙️ icon on a service to open the inline panel. Allows:
- Enable/disable notifications for that container
- CPU threshold override (slider)
- Memory threshold override
- Reset to global value (X)
- Overrides persist in `.dockerflow-container-settings.json` and auto-save with 400ms debounce
Botón **Test** en Settings envía un embed de prueba al webhook para verificar que funciona antes de habilitarlo.
A **Test** button in Settings sends a test embed to the webhook to verify it works before enabling.
## Tests
El proyecto usa [Vitest](https://vitest.dev/) para tests unitarios.
The project uses [Vitest](https://vitest.dev/) for unit tests.
```bash
# Correr todos los tests
# Run all tests
bun run test
# Correr en modo watch (re-ejecuta al guardar)
# Watch mode (re-runs on save)
bun run test:watch
# Verificar tipos TypeScript
# Type-check TypeScript
bun run typecheck
```
Los tests cubren:
Tests cover:
- **Logica de processing** (`src/client/hooks/processing.test.ts`) — sincronizacion de estados cuando se ejecutan acciones sobre containers (start/stop/restart), incluyendo manejo de estados crashed/dead, timeouts y minDuration
- **Deteccion de conexiones** (`src/server/docker.test.ts`) — descubrimiento de relaciones entre servicios por red compartida, clasificacion de servicios (infra, proxy, worker) y deduplicacion
- **Processing logic** (`src/client/hooks/processing.test.ts`) — state sync when actions are executed on containers (start/stop/restart), including crashed/dead states, timeouts and minDuration handling
- **Connection detection** (`src/server/docker.test.ts`) — discovery of service relationships via shared networks, service classification (infra, proxy, worker) and deduplication
- **Memory breakdown calc** (`src/server/watcher.test.ts`) — `computeMemoryBreakdown()` covering cgroup v1 and v2, fallback paths and edge cases
## CI
GitHub Actions ejecuta automaticamente en cada push/PR a `main`:
GitHub Actions runs automatically on every push/PR to `main`:
1. Typecheck (errores de tipos)
1. Typecheck (type errors)
2. Tests (Vitest)
3. Build (produccion)
3. Build (production)
Ver `.github/workflows/ci.yml`.
See `.github/workflows/ci.yml`.
## Seguridad
## Security
### Red y autenticación
### Network and authentication
- **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.
- **HTTPS required in production** — the auth token travels in HTTP headers. Without HTTPS, it's plaintext visible on the network. Use a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel) in front of ContainerFlow.
- **Rate limiting** — included by default: 5 failed attempts per minute per IP. After the limit, returns `429 Too Many Requests`. Applies to both REST API and WebSocket authentication.
- **Local access by default** — without `AUTH_TOKEN`, the server only listens on `127.0.0.1`. With `AUTH_TOKEN`, it listens on `0.0.0.0` for remote access.
### Privilegios del container
### Container privileges
ContainerFlow es una herramienta privilegiada por diseño:
ContainerFlow is a privileged tool by design:
- **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).
- **Docker socket** (`/var/run/docker.sock`) — full daemon access. Equivalent to root on the host: can create privileged containers, mount any path, read/write the entire filesystem. If ContainerFlow is compromised, the host is compromised.
- **Read-only host mounts** — `docker-compose.yml` mounts `/home`, `/opt`, `/srv` and `/root` as `:ro` so the `rebuild` and `exec` actions can read compose files. Allows **read access** to files in those directories (including SSH keys, git credentials, etc. of any user on the system).
**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.
**Implications on a multi-user server:** if multiple users (`/home/jorge`, `/home/israel`, `/home/pedro`) have projects on the same host, ContainerFlow can read all their files. Docker socket access makes this relatively moot (anyone with the socket already has full host access), but worth being aware of.
### Setup recomendado para single-user
### Recommended setup for single-user
Defaults actuales — convenientes y suficientes:
Current defaults — convenient and sufficient:
```yaml
volumes:
@@ -266,125 +311,141 @@ volumes:
- /root:/root:ro
```
### Setup recomendado para multi-user / producción
### Recommended setup for multi-user / production
Limita los mounts a directorios específicos donde tienes proyectos:
Limit mounts to specific directories where you have projects:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- containerflow-data:/app/data
# En vez de /home completo, solo tus proyectos
# Instead of all of /home, only your projects
- /home/jorge/git:/home/jorge/git:ro
- /srv/apps:/srv/apps:ro
```
Esto reduce el blast radius si hay un bug que filtre paths.
This reduces blast radius if there's a bug that leaks paths.
### Setup recomendado para deploys compartidos: `ALLOWED_PATHS`
### Recommended setup for shared deploys: `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`:
If multiple admins share a server and each should only interact with their own containers, configure the `ALLOWED_PATHS` env var in `.env`:
```bash
# .env
ALLOWED_PATHS=/home/jorge:/srv/myapp # rutas separadas por ":"
ALLOW_NON_COMPOSE=false # opcional, default false
ALLOWED_PATHS=/home/jorge:/srv/myapp # paths separated by ":"
ALLOW_NON_COMPOSE=false # optional, default false
```
**Comportamiento:**
**Behavior:**
- `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"
- `ALLOWED_PATHS` empty (default) → permissive mode: all actions available for all containers
- `ALLOWED_PATHS` with values → strict mode:
- **Visualization, stats and logs:** always available for all containers (visibility comes from the Docker socket)
- **Actions** (start/stop/restart/rebuild/remove/exec): only allowed if the container's compose file is under an allowed path
- Containers outside the paths appear with a **lock icon 🔒** and all their actions are disabled
- The context menu and detail panel show a "View-only" badge
**`ALLOW_NON_COMPOSE`** controla qué pasa con containers corridos manualmente (`docker run` sin labels de compose):
**`ALLOW_NON_COMPOSE`** controls what happens with manually-run containers (`docker run` without compose labels):
- `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.)
- `false` (default): blocks actions — view-only for non-compose containers
- `true`: allows actions on non-compose containers (useful if you have utility containers like Portainer agent, Watchtower, etc.)
**Ejemplo multi-usuario:**
**Multi-user example:**
```bash
# Servidor compartido con jorge, israel, pedro, nayeli
# Cada uno corre su propia instancia de ContainerFlow en puerto distinto
# El de jorge:
# Shared server with jorge, israel, pedro, nayeli
# Each runs their own ContainerFlow instance on a different port
# jorge's:
ALLOWED_PATHS=/home/jorge
# El de israel:
# israel's:
ALLOWED_PATHS=/home/israel
```
Cada uno ve **todos** los containers del servidor, pero solo puede hacer rebuild/restart/exec sobre los suyos.
Each one sees **all** the server's containers, but can only rebuild/restart/exec their own.
**Endpoint relevante:** `GET /api/config` devuelve la config activa (consumido por el frontend para deshabilitar botones).
**Relevant endpoint:** `GET /api/config` returns the active config (consumed by the frontend to disable buttons).
## Stack
| Componente | Tecnologia |
| Component | Technology |
|---|---|
| Runtime | Bun |
| Server | Hono |
| Frontend | React 19 + Vite 6 |
| Grafos | @xyflow/react 12 |
| Estilos | Tailwind CSS 4 |
| Iconos | Lucide React |
| Graph | @xyflow/react 12 |
| Styles | Tailwind CSS 4 |
| Icons | Lucide React |
| Docker API | dockerode |
| Comunicacion | WebSocket nativo |
| Communication | Native WebSocket |
| Tests | Vitest |
## Estructura
## Structure
```
src/
server/
index.ts — servidor Hono + WebSocket + CLI args + REST API
docker.ts — descubrimiento de servicios y conexiones
watcher.ts — polling de stats + stream de eventos Docker
stats-db.ts — SQLite de historial de stats (insert, query por rango, cleanup 7d)
discord.ts webhooks Discord (state changes, resource alerts, cooldown, debounce, queue)
container-settings.ts — overrides por contenedor (umbrales y toggle de notificaciones)
index.ts — Hono server + WebSocket + CLI args + REST API
docker.ts — service and connection discovery
watcher.ts — stats polling + Docker events stream (computeMemoryBreakdown)
stats-db.ts — SQLite stats history (insert, query by range, 7d cleanup)
events-db.ts — SQLite events_log + notifications_log
discord.ts — Discord webhooks (state changes, resource alerts, cooldown, debounce, queue)
container-settings.ts — per-container overrides (thresholds and notification toggle)
client/
App.tsx — dashboard principal + login screen
main.tsx — entry point React
index.css — Tailwind + animaciones custom
App.tsx — main dashboard + login screen
main.tsx — React entry point
index.css — Tailwind + custom animations
i18n.tsx — translations EN + ES, useT() hook
nodes/
ServiceNode.tsx — nodo visual por container
GroupNode.tsx — header de grupo (proyecto/compose)
ServiceNode.tsx — visual node per container
GroupNode.tsx — group header (project/compose)
hooks/
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
useDocker.ts — WebSocket hook for real-time data + action error toasts
useServerConfig.ts — fetch /api/config + canInteract() helper for ALLOWED_PATHS
useStatsHistory.ts — fetch stats history by range (1h/6h/24h/7d)
useStatsStore.ts — in-memory store for live stats
processing.ts — pure processing state logic
engine/
layout.ts — layout de grupos + grid + edges
layout.ts — group layout + 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 (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
HeaderBar.tsx — top navigation bar with notification bell
EdgeLegend.tsx — connection type legend
LoginScreen.tsx — authentication screen
NodeContextMenu.tsx — node context menu (disabled when locked)
OffsetEdge.tsx — custom edge with offset to avoid overlap
Sparkline.tsx — lightweight line chart for stats history
StatsCard.tsx — metric card with sparkline, hover, average and threshold
ThresholdBar.tsx — per-container threshold slider with override/reset
ActionErrorToast.tsx — top-right toast stack for action errors
Tooltip.tsx — info tooltip with portal + smart placement
panels/
DetailPanel.tsx — panel lateral con info, stats, env, config y logs
LogPanel.tsx — panel de logs por container
DetailPanel.tsx — side panel with info, stats, env, config and logs
LogPanel.tsx — log panel per container
pages/
MonitoringPage.tsx — historial de CPU/RAM, eventos Docker y umbrales por contenedor
SettingsPage.tsx — configuracion (auth, Discord webhook, eventos, umbrales globales)
MonitoringPage.tsx — CPU/RAM history, Docker events and per-container thresholds
SettingsPage.tsx — configuration (auth, Discord webhook, events, global thresholds)
shared/
types.ts — tipos compartidos server/client
types.ts — shared server/client types
```
## Licencia
## Community and contributions
ContainerFlow is in active development (`v0.x`).
- 🐛 **Bug?** Open an [issue](https://github.com/RGJorge/containerflow/issues/new?template=bug_report.md)
- 💡 **Idea?** Open a [feature request](https://github.com/RGJorge/containerflow/issues/new?template=feature_request.md)
- 💬 **Discussion / question?** Open a [discussion](https://github.com/RGJorge/containerflow/discussions)
- 🔒 **Security vulnerability?** Report privately — see [SECURITY.md](SECURITY.md)
- 📜 **Code of Conduct** — see [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
- 🛠 **Want to contribute code?** — see [CONTRIBUTING.md](CONTRIBUTING.md). Currently we only accept issues; PRs will open as the project matures and patterns stabilize.
If ContainerFlow is useful to you, a ⭐ on GitHub helps project visibility.
## License
Copyright (C) 2026 Jorge Gonzalez D. (RGJorge)
Este proyecto esta licenciado bajo **GNU Affero General Public License v3.0** (AGPL-3.0). Ver el archivo [LICENSE](LICENSE) para los terminos completos.
This project is licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0). See the [LICENSE](LICENSE) file for full terms.
Para uso comercial con codigo cerrado, contactar para una licencia comercial: alteonx.servicios@gmail.com
For commercial use with closed source, contact for a commercial license: alteonx.servicios@gmail.com
+3
View File
@@ -8,6 +8,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"dockerode": "^4",
"hono": "^4",
"html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"yaml": "^2",
"zod": "^3",
@@ -494,6 +495,8 @@
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+15
View File
@@ -0,0 +1,15 @@
# Override compose file for local development / building from source.
# Activated via COMPOSE_FILE env var in .env (see .env.example).
#
# Usage:
# 1. In .env, set: COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
# 2. Run: docker compose up -d --build
#
# This overrides the `image:` from docker-compose.yml with a local build,
# tagging it as `containerflow:local` so it's distinguishable from
# prebuilt versions.
services:
containerflow:
build: .
image: containerflow:local
+3 -1
View File
@@ -1,6 +1,8 @@
services:
containerflow:
build: .
# Pulls the latest prebuilt image from GHCR.
# To build from source locally instead, see docker-compose.local.yml + COMPOSE_FILE in .env.
image: ghcr.io/rgjorge/containerflow:latest
ports:
- "${EXTERNAL_PORT:-9470}:9470"
volumes:
-944
View File
@@ -1,944 +0,0 @@
# Alteonx DockerFlow
Herramienta open source para visualizar arquitecturas Docker en tiempo real.
```
git clone github.com/user/alteonx-dockerflow
cd alteonx-dockerflow
bun install
claude mcp add alteonx-dockerflow -- bun run src/mcp.ts
# Desde Claude Code:
> "arranca el visualizer"
> "Listo, abre http://localhost:9470"
```
---
## Stack
| Capa | Tecnología | Por qué |
|---|---|---|
| **Runtime** | Bun | 3x más rápido que Node, WebSocket nativo, bundler incluido, menos RAM |
| **Server** | Hono | 14KB, ultra rápido, soporte nativo Bun, middleware mínimo |
| **Frontend** | React 19 + @xyflow/react 12 | Librería de grafos más madura, nodos custom, edges custom, minimap |
| **Styling** | Tailwind v4 | Utility-first, tree-shaking agresivo, sin runtime |
| **Real-time** | Bun WebSocket | Nativo en Bun, zero dependencias, más rápido que socket.io |
| **Animaciones** | CSS transitions + keyframes | Sin librerías extra, GPU-accelerated, zero overhead |
| **Docker API** | dockerode | Estándar de facto, tipado, streams |
| **MCP** | @modelcontextprotocol/sdk | SDK oficial, stdio transport |
| **Build** | Vite 6 | HMR instantáneo, tree-shaking, build optimizado |
### Recursos estimados
| Recurso | Valor |
|---|---|
| **RAM** | ~30-50 MB |
| **CPU** | <1% idle, ~2% durante polling |
| **Disco** | ~80 MB (node_modules), ~2 MB build |
| **Bundle** | ~150 KB gzip |
| **Startup** | <500ms con Bun |
### Dependencias totales (mínimas)
```json
{
"dependencies": {
"hono": "^4",
"dockerode": "^4",
"@modelcontextprotocol/sdk": "^1.12",
"zod": "^3",
"yaml": "^2"
},
"devDependencies": {
"react": "^19",
"react-dom": "^19",
"@xyflow/react": "^12",
"@dagrejs/dagre": "^1",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"vite": "^6",
"typescript": "^5"
}
}
```
---
## Arquitectura
```
┌──────────────────────────────────────────────────────────────────┐
│ Docker Socket │
│ /var/run/docker.sock │
└──────────┬──────────────────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Hono Server (:9470)│ │ MCP Server (stdio) │
│ │ │ │
│ GET / │ │ Tools: │
│ → serve SPA │ │ • start_dashboard │
│ │ │ • list_services │
│ WS /ws │ │ • get_stats │
│ → push eventos │ │ • get_logs │
│ → push stats │ │ • restart_service │
│ │ │ • inspect_service │
│ Docker watcher: │ │ • get_networks │
│ • listContainers │ │ │
│ • getEvents stream │ │ Resources: │
│ • stats polling │ │ • docker://services │
│ │ │ │
│ Para: humanos 👀 │ │ │
└──────────────────────┘ │ │
│ Para: Claude Code 🤖 │
└──────────────────────────┘
```
---
## Estructura del proyecto
```
alteonx-dockerflow/
├── src/
│ ├── server/
│ │ ├── index.ts # Hono server + WebSocket
│ │ ├── docker.ts # Docker API wrapper (dockerode)
│ │ └── watcher.ts # Docker events stream + stats polling
│ ├── mcp/
│ │ └── index.ts # MCP server (stdio)
│ ├── client/
│ │ ├── App.tsx # React Flow canvas
│ │ ├── main.tsx # Entry point
│ │ ├── nodes/
│ │ │ ├── ServiceNode.tsx # Nodo de container
│ │ │ └── GroupNode.tsx # Nodo grupo (compose project)
│ │ ├── panels/
│ │ │ └── DetailPanel.tsx # Panel lateral de detalles
│ │ ├── hooks/
│ │ │ ├── useDocker.ts # WebSocket hook
│ │ │ └── useStatsStore.ts# Stats por nodo (useSyncExternalStore)
│ │ └── engine/
│ │ └── layout.ts # Auto-layout
│ └── shared/
│ └── types.ts # Tipos compartidos server/client
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md
```
---
## Cómo funciona (para cualquier proyecto)
### 1. Auto-discovery (zero config)
El dashboard lee el Docker socket y automáticamente:
```ts
// src/server/docker.ts
import Docker from "dockerode";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export async function discoverServices() {
const containers = await docker.listContainers({ all: true });
return containers.map((c) => ({
id: c.Id.slice(0, 12),
name: c.Labels["com.docker.compose.service"] || c.Names[0].replace("/", ""),
image: c.Image,
state: c.State,
status: c.Status,
ports: c.Ports.filter((p) => p.PublicPort).map((p) => ({
host: p.PublicPort,
container: p.PrivatePort,
})),
networks: Object.keys(c.NetworkSettings.Networks),
project: c.Labels["com.docker.compose.project"] || "standalone",
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
}));
}
export async function discoverConnections() {
// Servicios en la misma network = conectados
const networks = await docker.listNetworks();
const connections: { from: string; to: string; network: string }[] = [];
for (const net of networks) {
const info = await docker.getNetwork(net.Id).inspect();
const members = Object.values(info.Containers || {}).map((c: any) => c.Name);
// Cada par de containers en la misma red = edge
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
connections.push({
from: members[i],
to: members[j],
network: net.Name,
});
}
}
}
return connections;
}
```
**Resultado:** sin configurar nada, el usuario ve todos sus containers como nodos y las conexiones de red como edges. Funciona con cualquier proyecto Docker.
### 2. Agrupación visual (subgraphs automáticos)
Cada container es su propio nodo (cuadro) con imagen, estado, puertos, stats. Los subgraphs son bordes visuales que agrupan containers del mismo compose file o proyecto.
**Docker expone 2 labels para agrupar:**
| Label | Qué es | Ejemplo |
|---|---|---|
| `com.docker.compose.project` | Nombre del proyecto (directorio) | `ninjasagacw` |
| `com.docker.compose.project.config_files` | Qué compose file lo levantó | `docker-compose.infra.yml` |
**Auto-detección del nivel de agrupación:**
```ts
function detectGrouping(services: Service[]): "project" | "compose_file" {
const projects = new Set(services.map((s) => s.project));
// Múltiples proyectos → agrupar por proyecto
if (projects.size > 1) return "project";
// 1 solo proyecto con múltiples compose files → agrupar por compose file
return "compose_file";
}
```
**Caso 1: Un proyecto, múltiples compose files** (como NinjaSaga):
```
┌─ infra.yml ──────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 🐘 db │ │ 📡 collector │ │ ⚡ redis │ │
│ │ postgres:17 │ │ ./backend │ │ redis:7 │ │
│ │ CPU 0.3% │ │ CPU 1.2% │ │ CPU 0.1% │ │
│ │ MEM 45MB │ │ MEM 82MB │ │ MEM 12MB │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ ⚙️ celery- │ │ ⏰ celery- │ │
│ │ worker │ │ beat │ │
│ │ CPU 0.5% │ │ CPU 0.1% │ │
│ │ MEM 65MB │ │ MEM 40MB │ │
│ └──────────────┘ └──────────────┘ │
└───────────────────────────────────────────────────────────┘
┌─ dev.yml ────────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 🔧 backend │ │ 🔑 auth │ │ ⚙️ celery │ │
│ │ -dev │ │ -dev │ │ -dev │ │
│ │ :4020 │ │ :4021 │ │ │ │
│ │ CPU 0.8% │ │ CPU 0.3% │ │ CPU 0.2% │ │
│ │ MEM 120MB │ │ MEM 95MB │ │ MEM 60MB │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 🖥️ frontend │ │
│ │ -dev │ │
│ │ :4030 │ │
│ │ CPU 0.5% │ │
│ │ MEM 180MB │ │
│ └──────────────┘ │
└───────────────────────────────────────────────────────────┘
Edges cruzan entre subgraphs:
backend-dev ──→ db (postgres)
backend-dev ──→ redis (cache)
frontend-dev ──→ backend-dev (proxy /data)
collector ──→ db (snapshot)
collector ──→ redis (invalidate)
celery-worker ──→ redis (broker)
```
**Caso 2: Múltiples proyectos** (usuario con varios repos):
```
┌─ mi-saas ──────────────────┐ ┌─ monitoring ──────────────────┐
│ │ │ │
│ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ ┌────────────┐ │
│ │ 🚀 api │ │ 🐘 db │ │ │ │ 📊 grafana│ │ 📈 prometheus│
│ │ :3000 │ │ │ │ │ │ :3001 │ │ │ │
│ └────────┘ └────────┘ │ │ └──────────┘ └────────────┘ │
│ │ │ │
│ ┌────────┐ ┌────────┐ │ │ ┌──────────┐ │
│ │ ⚡redis│ │ 🌐 web │ │ │ │ 📋 loki │ │
│ │ │ │ :8080 │ │ │ │ │ │
│ └────────┘ └────────┘ │ │ └──────────┘ │
└──────────────────────────────┘ └──────────────────────────────┘
```
**Implementación en React Flow:**
```tsx
// Los subgraphs se renderizan como nodos "group" de React Flow
function buildGroupNodes(services: Service[], groupBy: "project" | "compose_file") {
const groups = new Map<string, Service[]>();
for (const svc of services) {
const key = groupBy === "project" ? svc.project : svc.compose_file;
const label = groupBy === "compose_file"
? key.replace("docker-compose.", "").replace(".yml", "") // "infra", "dev", "prod"
: key; // "mi-saas", "monitoring"
if (!groups.has(label)) groups.set(label, []);
groups.get(label)!.push(svc);
}
const nodes = [];
for (const [groupLabel, svcs] of groups) {
// Nodo grupo (subgraph visual)
nodes.push({
id: `group-${groupLabel}`,
type: "group",
data: { label: groupLabel },
position: { x: 0, y: 0 },
style: {
border: "1px dashed #334155",
borderRadius: 16,
padding: 24,
background: "rgba(30, 41, 59, 0.3)",
},
});
// Nodos hijos dentro del grupo
for (const svc of svcs) {
nodes.push({
id: svc.name,
type: "service",
data: { ...svc, label: svc.name },
parentId: `group-${groupLabel}`, // ← lo pone dentro del subgraph
extent: "parent",
position: { x: 0, y: 0 }, // dagre calcula la posición
});
}
}
return nodes;
}
```
### 3. Smart edge detection (heurísticas)
Además de redes, detecta relaciones por convención:
```ts
// src/server/docker.ts
export function inferEdgeType(from: Service, to: Service): EdgeType | null {
const toImage = to.image.toLowerCase();
const fromEnv = from.env || {};
// Detectar DB connections
if (toImage.includes("postgres") || toImage.includes("mysql") || toImage.includes("mongo")) {
// Buscar en env vars del "from" si referencia al "to"
for (const [key, val] of Object.entries(fromEnv)) {
if (key.includes("DATABASE") || key.includes("DB_HOST") || key.includes("MONGO")) {
return { type: "database", label: key.split("_")[0].toLowerCase() };
}
}
return { type: "database", label: "db" };
}
// Detectar Redis connections
if (toImage.includes("redis")) {
return { type: "cache", label: "redis" };
}
// Detectar RabbitMQ / message brokers
if (toImage.includes("rabbit") || toImage.includes("kafka")) {
return { type: "broker", label: "messages" };
}
// Detectar nginx/traefik → upstream
if (fromImage.includes("nginx") || fromImage.includes("traefik")) {
return { type: "proxy", label: "upstream" };
}
return null;
}
```
### 3. Docker Events (automático, zero config)
```ts
// src/server/watcher.ts
import Docker from "dockerode";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export function watchDockerEvents(onEvent: (e: DockerEvent) => void) {
docker.getEvents({}, (err, stream) => {
if (err || !stream) return;
stream.on("data", (chunk) => {
try {
const event = JSON.parse(chunk.toString());
if (event.Type !== "container") return;
onEvent({
type: "docker",
action: event.Action, // start, stop, die, restart, health_status
service: event.Actor?.Attributes?.["com.docker.compose.service"]
|| event.Actor?.Attributes?.name
|| "unknown",
time: event.time,
});
} catch {}
});
});
}
```
Cada start/stop/restart se ve como un pulso animado (flash) en el nodo.
### 4. Stats polling
```ts
// src/server/watcher.ts
export async function pollStats(services: Service[]): Promise<Stats[]> {
const running = services.filter((s) => s.state === "running");
const results: Stats[] = [];
for (const svc of running) {
try {
const container = docker.getContainer(svc.id);
const raw = await container.stats({ stream: false });
const cpuDelta = raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
const sysDelta = raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
results.push({
service: svc.name,
cpu: sysDelta > 0 ? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100 : 0,
mem_mb: (raw.memory_stats.usage || 0) / 1024 / 1024,
mem_percent: ((raw.memory_stats.usage || 0) / (raw.memory_stats.limit || 1)) * 100,
net_rx_mb: Object.values(raw.networks || {}).reduce((a: number, n: any) => a + (n.rx_bytes || 0), 0) / 1024 / 1024,
net_tx_mb: Object.values(raw.networks || {}).reduce((a: number, n: any) => a + (n.tx_bytes || 0), 0) / 1024 / 1024,
});
} catch {}
}
return results;
}
```
---
---
## Nodo visual (ServiceNode)
```tsx
// src/client/nodes/ServiceNode.tsx
import { Handle, Position } from "@xyflow/react";
const stateStyles = {
running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10" },
exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" },
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" },
};
const imageIcons: Record<string, string> = {
postgres: "🐘", redis: "⚡", nginx: "🔀", node: "💚", python: "🐍",
mongo: "🍃", mysql: "🐬", rabbitmq: "🐰", certbot: "📜",
};
function guessIcon(image: string): string {
for (const [key, icon] of Object.entries(imageIcons)) {
if (image.toLowerCase().includes(key)) return icon;
}
return "📦";
}
export function ServiceNode({ data }: { data: ServiceNodeData }) {
const s = stateStyles[data.state] || stateStyles.exited;
const icon = guessIcon(data.image);
return (
<div className={`relative rounded-xl border border-slate-700 ${s.bg} backdrop-blur-sm
shadow-lg shadow-black/30 p-4 min-w-[180px] ring-2 ${s.ring}
transition-all duration-500`}>
<Handle type="target" position={Position.Top} className="!bg-slate-500" />
{/* Status dot + name */}
<div className="flex items-center gap-2 mb-2">
<div className={`w-2.5 h-2.5 rounded-full ${s.dot}
${data.state === "running" ? "animate-pulse" : ""}`} />
<span className="font-bold text-white text-sm">{icon} {data.label}</span>
</div>
{/* Image */}
<div className="text-[11px] text-slate-400 truncate mb-1">{data.image}</div>
{/* Ports */}
{data.ports?.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1.5">
{data.ports.map((p) => (
<span key={p} className="text-[10px] bg-slate-800 text-cyan-400 px-1.5 py-0.5 rounded font-mono">
:{p}
</span>
))}
</div>
)}
{/* Stats bar */}
{data.stats && (
<div className="mt-2.5 space-y-1">
<div className="flex justify-between text-[10px] text-slate-400">
<span>CPU {data.stats.cpu.toFixed(1)}%</span>
<span>MEM {data.stats.mem_mb.toFixed(0)}MB</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500/70 rounded-full transition-all duration-500"
style={{ width: `${Math.min(data.stats.cpu, 100)}%` }} />
</div>
</div>
)}
{/* Compose project badge */}
{data.project && (
<div className="mt-2 flex justify-end">
<span className="text-[9px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">
{data.project}
</span>
</div>
)}
<Handle type="source" position={Position.Bottom} className="!bg-slate-500" />
</div>
);
}
```
---
## Server (Hono + Bun WebSocket)
```ts
// src/server/index.ts
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { discoverServices, discoverConnections } from "./docker";
import { watchDockerEvents, pollStats } from "./watcher";
const app = new Hono();
// Serve frontend
app.use("/*", serveStatic({ root: "./dist" }));
// REST endpoints (para MCP y fallback)
app.get("/api/services", async (c) => c.json(await discoverServices()));
app.get("/api/connections", async (c) => c.json(await discoverConnections()));
// WebSocket (Bun native)
const clients = new Set<WebSocket>();
const PORT = parseInt(process.env.PORT || "9470");
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
Bun.serve({
hostname: HOST,
port: PORT,
fetch: app.fetch,
websocket: {
open(ws) { clients.add(ws); },
close(ws) { clients.delete(ws); },
message(ws, msg) {
// Handle subscribe_logs, unsubscribe_logs, etc.
},
},
});
function broadcast(type: string, data: any) {
const msg = JSON.stringify({ type, data });
for (const ws of clients) ws.send(msg);
}
// Docker events → broadcast
watchDockerEvents((event) => broadcast("docker_event", event));
// Stats polling cada 3s
setInterval(async () => {
const services = await discoverServices();
const connections = await discoverConnections();
const stats = await pollStats(services);
broadcast("services", services);
broadcast("connections", connections);
broadcast("stats", stats);
}, 3000);
console.log(`Alteonx DockerFlow running on http://${HOST}:${PORT}`);
```
---
## MCP Server
```ts
// src/mcp/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import Docker from "dockerode";
import { z } from "zod";
import { execSync } from "child_process";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const server = new McpServer({ name: "alteonx-dockerflow", version: "1.0.0" });
// ── Dashboard control ──
server.tool("start_dashboard",
"Start the Alteonx DockerFlow dashboard and return the URL",
{ port: z.number().default(9470) },
async ({ port }) => {
// Arranca el server en background
execSync(`bun run src/server/index.ts &`, { stdio: "ignore" });
return { content: [{ type: "text", text: `Dashboard running at http://localhost:${port}` }] };
}
);
// ── Docker tools ──
server.tool("list_services",
"List all Docker containers with status, ports, image",
{},
async () => {
const containers = await docker.listContainers({ all: true });
const services = containers.map((c) => ({
name: c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", ""),
state: c.State,
status: c.Status,
image: c.Image,
ports: c.Ports.filter((p) => p.PublicPort).map((p) => `${p.PublicPort}:${p.PrivatePort}`),
project: c.Labels["com.docker.compose.project"] || "",
}));
return { content: [{ type: "text", text: JSON.stringify(services, null, 2) }] };
}
);
server.tool("get_stats",
"Get CPU and memory stats for a container",
{ container: z.string().describe("Container name or ID") },
async ({ container }) => {
const c = docker.getContainer(container);
const raw = await c.stats({ stream: false });
const cpuDelta = raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
const sysDelta = raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
return {
content: [{
type: "text",
text: JSON.stringify({
cpu_percent: (sysDelta > 0 ? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100 : 0).toFixed(2),
mem_mb: ((raw.memory_stats.usage || 0) / 1024 / 1024).toFixed(1),
mem_percent: (((raw.memory_stats.usage || 0) / (raw.memory_stats.limit || 1)) * 100).toFixed(1),
}, null, 2),
}],
};
}
);
server.tool("get_logs",
"Get recent logs from a container",
{
container: z.string().describe("Container name or ID"),
tail: z.number().default(50).describe("Number of lines"),
},
async ({ container, tail }) => {
const logs = await docker.getContainer(container).logs({
stdout: true, stderr: true, tail, timestamps: true,
});
return { content: [{ type: "text", text: logs.toString() }] };
}
);
server.tool("restart_service",
"Restart a Docker container",
{ container: z.string() },
async ({ container }) => {
await docker.getContainer(container).restart();
return { content: [{ type: "text", text: `Restarted: ${container}` }] };
}
);
server.tool("inspect_service",
"Get detailed info about a container (filters secrets from env)",
{ container: z.string() },
async ({ container }) => {
const info = await docker.getContainer(container).inspect();
return {
content: [{
type: "text",
text: JSON.stringify({
name: info.Name,
state: info.State,
image: info.Config.Image,
cmd: info.Config.Cmd,
env: info.Config.Env?.filter((e) =>
!e.match(/PASSWORD|SECRET|KEY|TOKEN|PRIVATE/i)
),
networks: Object.keys(info.NetworkSettings.Networks || {}),
mounts: info.Mounts?.map((m) => ({
type: m.Type, src: m.Source, dst: m.Destination,
})),
ports: info.NetworkSettings.Ports,
}, null, 2),
}],
};
}
);
server.tool("get_networks",
"List Docker networks and connected containers",
{},
async () => {
const networks = await docker.listNetworks();
const result = [];
for (const net of networks) {
const info = await docker.getNetwork(net.Id).inspect();
result.push({
name: net.Name,
driver: net.Driver,
containers: Object.values(info.Containers || {}).map((c: any) => c.Name),
});
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
);
// ── Resources ──
server.resource("services", "docker://services", async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(await docker.listContainers({ all: true }), null, 2),
}],
}));
// ── Start ──
const transport = new StdioServerTransport();
await server.connect(transport);
```
### Setup en Claude Code
```bash
# Instalar
git clone github.com/user/alteonx-dockerflow
cd alteonx-dockerflow
bun install
# Agregar MCP
claude mcp add alteonx-dockerflow -- bun run src/mcp/index.ts
# Usar
> "arranca el dashboard" → start_dashboard
> "qué containers tengo?" → list_services
> "cuánta RAM usa el backend?" → get_stats
> "muéstrame los logs de redis" → get_logs
```
---
## Seguridad
### Comportamiento por defecto (seguro sin configurar nada)
| `AUTH_TOKEN` | Bind | Acceso | Uso |
|---|---|---|---|
| No definido | `127.0.0.1:9470` | Solo local | Dev en tu máquina |
| Definido | `0.0.0.0:9470` | Remoto (con token) | SSH a servidor, equipo |
```ts
// src/server/index.ts
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
Bun.serve({
hostname: HOST,
port: 9470,
// ...
});
```
### Cómo funciona el auth
**Sin token (default):** bind a `127.0.0.1`, solo accesible desde tu máquina. No pide nada.
**Con token:** bind a `0.0.0.0`, el dashboard pide el token al entrar:
```
┌─────────────────────────────────────────┐
│ │
│ 🔒 Alteonx DockerFlow │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Token: •••••••••• │ │
│ └─────────────────────────────────┘ │
│ [ Entrar ] │
│ │
└─────────────────────────────────────────┘
```
- El token se guarda en `localStorage` (no lo pide cada vez)
- Toda request HTTP y conexión WebSocket valida el token
- Token inválido → 401 Unauthorized
```ts
// Middleware de auth
app.use("*", async (c, next) => {
if (!AUTH_TOKEN) return next(); // sin token = sin auth
// Skip para la página de login
if (c.req.path === "/" || c.req.path === "/auth") return next();
const token = c.req.header("Authorization")?.replace("Bearer ", "");
if (token !== AUTH_TOKEN) return c.json({ error: "Unauthorized" }, 401);
return next();
});
// WebSocket auth
websocket: {
open(ws) {
if (AUTH_TOKEN && ws.data.token !== AUTH_TOKEN) {
ws.close(1008, "Unauthorized");
return;
}
clients.add(ws);
},
}
```
### Setup remoto (SSH)
```bash
# En el servidor
AUTH_TOKEN=mi-clave-super-segura bun run src/server/index.ts
# Desde tu máquina
# Abrir http://servidor:9470 → pide token → listo
```
### Qué se protege
| Recurso | Sin auth | Con auth |
|---|---|---|
| Dashboard visual | Accesible (localhost) | Requiere token |
| WebSocket (stats, eventos) | Accesible (localhost) | Requiere token |
| API `/api/services` | Accesible (localhost) | Requiere token |
| MCP Server | Siempre local (stdio) | N/A (no pasa por HTTP) |
| Docker socket | Read-only (`:ro`) | Read-only (`:ro`) |
### Qué NO expone nunca
- Variables de entorno con `PASSWORD`, `SECRET`, `KEY`, `TOKEN`, `PRIVATE` se filtran automáticamente en `inspect_service`
- El Docker socket se monta como read-only (`:ro`) — no puede crear/eliminar containers desde el dashboard
- El MCP sí puede hacer `restart_service` porque corre local y tiene approval flow de Claude Code
---
## Colores
| Tipo | Color | Uso |
|---|---|---|
| Nodo running | `#22c55e` verde | Borde + pulso animado |
| Nodo stopped | `#ef4444` rojo | Borde estático |
| Nodo paused | `#f59e0b` amarillo | Borde estático |
| Docker event start | `#22c55e` verde | Flash en nodo |
| Docker event stop | `#ef4444` rojo | Flash en nodo |
| Docker event restart | `#f59e0b` amarillo | Flash en nodo |
---
## Filtrado de proyectos
### Por CLI (qué containers carga el backend)
| Modo | Comando | Qué carga |
|---|---|---|
| **Auto** (default) | `bunx alteonx-dockerflow` | Solo containers del proyecto actual (detecta por directorio) |
| **Multi** | `bunx alteonx-dockerflow --projects ninjasagacw,tonal` | Containers de proyectos específicos |
| **All** | `bunx alteonx-dockerflow --all` | Todo lo que esté corriendo |
```ts
// src/server/index.ts
const args = process.argv.slice(2);
const ALL = args.includes("--all");
const PROJECTS = args.find((a) => a.startsWith("--projects="))?.split("=")[1]?.split(",")
|| [path.basename(process.cwd())]; // default: nombre del directorio actual
function filterServices(services: Service[]): Service[] {
if (ALL) return services;
return services.filter((s) => PROJECTS.includes(s.project));
}
```
### Por frontend (filtro visual en vivo)
El backend siempre envía el campo `project` en cada servicio. El frontend tiene un dropdown con checkboxes para filtrar sin reiniciar:
```
┌──────────────────────────────────────────────────────┐
│ Alteonx DockerFlow [Proyecto ▼] [⚙️] │
│ ☑ ninjasagacw │
│ ☑ tonal │
│ ☑ megalabs │
│ ────────── │
│ ☑ Mostrar todos │
└──────────────────────────────────────────────────────┘
```
- Checkboxes por proyecto, filtra los nodos en vivo
- Selección se guarda en `localStorage`
- Si usaste `--all` ves todos los proyectos disponibles para filtrar
- Si usaste modo auto, solo ves el proyecto actual (sin dropdown)
### Para apagar
- `Ctrl+C` en la terminal
- Desde MCP: `> "apaga el dashboard"` → tool `stop_dashboard`
---
## Dificultad para el usuario final
| Paso | Dificultad | Tiempo |
|---|---|---|
| `git clone` + `bun install` | Trivial | 30s |
| `claude mcp add` | Trivial | 10s |
| "arranca el dashboard" | Trivial | 5s |
| Ver arquitectura + stats | Automático | 0s (auto-discovery) |
**Requisitos del usuario:**
- Docker instalado y corriendo
- Bun instalado (`curl -fsSL https://bun.sh/install | bash`)
- Claude Code con MCP (opcional, puede usar sin MCP también)
---
## Roadmap
### Fase 1 — MVP (auto-discovery + stats + agrupación)
- [ ] Server Hono + Bun WebSocket
- [ ] Auto-discovery de containers desde Docker socket
- [ ] Smart edge detection (networks + heurísticas)
- [ ] **Agrupación visual por compose project/file** (subgraph con borde + label)
- [ ] **Filtrado por proyecto**: CLI (`--all`, `--projects`, auto) + dropdown en frontend
- [ ] ServiceNode con estado, imagen, puertos, stats
- [ ] Auto-layout con dagre (respetando grupos)
- [ ] Stats polling (CPU/MEM) cada 3s
- [ ] Docker events (start/stop/restart) como pulso en nodos
- [ ] Dark theme, minimap, zoom, pan
### Fase 3 — Polish
- [ ] Click en nodo → panel lateral con logs en vivo
- [ ] Notificaciones y alertas
- [ ] Responsive (funcione en tablet)
- [ ] Export PNG/SVG del grafo actual
- [ ] Customizar posiciones de nodos (drag + guardar layout)
### Fase 4 — Avanzado
- [ ] Health check status en nodos (healthy/unhealthy/starting)
- [ ] Métricas históricas (SQLite para mini-gráficas en cada nodo)
- [ ] Alertas cuando un container se cae (webhook/push)
- [ ] Multi-host (Docker Swarm / remote Docker sockets)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 MiB

After

Width:  |  Height:  |  Size: 5.5 MiB

View File
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "containerflow",
"version": "0.1.0",
"version": "0.1.6",
"license": "AGPL-3.0-or-later",
"author": "Jorge Gonzalez D. (RGJorge)",
"type": "module",
@@ -21,6 +21,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"dockerode": "^4",
"hono": "^4",
"html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"yaml": "^2",
"zod": "^3"
+66 -11
View File
@@ -18,12 +18,13 @@ 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";
import { buildLayout, computeEdges, getComposeKey, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
import { DetailPanel } from "./panels/DetailPanel";
import { NodeContextMenu } from "./components/NodeContextMenu";
import { LoginScreen } from "./components/LoginScreen";
import { OffsetEdge } from "./components/OffsetEdge";
import { HeaderBar, type Page } from "./components/HeaderBar";
import { ExportPngButton } from "./components/ExportPngButton";
import { EdgeLegend } from "./components/EdgeLegend";
import { ActionErrorToast } from "./components/ActionErrorToast";
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
@@ -143,6 +144,37 @@ function Dashboard({ token }: { token: string }) {
const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({});
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 });
const [discordEnabled, setDiscordEnabled] = useState(false);
// Project aliases — friendly names for cryptic project keys (Coolify, Dokploy, etc.)
const [projectAliases, setProjectAliases] = useState<Record<string, string>>({});
// Save / reset handler — passed to GroupNode via node data. Wrapped in a ref
// so the layout effect doesn't have to recompute every time the callback
// identity changes; GroupNode always gets the latest version.
const handleAliasChange = useCallback(async (project: string, newAlias: string) => {
const trimmed = newAlias.trim();
setProjectAliases((prev) => {
const next = { ...prev };
if (trimmed) next[project] = trimmed;
else delete next[project];
return next;
});
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
await fetch("/api/project-aliases", {
method: "PUT",
headers,
body: JSON.stringify({ project, alias: trimmed }),
});
} catch {
// On error, refetch from server to revert optimistic update.
fetch("/api/project-aliases", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectAliases)
.catch(() => {});
}
}, [token]);
const handleAliasChangeRef = useRef(handleAliasChange);
handleAliasChangeRef.current = handleAliasChange;
useEffect(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -150,6 +182,10 @@ function Dashboard({ token }: { token: string }) {
.then((r) => r.ok ? r.json() : {})
.then(setContainerSettings)
.catch(() => {});
fetch("/api/project-aliases", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectAliases)
.catch(() => {});
fetch("/api/discord-config", { headers })
.then((r) => r.ok ? r.json() : null)
.then((c: any) => {
@@ -364,7 +400,8 @@ function Dashboard({ token }: { token: string }) {
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
// Mark service nodes as locked + inject effective thresholds for progress bar coloring
// Mark service nodes as locked + inject effective thresholds for progress bar coloring.
// For group nodes, inject the alias (if any) + the change handler.
for (const n of newNodes) {
if (n.type === "service") {
const svc = filteredServices.find((s) => s.uid === n.id);
@@ -375,6 +412,15 @@ function Dashboard({ token }: { token: string }) {
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
(n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined;
}
} else if (n.type === "group") {
// Use the raw `project` from the layout (NOT the groupKey which includes
// the compose suffix). This way the alias matches what `service.project`
// is on individual services, and the filter dropdown shares the same key.
const project = (n.data as any).project as string | undefined;
if (project) {
(n.data as any).alias = projectAliases[project];
(n.data as any).onAliasChange = handleAliasChangeRef.current;
}
}
}
@@ -458,7 +504,7 @@ function Dashboard({ token }: { token: string }) {
return result;
});
}
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled, projectAliases]);
// Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -566,11 +612,11 @@ function Dashboard({ token }: { token: string }) {
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} />}
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} projectAliases={projectAliases} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
{/* Canvas — inset (only visible on dashboard) */}
<div className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<div id="dashboard-canvas" className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<ReactFlow
onInit={(instance) => { reactFlowRef.current = instance; }}
nodes={dimmedNodes}
@@ -641,7 +687,9 @@ function Dashboard({ token }: { token: string }) {
proOptions={{ hideAttribution: true }}
>
<Background color="#374151" gap={30} size={2} />
<Controls position="bottom-left" />
<Controls position="bottom-left">
<ExportPngButton onError={(msg) => pushActionError("dashboard", "export", msg)} />
</Controls>
<EdgeLegend />
<MiniMap
position="bottom-right"
@@ -659,7 +707,7 @@ function Dashboard({ token }: { token: string }) {
{/* Project filter */}
{projects.length > 1 && (
<div className="absolute top-3 right-3 z-10" ref={filterRef}>
<div data-no-export="true" className="absolute top-3 right-3 z-10" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 backdrop-blur-sm hover:bg-slate-700/80 border border-slate-700/50 px-3 py-1.5 rounded-md transition-colors"
@@ -671,7 +719,7 @@ function Dashboard({ token }: { token: string }) {
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[220px]">
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[280px] max-h-[280px] overflow-y-auto">
{/* Select/Deselect all */}
<button
onClick={() => {
@@ -702,19 +750,26 @@ function Dashboard({ token }: { token: string }) {
const projectServices = services.filter((s) => s.project === p);
const running = projectServices.filter((s) => s.state === "running").length;
const stopped = projectServices.length - running;
const display = projectAliases[p] || p;
const composeKeys = [...new Set(projectServices.map((s) => getComposeKey(s.compose_file)))];
const composeSuffix = composeKeys.join(" - ");
return (
<button
key={p}
onClick={() => toggleProject(p)}
title={composeSuffix ? `${display} / ${composeSuffix}` : display}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{active && <Check size={12} className="text-white" />}
</div>
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
<span className="ml-auto flex items-center gap-1.5 text-xs">
<span className={`flex-1 min-w-0 truncate text-left uppercase ${active ? "text-slate-200" : "text-slate-500"}`}>
{display}
{composeSuffix && <span className="ml-1 text-xs text-slate-500">/ {composeSuffix}</span>}
</span>
<span className="ml-auto flex items-center gap-1.5 text-xs shrink-0">
<span className="text-emerald-500/70">{running}</span>
<span className="text-slate-600">/</span>
<span className="text-slate-400">{projectServices.length}</span>
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import { ControlButton } from "@xyflow/react";
import { Download, Loader2 } from "lucide-react";
import { useT } from "../i18n";
import { exportGraphAsPng, downloadPng } from "../utils/exportPng";
interface Props {
onError?: (message: string) => void;
}
export function ExportPngButton({ onError }: Props) {
const { t } = useT();
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
try {
const { dataUrl } = await exportGraphAsPng();
downloadPng(dataUrl, window.location.hostname);
} catch (err) {
const code = err instanceof Error ? err.message : "UNKNOWN";
const message =
code === "EMPTY_GRAPH"
? t("controls.exportPng.empty")
: code === "GRAPH_TOO_LARGE"
? t("controls.exportPng.tooLarge")
: t("controls.exportPng.failed");
onError?.(message);
} finally {
setBusy(false);
}
};
return (
<ControlButton onClick={handleClick} title={t("controls.exportPng")} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <Download />}
</ControlButton>
);
}
+1 -1
View File
@@ -211,7 +211,7 @@ export function HeaderBar({
/>
<div className="flex flex-col items-end">
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
<span className="text-[9px] text-slate-500 font-mono -mt-1">v0.0.1</span>
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
</div>
</div>
+5 -3
View File
@@ -9,7 +9,7 @@ export const GROUP_PADDING = 28;
export const GROUP_HEADER = 44;
const GROUP_GAP = 50;
function getComposeKey(file: string): string {
export function getComposeKey(file: string): string {
if (!file) return "default";
const match = file.match(/docker-compose\.?(.*)\.yml/);
const key = match?.[1] || "";
@@ -127,12 +127,14 @@ export function buildLayout(
.filter(Boolean);
const subtitle = composeFiles.join(", ");
// Group node
// Group node. `project` is the raw project key (without the compose part);
// it's what the alias system uses so the same alias applies across all
// compose files of the same project and matches the filter dropdown.
nodes.push({
id: `group-${groupKey}`,
type: "group",
position: { x: groupX, y: 0 },
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length },
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length, project: svcs[0]?.project },
style: {
width: groupWidth,
height: groupHeight,
+24
View File
@@ -20,6 +20,18 @@ const en = {
"filter.projects": "Projects",
"filter.all": "All",
// Canvas controls
"controls.exportPng": "Export graph as PNG",
"controls.exportPng.empty": "No containers to export",
"controls.exportPng.tooLarge": "Graph is too large to export at full quality",
"controls.exportPng.failed": "Failed to export graph",
// Group alias
"group.rename": "Rename project",
"group.resetAlias": "Reset to original name",
"group.saveAlias": "Save",
"group.cancelAlias": "Cancel",
// Login
"login.connecting": "Connecting...",
"login.connect": "Connect",
@@ -279,6 +291,18 @@ const es: Record<TranslationKey, string> = {
"filter.projects": "Proyectos",
"filter.all": "Todos",
// Canvas controls
"controls.exportPng": "Exportar grafo como PNG",
"controls.exportPng.empty": "No hay containers para exportar",
"controls.exportPng.tooLarge": "El grafo es muy grande para exportar en alta calidad",
"controls.exportPng.failed": "Error al exportar el grafo",
// Group alias
"group.rename": "Renombrar proyecto",
"group.resetAlias": "Restaurar nombre original",
"group.saveAlias": "Guardar",
"group.cancelAlias": "Cancelar",
// Login
"login.connecting": "Conectando...",
"login.connect": "Conectar",
+134 -10
View File
@@ -1,11 +1,18 @@
import { memo } from "react";
import { memo, useEffect, useRef, useState } from "react";
import type { NodeProps } from "@xyflow/react";
import { Server, Wrench, Rocket, Box, Folder } from "lucide-react";
import { Server, Wrench, Rocket, Box, Folder, Pencil, RotateCcw, Check, X } from "lucide-react";
import { useT } from "../i18n";
interface GroupNodeData {
label: string;
subtitle?: string;
count?: number;
/** Original (raw) project key used to look up / save the alias. */
project?: string;
/** Current alias if set, else undefined / empty string. */
alias?: string;
/** Save handler — called with (project, newAlias). Empty newAlias = reset. */
onAliasChange?: (project: string, newAlias: string) => void;
[key: string]: unknown;
}
@@ -36,24 +43,141 @@ function getProjectColor(label: string) {
}
export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
const { t } = useT();
const d = data as unknown as GroupNodeData;
// Label is "PROJECT / COMPOSE" — match compose part for known colors
const parts = d.label.split(" / ");
const composePart = parts.length > 1 ? parts[parts.length - 1] : d.label;
const known = groupConfig[composePart];
// Label is "PROJECT / COMPOSE" (uppercase). We let users alias only the
// project portion — the compose suffix (DEV / PROD / INFRA / docker-compose
// file name) stays as a structural hint and is also used for the icon match.
const labelParts = d.label.split(" / ");
const projectPart = labelParts[0] || d.label;
const composePart = labelParts.length > 1 ? labelParts.slice(1).join(" / ") : "";
const iconKey = composePart || d.label;
const known = groupConfig[iconKey];
const proj = known ? null : getProjectColor(d.label);
const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor };
const Icon = config.icon;
const hasAlias = Boolean(d.alias && d.alias.trim().length > 0);
const projectDisplay = hasAlias ? d.alias! : projectPart;
const displayName = composePart ? `${projectDisplay} / ${composePart}` : projectDisplay;
const canEdit = Boolean(d.project && d.onAliasChange);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(projectDisplay);
const inputRef = useRef<HTMLInputElement | null>(null);
// Focus input when entering edit mode. Cursor lands at the end of the
// current draft — no selection highlight, so users can just type to append.
useEffect(() => {
if (editing && inputRef.current) {
const el = inputRef.current;
el.focus();
const len = el.value.length;
el.setSelectionRange(len, len);
}
}, [editing]);
// Keep draft in sync if alias changes externally while not editing
useEffect(() => {
if (!editing) setDraft(projectDisplay);
}, [projectDisplay, editing]);
const startEdit = () => {
if (!canEdit) return;
// Short names (≤25 chars) → prefill so user can tweak (e.g. add a suffix).
// Long names (cryptic IDs from Coolify/Dokploy/etc) → start blank for a fresh alias.
setDraft(projectDisplay.length <= 25 ? projectDisplay : "");
setEditing(true);
};
const commit = () => {
if (!canEdit || !d.project) return;
// Reset if the user types the original project (case-insensitive) —
// no point storing an alias that's identical to the source key.
const finalAlias = draft.trim().toLowerCase() === projectPart.trim().toLowerCase() ? "" : draft;
d.onAliasChange?.(d.project, finalAlias);
setEditing(false);
};
const cancel = () => {
setEditing(false);
setDraft(projectDisplay);
};
const reset = () => {
if (!canEdit || !d.project) return;
d.onAliasChange?.(d.project, "");
};
return (
<div className="absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
<Icon size={16} style={{ color: config.color }} />
<div className="group absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
<Icon size={16} style={{ color: config.color }} className="shrink-0" />
{editing ? (
<>
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") cancel();
}}
onBlur={commit}
maxLength={64}
className="text-sm font-semibold tracking-wider uppercase bg-transparent border-none outline-none p-0 min-w-0"
style={{ color: config.color, width: `${Math.max(draft.length * 9 + 8, 100)}px` }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
{composePart && (
<span
className="text-sm font-semibold tracking-wider uppercase"
style={{ color: config.color }}
>
{d.label}
/ {composePart}
</span>
)}
<button
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); commit(); }}
className="text-emerald-400 hover:text-emerald-300 transition-colors"
title={t("group.saveAlias")}
>
<Check size={14} />
</button>
<button
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); cancel(); }}
className="text-slate-500 hover:text-slate-300 transition-colors"
title={t("group.cancelAlias")}
>
<X size={14} />
</button>
</>
) : (
<>
<span
className={`text-sm font-semibold tracking-wider uppercase ${canEdit ? "cursor-pointer hover:opacity-80" : ""}`}
style={{ color: config.color }}
onClick={canEdit ? startEdit : undefined}
>
{displayName}
</span>
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); startEdit(); }}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity"
title={t("group.rename")}
>
<Pencil size={11} />
</button>
)}
{hasAlias && canEdit && (
<button
onClick={(e) => { e.stopPropagation(); reset(); }}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity"
title={t("group.resetAlias")}
>
<RotateCcw size={11} />
</button>
)}
</>
)}
{d.subtitle && (
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]">
{d.subtitle}
@@ -61,7 +185,7 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
)}
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} />
{d.count != null && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
<Box size={12} style={{ color: config.borderColor }} />
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
{d.count}
+3 -3
View File
@@ -141,7 +141,7 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
return (
<div
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] max-w-[240px] ring-2 ${s.ring}
transition-[opacity,box-shadow] duration-300 ${flashClass} ${d.locked ? "opacity-70" : ""}`}
>
{d.locked && (
@@ -181,7 +181,7 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-bold text-white text-sm truncate">{d.label}</span>
<span className="font-bold text-white text-sm truncate" title={d.label}>{d.label}</span>
{d.state === "processing" ? (
<div className="flex items-center gap-0.5 shrink-0">
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
@@ -193,7 +193,7 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
<div className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
)}
</div>
<div className="text-xs text-slate-500 truncate mt-0.5">
<div className="text-xs text-slate-500 truncate mt-0.5" title={d.image}>
{d.image.startsWith("sha256:") ? `${t("node.noTag")} (${d.image.slice(7, 19)})` : d.image}
</div>
</div>
+35 -9
View File
@@ -7,6 +7,7 @@ import { StatsCard } from "../components/StatsCard";
import { ThresholdBar } from "../components/ThresholdBar";
import { Tooltip } from "../components/Tooltip";
import { guessIcon } from "../nodes/ServiceNode";
import { getComposeKey } from "../engine/layout";
function timeAgo(ts: number): string {
const diff = Math.floor((Date.now() / 1000) - ts);
@@ -85,9 +86,10 @@ interface MonitoringPageProps {
eventLogStream: EventLogEntry[];
notificationStream: NotificationLogEntry[];
onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void;
projectAliases?: Record<string, string>;
}
export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail }: MonitoringPageProps) {
export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail, projectAliases = {} }: MonitoringPageProps) {
const { t } = useT();
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
@@ -235,12 +237,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
};
// Labels
const aliasOrName = (p: string) => projectAliases[p] || p;
const projectLabel = selectedProjects.size === 0
? t("monitoring.filterProject")
: selectedProjects.size === allProjects.length
? t("monitoring.allProjects")
: selectedProjects.size === 1
? [...selectedProjects][0]
? aliasOrName([...selectedProjects][0])
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`;
const serviceLabel = selectedServices.size === 0
@@ -308,18 +311,24 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
<div className="border-t border-slate-700/50 my-1" />
{allProjects.map((project) => {
const isSelected = selectedProjects.has(project);
const composeKeys = [...new Set(services.filter((s) => s.project === project).map((s) => getComposeKey(s.compose_file)))];
const composeSuffix = composeKeys.join(" - ");
return (
<button
key={project}
onClick={() => toggleProject(project)}
title={composeSuffix ? `${aliasOrName(project)} / ${composeSuffix}` : aliasOrName(project)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{isSelected && <Check size={12} className="text-white" />}
</div>
<span className={isSelected ? "text-slate-200" : "text-slate-400"}>{project}</span>
<span className={`min-w-0 truncate uppercase ${isSelected ? "text-slate-200" : "text-slate-400"}`}>
{aliasOrName(project)}
{composeSuffix && <span className="ml-1 text-xs text-slate-500">/ {composeSuffix}</span>}
</span>
</button>
);
})}
@@ -453,7 +462,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
: `${selectedServices.size} ${t("footer.containers")}`)
: selectedProjects.size === 1
? [...selectedProjects][0]
? (() => {
const proj = [...selectedProjects][0];
const display = aliasOrName(proj);
const composeKeys = [...new Set(services.filter((s) => s.project === proj).map((s) => getComposeKey(s.compose_file)))];
const suffix = composeKeys.join(" - ");
return suffix ? `${display} / ${suffix.toUpperCase()}` : display;
})()
: selectedProjects.size === allProjects.length
? t("monitoring.allProjects")
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`
@@ -478,6 +493,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
globalRange={statsRange}
fallbackData={filteredHistory[svc] || []}
token={token}
projectAliases={projectAliases}
/>
))}
</div>
@@ -581,7 +597,7 @@ function MonitoringTotalsCard({
return (
<div className="px-5 py-3 border-b border-slate-700/40 bg-slate-900/40">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-slate-300 font-medium truncate">{title}</span>
<span className="text-xs text-slate-300 font-medium truncate uppercase">{title}</span>
<span className="text-[10px] text-slate-500">· {containerCount} {t("footer.containers")}</span>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -637,6 +653,7 @@ interface MonitoringServiceCardProps {
globalRange: StatsRange;
fallbackData: StatsHistoryPoint[];
token: string;
projectAliases: Record<string, string>;
}
function MonitoringServiceCard({
@@ -655,6 +672,7 @@ function MonitoringServiceCard({
globalRange,
fallbackData,
token,
projectAliases,
}: MonitoringServiceCardProps) {
const { t } = useT();
const [localRange, setLocalRange] = useState<StatsRange | null>(null);
@@ -692,9 +710,17 @@ function MonitoringServiceCard({
<ServiceIcon uid={svc} services={services} />
<div className="min-w-0">
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
{svc.includes("/") && (
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span>
)}
{svc.includes("/") && (() => {
const proj = svc.split("/")[0];
const display = projectAliases[proj] || proj;
// Only show THIS service's compose (not the whole project's list).
const thisCompose = svcData ? getComposeKey(svcData.compose_file) : "";
return (
<span className="text-[10px] text-slate-500 truncate block leading-tight uppercase">
{display}{thisCompose && <span> / {thisCompose}</span>}
</span>
);
})()}
</div>
<div className="flex-1" />
{/* Per-card range buttons */}
+1 -1
View File
@@ -122,7 +122,7 @@ export function SettingsPage({ projects, servicesCount, token }: SettingsPagePro
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">{t("settings.version")}</span>
<span className="text-slate-200 font-mono">v0.0.1</span>
<span className="text-slate-200 font-mono">v{__APP_VERSION__}</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">{t("settings.mode")}</span>
+17 -15
View File
@@ -394,12 +394,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
className={`absolute top-0 left-0 bottom-0 w-[900px] bg-slate-900/95 backdrop-blur-sm border-r border-slate-700/60 flex flex-col z-50 rounded-l-xl transition-transform duration-[400ms] ease-out ${visible ? "translate-x-0" : "-translate-x-full"}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 shrink-0">
<div className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${stateDot}`} />
<span className="text-sm font-semibold text-white truncate">{service.name}</span>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 shrink-0 gap-3">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<span className={`w-2 h-2 rounded-full shrink-0 ${stateDot}`} />
<span className="text-sm font-semibold text-white truncate min-w-0 max-w-[240px]">{service.name}</span>
<Tooltip text={service.name} placement="bottom" width="w-72" size={12} />
{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")}>
<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 shrink-0" title={t("access.viewOnly")}>
<Lock size={10} />
{t("access.viewOnly")}
</span>
@@ -409,20 +410,20 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
href={`http://${window.location.hostname}:${service.ports[0].host}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-slate-500 hover:text-cyan-400 transition-colors"
className="flex items-center gap-1 text-slate-500 hover:text-cyan-400 transition-colors shrink-0"
title={`Open http://${window.location.hostname}:${service.ports[0].host}`}
>
<ExternalLink size={12} />
<span className="text-[11px] font-mono">:{service.ports[0].host}</span>
</a>
)}
<span className={`text-xs font-mono ${stateColor} flex items-center gap-1`}>
<span className={`text-xs font-mono ${stateColor} flex items-center gap-1 shrink-0`}>
{isProcessing ? `processing... ${elapsed}s` :
isCrashed ? <><AlertTriangle size={11} />crashed (exit {service.exit_code}{service.oom_killed ? ", OOM" : ""})</> :
service.state}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
{/* Action buttons */}
{isProcessing ? (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-yellow-400">
@@ -1265,16 +1266,17 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
{/* Logs fullscreen modal */}
{logsModal && (
<div className="fixed inset-0 z-[100] bg-slate-900 flex flex-col">
<div className="flex items-center justify-between px-6 py-3 border-b border-slate-800 shrink-0">
<div className="flex items-center gap-2.5">
<Terminal size={16} className="text-cyan-400" />
<span className="text-sm font-semibold text-white">{service.name}</span>
<span className="text-xs text-slate-500 font-mono">logs</span>
<div className="flex items-center justify-between px-6 py-3 border-b border-slate-800 shrink-0 gap-3">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<Terminal size={16} className="text-cyan-400 shrink-0" />
<span className="text-sm font-semibold text-white truncate min-w-0 max-w-[240px]">{service.name}</span>
<Tooltip text={service.name} placement="bottom" width="w-72" size={12} />
<span className="text-xs text-slate-500 font-mono shrink-0">logs</span>
{service.state === "running" && subscribedRef.current && (
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse shrink-0" />
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => {
const text = allLines.map((l) => `${l.timestamp ? formatTimestamp(l.timestamp) + " " : ""}${l.line}`).join("\n");
+165
View File
@@ -0,0 +1,165 @@
import { toPng } from "html-to-image";
const PIXEL_RATIO = 3;
const BACKGROUND = "#0f172a"; // slate-900 (matches dashboard)
const DOT_COLOR = "rgba(55, 65, 81, 0.7)"; // slate-700 at 70% — matches the perceptual softness of the SVG pattern
const DOT_GAP = 30;
const DOT_SIZE = 2;
// Hard cap to avoid browser canvas memory issues. 100M pixels ≈ 800 MB RAM.
const MAX_PIXELS = 100_000_000;
export interface ExportPngOptions {
pixelRatio?: number;
backgroundColor?: string;
}
export interface ExportPngResult {
dataUrl: string;
width: number;
height: number;
}
/**
* Captures the dashboard canvas (React Flow area) as a PNG data URL.
*
* The React Flow `<Background>` component renders dots as an SVG `<pattern>`,
* which html-to-image does not rasterize reliably across browsers. To get a
* deterministic output we:
* 1. Capture the React Flow area with transparent background (nodes/edges
* only) and skip the buggy SVG pattern via `filter`.
* 2. Paint our own background + dot grid onto a canvas at the correct
* pixel ratio.
* 3. Draw the captured layer on top.
*
* Overlay UI (Controls, MiniMap, EdgeLegend, anything with `data-no-export`)
* is excluded so the export is just the graph itself.
*/
export async function exportGraphAsPng(opts: ExportPngOptions = {}): Promise<ExportPngResult> {
const pixelRatio = opts.pixelRatio ?? PIXEL_RATIO;
const backgroundColor = opts.backgroundColor ?? BACKGROUND;
const target = document.querySelector(".react-flow") as HTMLElement | null;
if (!target) {
throw new Error("CANVAS_NOT_FOUND");
}
const rect = target.getBoundingClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
if (width === 0 || height === 0) {
throw new Error("EMPTY_GRAPH");
}
if (width * height * pixelRatio * pixelRatio > MAX_PIXELS) {
throw new Error("GRAPH_TOO_LARGE");
}
// 1. Temporarily disable CSS effects that don't translate well to a
// rasterized PNG: Tailwind's `ring-*` (box-shadow halo around rounded
// corners shows as harsh edges without backdrop-blur underneath) and
// `backdrop-filter` (browsers don't capture it at all). Restored in
// the `finally` block.
const tempStyle = document.createElement("style");
tempStyle.dataset.exportPngOverride = "true";
tempStyle.textContent = `
/* Only target service (container) nodes — group headers stay transparent
so the group's outline / border remains visible at the top. */
.react-flow__node:not(.react-flow__node-group) > * {
--tw-ring-shadow: 0 0 #0000 !important;
backdrop-filter: none !important;
/* Solid dark fill so the dot grid doesn't bleed through node bodies.
State is still indicated by the border colors and the inner state dot. */
background-color: rgb(15 23 42 / 0.85) !important;
}
`;
document.head.appendChild(tempStyle);
let nodesDataUrl: string;
try {
// 2. Capture nodes/edges with transparent background.
nodesDataUrl = await toPng(target, {
width,
height,
pixelRatio,
backgroundColor: undefined,
filter: (node) => {
// node is typed as HTMLElement but at runtime can be any Element (incl. SVG).
// We rely on Element-level APIs which exist on both HTML and SVG.
const el = node as Element;
const cl = el.classList;
if (!cl) return true;
// Custom: anything explicitly marked
if ((node as HTMLElement).dataset?.noExport === "true") return false;
// React Flow overlays
if (cl.contains("react-flow__controls")) return false;
if (cl.contains("react-flow__minimap")) return false;
if (cl.contains("react-flow__attribution")) return false;
if (cl.contains("react-flow__panel")) return false;
// We re-render the dots manually below, skip React Flow's SVG pattern.
if (cl.contains("react-flow__background")) return false;
return true;
},
});
} finally {
document.head.removeChild(tempStyle);
}
// 2. Load the captured image so we can composite it onto a canvas.
const layer = await loadImage(nodesDataUrl);
// 3. Composite: solid bg + dot grid + captured layer.
const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("CANVAS_CONTEXT_FAILED");
}
// Solid base
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Dot grid (matches the React Flow <Background> config: gap 30, size 2)
ctx.fillStyle = DOT_COLOR;
const gap = DOT_GAP * pixelRatio;
const radius = (DOT_SIZE * pixelRatio) / 2;
for (let x = gap; x < canvas.width; x += gap) {
for (let y = gap; y < canvas.height; y += gap) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Captured nodes/edges on top
ctx.drawImage(layer, 0, 0, canvas.width, canvas.height);
return { dataUrl: canvas.toDataURL("image/png"), width, height };
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error(`Image load failed: ${e}`));
img.src = src;
});
}
/**
* Triggers a browser download of a data URL with a filename of the form
* `containerflow-<hostname>-<timestamp>.png`.
*/
export function downloadPng(dataUrl: string, hostname?: string): void {
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const host = hostname?.replace(/[^a-z0-9-]/gi, "").toLowerCase() || "graph";
const filename = `containerflow-${host}-${ts}.png`;
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+40 -1
View File
@@ -8,6 +8,7 @@ import { docker, discoverServices, discoverConnections, getContainerLogs, stream
import { pollStats, watchDockerEvents } from "./watcher";
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord";
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db";
import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
@@ -202,7 +203,8 @@ app.get("/api/init", async (c) => {
// We deliberately do NOT trigger a fresh pollStats here — on cold start
// with many containers it can exceed Bun's 10s request timeout and hang
// the dashboard. The first regular poll (within ~3s) populates via WS.
return c.json({ services, connections, positions, stats: lastStats });
const projectAliases = loadProjectAliases();
return c.json({ services, connections, positions, stats: lastStats, projectAliases });
});
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
@@ -615,6 +617,43 @@ app.put("/api/container-settings", async (c) => {
}
});
// ── Project aliases ──
app.get("/api/project-aliases", (c) => {
return c.json(loadProjectAliases());
});
app.put("/api/project-aliases", async (c) => {
try {
const body = await c.req.json() as { project: string; alias: string };
if (!body.project) {
return c.json({ error: "Missing project" }, 400);
}
const aliases = loadProjectAliases();
const clean = sanitizeAlias(body.alias || "");
if (clean) {
aliases[body.project] = clean;
} else {
// Empty alias = reset to original (remove the entry)
delete aliases[body.project];
}
saveProjectAliases(aliases);
return c.json({ ok: true, alias: clean || null });
} catch {
return c.json({ error: "Failed to save" }, 500);
}
});
app.delete("/api/project-aliases/:project", (c) => {
const project = c.req.param("project");
if (!project) {
return c.json({ error: "Missing project" }, 400);
}
const aliases = loadProjectAliases();
delete aliases[project];
saveProjectAliases(aliases);
return c.json({ ok: true });
});
// ── Stats history ──
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
+29
View File
@@ -0,0 +1,29 @@
import fs from "fs";
import path from "path";
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const ALIASES_FILE = path.join(DATA_DIR, ".dockerflow-project-aliases.json");
const MAX_ALIAS_LENGTH = 64;
export type ProjectAliases = Record<string, string>;
export function loadProjectAliases(): ProjectAliases {
try {
if (fs.existsSync(ALIASES_FILE)) {
return JSON.parse(fs.readFileSync(ALIASES_FILE, "utf-8"));
}
} catch {}
return {};
}
export function saveProjectAliases(aliases: ProjectAliases): void {
fs.writeFileSync(ALIASES_FILE, JSON.stringify(aliases, null, 2));
}
export function sanitizeAlias(raw: string): string {
return raw
.replace(/[\x00-\x1f\x7f]/g, "") // strip control chars
.trim()
.slice(0, MAX_ALIAS_LENGTH);
}
+5
View File
@@ -1,6 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json" with { type: "json" };
export default defineConfig({
plugins: [react(), tailwindcss()],
@@ -9,6 +10,10 @@ export default defineConfig({
outDir: "../../dist",
emptyOutDir: true,
},
define: {
// Inject app version from package.json so UI never drifts from the source of truth
__APP_VERSION__: JSON.stringify(pkg.version),
},
server: {
port: 9420,
host: "0.0.0.0",