mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38b8a9ff0 | ||
|
|
fae265727c | ||
|
|
3b18cad271 | ||
|
|
6fd4440b7a | ||
|
|
61472e4e04 | ||
|
|
b079422de8 | ||
|
|
71b8f94e21 | ||
|
|
2a660c1828 | ||
|
|
dd6d2599f0 |
+23
-37
@@ -1,60 +1,46 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# Servidor
|
# Server
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Puerto del servidor (por defecto: 9470)
|
|
||||||
PORT=9470
|
PORT=9470
|
||||||
|
|
||||||
# Token de autenticacion — dejar vacio para acceso solo en localhost (sin login)
|
# Auth token. Empty = localhost only, no login.
|
||||||
# Poner un valor para activar auth + acceso remoto (0.0.0.0)
|
# Set a value = login enabled + remote access (0.0.0.0).
|
||||||
AUTH_TOKEN=
|
AUTH_TOKEN=
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# Persistencia
|
# Persistence
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Directorio donde se guardan archivos persistentes (SQLite de stats,
|
# Where SQLite, configs, node positions and env file overrides are stored.
|
||||||
# config Discord, container settings, posiciones de nodos, env file overrides).
|
# Default native: ./data. In docker: /app/data (containerflow-data volume).
|
||||||
# Default nativo: ./data (relativo al cwd). En docker-compose.yml se setea
|
|
||||||
# a /app/data (montado en el volumen containerflow-data).
|
|
||||||
# El directorio se crea automaticamente al startup si no existe.
|
|
||||||
# DATA_DIR=/app/data
|
# 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
|
# Extra host path to mount so rebuild/remove can read compose files
|
||||||
# pueda leer compose files fuera de los defaults (/home, /opt, /srv, /root).
|
# outside /home, /opt, /srv, /root. Example: /data/apps
|
||||||
# Solo necesario si tus proyectos viven en una ruta no estandar.
|
|
||||||
# Ejemplo: HOST_PROJECTS_DIR=/data/apps
|
|
||||||
# HOST_PROJECTS_DIR=
|
# HOST_PROJECTS_DIR=
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# Control de acceso por path (multi-usuario)
|
# Access control (multi-tenant)
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Lista separada por ":" de prefijos donde se permiten acciones
|
# Empty = permissive (all actions allowed).
|
||||||
# (start/stop/restart/rebuild/remove/exec). Visualizacion, stats y
|
# Set = strict (only containers under these paths are actionable; rest locked).
|
||||||
# logs siempre disponibles para todos los containers.
|
# Colon-separated. Example: /home/jorge:/srv/myapp
|
||||||
#
|
|
||||||
# Vacio = modo permisivo (todas las acciones permitidas).
|
|
||||||
# Con valores = modo estricto (containers fuera de estas rutas
|
|
||||||
# aparecen con candado y acciones deshabilitadas).
|
|
||||||
#
|
|
||||||
# Ejemplo single-user:
|
|
||||||
# ALLOWED_PATHS=/home/jorge
|
|
||||||
#
|
|
||||||
# Ejemplo multi-path:
|
|
||||||
# ALLOWED_PATHS=/home/jorge:/srv/myapp:/opt/legacy
|
|
||||||
# ALLOWED_PATHS=
|
# ALLOWED_PATHS=
|
||||||
|
|
||||||
# Solo aplica cuando ALLOWED_PATHS esta activo. Si ALLOWED_PATHS esta
|
# Only applies when ALLOWED_PATHS is active.
|
||||||
# vacio, esta variable no tiene efecto (todo es accionable por default).
|
# false = block actions on non-compose containers.
|
||||||
#
|
# true = allow them (useful for watchtower, traefik, etc.).
|
||||||
# Cuando ALLOWED_PATHS esta activo, controla si containers no-compose
|
|
||||||
# (corridos con `docker run` directo, sin labels de compose) permiten
|
|
||||||
# acciones:
|
|
||||||
# false (default) = bloqueados, aparecen con candado
|
|
||||||
# true = permitidos (util para watchtower, traefik, etc.)
|
|
||||||
# ALLOW_NON_COMPOSE=false
|
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,3 +18,6 @@ recomendaciones.md
|
|||||||
docs/reddit.md
|
docs/reddit.md
|
||||||
# AI assistant context — internal, not for public repo
|
# AI assistant context — internal, not for public repo
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Planning notes — internal, not for public repo
|
||||||
|
task/
|
||||||
|
|||||||
+446
@@ -0,0 +1,446 @@
|
|||||||
|
# ContainerFlow
|
||||||
|
|
||||||
|
[](https://github.com/RGJorge/ContainerFlow/actions/workflows/ci.yml)
|
||||||
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
[](https://github.com/RGJorge/containerflow/tags)
|
||||||
|

|
||||||
|

|
||||||
|
[](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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> *"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
|
||||||
@@ -7,44 +7,64 @@
|
|||||||

|

|
||||||
[](https://github.com/RGJorge/containerflow/commits/main)
|
[](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.
|
Real-time Docker architecture visualizer. Displays services, connections and metrics from all your Docker Compose projects in an interactive dashboard.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Por qué ContainerFlow
|
> *"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/)
|
||||||
|
|
||||||
Las herramientas existentes te muestran números. ContainerFlow además:
|
## Why ContainerFlow
|
||||||
|
|
||||||
- **Visualiza arquitectura** — grafo interactivo con conexiones (app→db, app→cache, proxy→app) detectadas automáticamente, no solo una lista plana
|
Existing tools show you numbers. ContainerFlow also:
|
||||||
- **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
|
- **Visualizes architecture** — interactive graph with connections (app→db, app→cache, proxy→app) auto-detected, not just a flat list
|
||||||
- **Multi-usuario seguro** — variable `ALLOWED_PATHS` para servidores compartidos: ves todo, solo tocas lo tuyo
|
- **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
|
||||||
- **80 MB de RAM, startup en 500ms** — Bun + Hono. Pesa una fracción de Portainer y arranca antes que Grafana
|
- **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
|
## 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
|
```bash
|
||||||
git clone https://github.com/RGJorge/containerflow.git
|
git clone https://github.com/RGJorge/containerflow.git
|
||||||
cd containerflow
|
cd containerflow
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d
|
# In .env, uncomment: COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Abre `http://localhost:9470`. Listo.
|
For native development (hot reload, no Docker): `bun install && bun run dev`.
|
||||||
|
|
||||||
Para desarrollo nativo (hot reload): `bun install && bun run dev`.
|
## Documentation
|
||||||
|
|
||||||
## Documentación
|
- **[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.
|
||||||
|
|
||||||
- **[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.
|
## Requirements
|
||||||
- **[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
|
- [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
|
```bash
|
||||||
git clone https://github.com/RGJorge/containerflow.git
|
git clone https://github.com/RGJorge/containerflow.git
|
||||||
@@ -52,115 +72,115 @@ cd containerflow
|
|||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuracion
|
## Configuration
|
||||||
|
|
||||||
Copiar el archivo de ejemplo y editar:
|
Copy the example file and edit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Variables disponibles:
|
Available variables:
|
||||||
|
|
||||||
| Variable | Default | Descripcion |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `9470` | Puerto del servidor |
|
| `PORT` | `9470` | Server port |
|
||||||
| `AUTH_TOKEN` | _(vacio)_ | Token de autenticacion. Vacio = sin auth, solo localhost. Con valor = auth activado, acceso remoto |
|
| `AUTH_TOKEN` | _(empty)_ | Auth token. Empty = no auth, localhost only. Set = auth enabled, remote access allowed |
|
||||||
| `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`. |
|
| `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` | _(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`). |
|
| `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` | _(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). |
|
| `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` | **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. |
|
| `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
|
```bash
|
||||||
bun run dev
|
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
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Abre `http://localhost:9470`.
|
Opens `http://localhost:9470`.
|
||||||
|
|
||||||
### Produccion (manual)
|
### Production (manual)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build
|
bun run build
|
||||||
bun run start
|
bun run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Abre `http://localhost:9470`.
|
Opens `http://localhost:9470`.
|
||||||
|
|
||||||
### Modos de visualizacion
|
### Visualization modes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver TODOS los containers Docker
|
# View ALL Docker containers
|
||||||
bun run start -- --all
|
bun run start -- --all
|
||||||
|
|
||||||
# Ver solo proyectos especificos
|
# View only specific projects
|
||||||
bun run start -- --projects=mi-proyecto,otro-proyecto
|
bun run start -- --projects=my-project,another-project
|
||||||
|
|
||||||
# Auto-detectar desde el directorio actual
|
# Auto-detect from current directory
|
||||||
bun run start
|
bun run start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Funcionalidades
|
## Features
|
||||||
|
|
||||||
- **Descubrimiento automatico** — detecta servicios via Docker socket, agrupa por proyecto o compose file
|
- **Automatic discovery** — detects services via Docker socket, groups by project or compose file
|
||||||
- **Conexiones inteligentes** — detecta relaciones app→database, app→cache, proxy→app, worker→broker
|
- **Smart connections** — detects app→database, app→cache, proxy→app, worker→broker relationships
|
||||||
- **Metricas en tiempo real** — CPU y memoria por container, actualizado cada 3 segundos
|
- **Real-time metrics** — CPU and memory per container, refreshed every 3 seconds
|
||||||
- **Eventos Docker** — flash visual cuando un container inicia, para o reinicia
|
- **Docker events** — visual flash when a container starts, stops or restarts
|
||||||
- **Panel de detalle** — click en un container para ver info, stats, variables de entorno y configuracion en tabs separados
|
- **Detail panel** — click a container to see info, stats, env vars and config in separate tabs
|
||||||
- **Logs de containers** — logs en tiempo real con scroll automatico, filtro por stream (stdout/stderr) y opcion de copiar
|
- **Container logs** — real-time logs with auto-scroll, stream filter (stdout/stderr) and copy option
|
||||||
- **Acciones sobre containers** — start, stop, restart, rebuild y remove directamente desde el panel
|
- **Container actions** — start, stop, restart, rebuild, recreate and remove directly from the panel
|
||||||
- **Ejecutar comandos** — terminal inline (`docker exec`) desde el DetailPanel con output, sin abrir SSH ni terminal externa
|
- **Execute commands** — inline terminal (`docker exec`) from the DetailPanel with output, no SSH or external terminal needed
|
||||||
- **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
|
- **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
|
||||||
- **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
|
- **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
|
||||||
- **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
|
- **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
|
||||||
- **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?")
|
- **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?")
|
||||||
- **Filtro de proyectos** — dropdown para mostrar/ocultar proyectos, persiste entre sesiones
|
- **Project filter** — dropdown to show/hide projects, persists across sessions
|
||||||
- **Autenticacion** — pantalla de login con AUTH_TOKEN para acceso remoto seguro
|
- **Authentication** — login screen with AUTH_TOKEN for secure remote access
|
||||||
- **Leyenda de conexiones** — colores por tipo: Database (azul), Cache (rojo), Broker (naranja), Proxy (verde)
|
- **Connection legend** — color-coded by type: Database (blue), Cache (red), Broker (orange), Proxy (green)
|
||||||
- **Grupos visuales** — recuadros por proyecto/compose con titulo, archivo compose y conteo de containers
|
- **Visual groups** — boxes per project/compose with title, compose file and container count
|
||||||
- **Menu contextual** — click derecho en un nodo para acciones rapidas
|
- **Context menu** — right-click on a node for quick actions
|
||||||
- **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
|
- **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
|
||||||
- **Notificaciones Discord** — webhook configurable que avisa cambios de estado, alertas de recursos, acciones manuales y errores
|
- **Discord notifications** — configurable webhook for state changes, resource alerts, manual actions and errors
|
||||||
- **Umbrales por contenedor** — overrides personalizados de CPU/MEM (con fallback a umbrales globales) y toggle de notificaciones por servicio
|
- **Per-container thresholds** — custom CPU/MEM overrides (with fallback to global thresholds) and notification toggle per service
|
||||||
- **Pagina de settings** — configuracion de la aplicacion (auth, Discord, hosts Docker)
|
- **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" |
|
| **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" |
|
||||||
| **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" |
|
| **No `cpu_quota`** | Similar to memory — a container can saturate all cores. Critical in multi-tenant, degrades host responsiveness in single-tenant. | Banner: "No CPU limit configured in 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" |
|
| **`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
|
```yaml
|
||||||
# docker-compose.yml — buenas prácticas
|
# docker-compose.yml — best practices
|
||||||
services:
|
services:
|
||||||
mi-app:
|
my-app:
|
||||||
image: mi-app:latest
|
image: my-app:latest
|
||||||
restart: unless-stopped # ← reinicia tras crashes, respeta stops manuales
|
restart: unless-stopped # ← restarts on crash, respects manual stops
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "0.5" # ← máximo medio núcleo
|
cpus: "0.5" # ← maximum half a core
|
||||||
memory: 256M # ← tope absoluto, evita OOM del host
|
memory: 256M # ← absolute cap, prevents host OOM
|
||||||
healthcheck: # ← detecta apps "vivas pero rotas"
|
healthcheck: # ← detects "alive but broken" apps
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
@@ -168,117 +188,118 @@ services:
|
|||||||
start_period: 30s
|
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
|
- **No limits**: a memory leak in one service takes down the ENTIRE server
|
||||||
- **Con límites**: el container se mata a sí mismo, el resto sigue vivo, las restart policies lo reviven
|
- **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.)
|
- **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.)
|
||||||
- **Mounts no persistentes**: warning cuando una DB usa `tmpfs` o bind a directorio efímero
|
- **Non-persistent mounts**: warning when a DB uses `tmpfs` or binds to an ephemeral directory
|
||||||
- **Versión latest**: warning cuando un container usa `image:latest` (no reproducible)
|
- **`: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)
|
- **Persistence** — CPU and memory stats stored in SQLite (`.dockerflow-stats.db`) on every Docker polling cycle (~3s)
|
||||||
- **Rangos** — `1h`, `6h`, `24h`, `7d` con buckets agregados (30s / 60s / 5min / 30min) para rendimiento
|
- **Ranges** — `1h`, `6h`, `24h`, `7d` with aggregated buckets (30s / 60s / 5min / 30min) for performance
|
||||||
- **Retención** — auto-limpieza horaria descarta datos con más de 7 días y compacta la base con `VACUUM`
|
- **Retention** — hourly auto-cleanup drops data older than 7 days and compacts the database with `VACUUM`
|
||||||
- **API** —
|
- **API** —
|
||||||
- `GET /api/stats/history?range=1h` — historial de todos los servicios
|
- `GET /api/stats/history?range=1h` — history of all services
|
||||||
- `GET /api/stats/history/:uid?range=1h` — historial de un servicio específico
|
- `GET /api/stats/history/:uid?range=1h` — history of a specific service
|
||||||
- **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).
|
- **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 |
|
| **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** | CPU o memoria de un contenedor supera el umbral (global o por-container) |
|
| **Resource Alerts** | A container's CPU or memory exceeds the threshold (global or per-container) |
|
||||||
| **UI Actions** | Acción manual disparada desde el panel: start/stop/restart/rebuild/remove |
|
| **UI Actions** | Manual action triggered from the panel: start/stop/restart/rebuild/remove |
|
||||||
| **Action Errors** | Falló una acción ejecutada desde la UI (incluye el mensaje de error) |
|
| **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`)
|
- **Global cooldown** — minimum minutes between alerts of the same type+service (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`)
|
- **Down reminder** — if a container stays down, resends a "Container Still Down" reminder every N minutes (default `5 min`)
|
||||||
- **Cola con rate limit** — 500ms mínimo entre webhooks; si Discord responde `429`, respeta el `Retry-After` y reintenta
|
- **Queue with rate limit** — 500ms minimum between webhooks; if Discord responds `429`, respects `Retry-After` and retries
|
||||||
- **Debounce de stop/die** — buffer de 15s para colapsar restart/redeploy en una sola notificación
|
- **Stop/die debounce** — 15s buffer to collapse restart/redeploy into a single notification
|
||||||
|
|
||||||
Umbrales:
|
Thresholds:
|
||||||
|
|
||||||
- **Globales** — CPU% y MEM% en Settings (default 50% / 60%)
|
- **Globals** — CPU% and MEM% in 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:
|
- **Per container** — from the monitoring page, click the ⚙️ icon on a service to open the inline panel. Allows:
|
||||||
- Activar/desactivar notificaciones para ese contenedor
|
- Enable/disable notifications for that container
|
||||||
- Override del umbral de CPU (drag del slider)
|
- CPU threshold override (slider)
|
||||||
- Override del umbral de memoria
|
- Memory threshold override
|
||||||
- Reset al valor global (X)
|
- Reset to global value (X)
|
||||||
- Los overrides se persisten en `.dockerflow-container-settings.json` y se auto-guardan con debounce de 400ms
|
- 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
|
## Tests
|
||||||
|
|
||||||
El proyecto usa [Vitest](https://vitest.dev/) para tests unitarios.
|
The project uses [Vitest](https://vitest.dev/) for unit tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Correr todos los tests
|
# Run all tests
|
||||||
bun run test
|
bun run test
|
||||||
|
|
||||||
# Correr en modo watch (re-ejecuta al guardar)
|
# Watch mode (re-runs on save)
|
||||||
bun run test:watch
|
bun run test:watch
|
||||||
|
|
||||||
# Verificar tipos TypeScript
|
# Type-check TypeScript
|
||||||
bun run typecheck
|
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
|
- **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
|
||||||
- **Deteccion de conexiones** (`src/server/docker.test.ts`) — descubrimiento de relaciones entre servicios por red compartida, clasificacion de servicios (infra, proxy, worker) y deduplicacion
|
- **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
|
## 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)
|
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.
|
- **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** — incluido por defecto: 5 intentos fallidos por minuto por IP. Despues del limite, retorna `429 Too Many Requests`. Aplica tanto a la API REST como a la autenticacion WebSocket.
|
- **Rate limiting** — 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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
||||||
- **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).
|
- **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
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
@@ -290,137 +311,141 @@ volumes:
|
|||||||
- /root:/root:ro
|
- /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
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- containerflow-data:/app/data
|
- containerflow-data:/app/data
|
||||||
# En vez de /home completo, solo tus proyectos
|
# Instead of all of /home, only your projects
|
||||||
- /home/jorge/git:/home/jorge/git:ro
|
- /home/jorge/git:/home/jorge/git:ro
|
||||||
- /srv/apps:/srv/apps: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
|
```bash
|
||||||
# .env
|
# .env
|
||||||
ALLOWED_PATHS=/home/jorge:/srv/myapp # rutas separadas por ":"
|
ALLOWED_PATHS=/home/jorge:/srv/myapp # paths separated by ":"
|
||||||
ALLOW_NON_COMPOSE=false # opcional, default false
|
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` empty (default) → permissive mode: all actions available for all containers
|
||||||
- `ALLOWED_PATHS` con valores → modo estricto:
|
- `ALLOWED_PATHS` with values → strict mode:
|
||||||
- **Visualización, stats y logs:** siempre disponibles para todos los containers (la visibilidad viene del Docker socket)
|
- **Visualization, stats and logs:** always available for all containers (visibility comes from the Docker socket)
|
||||||
- **Acciones** (start/stop/restart/rebuild/remove/exec): solo permitidas si el compose file del container está bajo una ruta permitida
|
- **Actions** (start/stop/restart/rebuild/remove/exec): only allowed if the container's compose file is under an allowed path
|
||||||
- Los containers fuera de las rutas aparecen con un **ícono de candado 🔒** y todas sus acciones quedan deshabilitadas
|
- Containers outside the paths appear with a **lock icon 🔒** and all their actions are disabled
|
||||||
- El menú contextual y el panel de detalle muestran un badge "View-only"
|
- 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
|
- `false` (default): blocks actions — view-only for non-compose containers
|
||||||
- `true`: permite acciones sobre containers no-compose (útil si tienes containers utilitarios como Portainer agent, Watchtower, etc.)
|
- `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
|
```bash
|
||||||
# Servidor compartido con jorge, israel, pedro, nayeli
|
# Shared server with jorge, israel, pedro, nayeli
|
||||||
# Cada uno corre su propia instancia de ContainerFlow en puerto distinto
|
# Each runs their own ContainerFlow instance on a different port
|
||||||
# El de jorge:
|
# jorge's:
|
||||||
ALLOWED_PATHS=/home/jorge
|
ALLOWED_PATHS=/home/jorge
|
||||||
|
|
||||||
# El de israel:
|
# israel's:
|
||||||
ALLOWED_PATHS=/home/israel
|
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
|
## Stack
|
||||||
|
|
||||||
| Componente | Tecnologia |
|
| Component | Technology |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Runtime | Bun |
|
| Runtime | Bun |
|
||||||
| Server | Hono |
|
| Server | Hono |
|
||||||
| Frontend | React 19 + Vite 6 |
|
| Frontend | React 19 + Vite 6 |
|
||||||
| Grafos | @xyflow/react 12 |
|
| Graph | @xyflow/react 12 |
|
||||||
| Estilos | Tailwind CSS 4 |
|
| Styles | Tailwind CSS 4 |
|
||||||
| Iconos | Lucide React |
|
| Icons | Lucide React |
|
||||||
| Docker API | dockerode |
|
| Docker API | dockerode |
|
||||||
| Comunicacion | WebSocket nativo |
|
| Communication | Native WebSocket |
|
||||||
| Tests | Vitest |
|
| Tests | Vitest |
|
||||||
|
|
||||||
## Estructura
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
server/
|
server/
|
||||||
index.ts — servidor Hono + WebSocket + CLI args + REST API
|
index.ts — Hono server + WebSocket + CLI args + REST API
|
||||||
docker.ts — descubrimiento de servicios y conexiones
|
docker.ts — service and connection discovery
|
||||||
watcher.ts — polling de stats + stream de eventos Docker
|
watcher.ts — stats polling + Docker events stream (computeMemoryBreakdown)
|
||||||
stats-db.ts — SQLite de historial de stats (insert, query por rango, cleanup 7d)
|
stats-db.ts — SQLite stats history (insert, query by range, 7d cleanup)
|
||||||
discord.ts — webhooks Discord (state changes, resource alerts, cooldown, debounce, queue)
|
events-db.ts — SQLite events_log + notifications_log
|
||||||
container-settings.ts — overrides por contenedor (umbrales y toggle de notificaciones)
|
discord.ts — Discord webhooks (state changes, resource alerts, cooldown, debounce, queue)
|
||||||
|
container-settings.ts — per-container overrides (thresholds and notification toggle)
|
||||||
client/
|
client/
|
||||||
App.tsx — dashboard principal + login screen
|
App.tsx — main dashboard + login screen
|
||||||
main.tsx — entry point React
|
main.tsx — React entry point
|
||||||
index.css — Tailwind + animaciones custom
|
index.css — Tailwind + custom animations
|
||||||
|
i18n.tsx — translations EN + ES, useT() hook
|
||||||
nodes/
|
nodes/
|
||||||
ServiceNode.tsx — nodo visual por container
|
ServiceNode.tsx — visual node per container
|
||||||
GroupNode.tsx — header de grupo (proyecto/compose)
|
GroupNode.tsx — group header (project/compose)
|
||||||
hooks/
|
hooks/
|
||||||
useDocker.ts — hook WebSocket para datos en tiempo real + toast de errores de accion
|
useDocker.ts — WebSocket hook for real-time data + action error toasts
|
||||||
useServerConfig.ts — fetch /api/config + helper canInteract() para ALLOWED_PATHS
|
useServerConfig.ts — fetch /api/config + canInteract() helper for ALLOWED_PATHS
|
||||||
useStatsHistory.ts — fetch del historial de stats por rango (1h/6h/24h/7d)
|
useStatsHistory.ts — fetch stats history by range (1h/6h/24h/7d)
|
||||||
useStatsStore.ts — store en memoria para stats live
|
useStatsStore.ts — in-memory store for live stats
|
||||||
processing.ts — logica pura de estados processing
|
processing.ts — pure processing state logic
|
||||||
engine/
|
engine/
|
||||||
layout.ts — layout de grupos + grid + edges
|
layout.ts — group layout + grid + edges
|
||||||
components/
|
components/
|
||||||
HeaderBar.tsx — barra superior con navegacion
|
HeaderBar.tsx — top navigation bar with notification bell
|
||||||
EdgeLegend.tsx — leyenda de tipos de conexion
|
EdgeLegend.tsx — connection type legend
|
||||||
LoginScreen.tsx — pantalla de autenticacion
|
LoginScreen.tsx — authentication screen
|
||||||
NodeContextMenu.tsx — menu contextual de nodos (con disable cuando locked)
|
NodeContextMenu.tsx — node context menu (disabled when locked)
|
||||||
OffsetEdge.tsx — edge custom con offset para evitar superposicion
|
OffsetEdge.tsx — custom edge with offset to avoid overlap
|
||||||
Sparkline.tsx — gráfica de línea ligera para historial de stats
|
Sparkline.tsx — lightweight line chart for stats history
|
||||||
StatsCard.tsx — tarjeta de métrica con sparkline, hover, promedio y umbral
|
StatsCard.tsx — metric card with sparkline, hover, average and threshold
|
||||||
ThresholdBar.tsx — slider de umbral por contenedor con override/reset
|
ThresholdBar.tsx — per-container threshold slider with override/reset
|
||||||
ActionErrorToast.tsx — stack de toasts top-right para errores de acciones
|
ActionErrorToast.tsx — top-right toast stack for action errors
|
||||||
|
Tooltip.tsx — info tooltip with portal + smart placement
|
||||||
panels/
|
panels/
|
||||||
DetailPanel.tsx — panel lateral con info, stats, env, config y logs
|
DetailPanel.tsx — side panel with info, stats, env, config and logs
|
||||||
LogPanel.tsx — panel de logs por container
|
LogPanel.tsx — log panel per container
|
||||||
pages/
|
pages/
|
||||||
MonitoringPage.tsx — historial de CPU/RAM, eventos Docker y umbrales por contenedor
|
MonitoringPage.tsx — CPU/RAM history, Docker events and per-container thresholds
|
||||||
SettingsPage.tsx — configuracion (auth, Discord webhook, eventos, umbrales globales)
|
SettingsPage.tsx — configuration (auth, Discord webhook, events, global thresholds)
|
||||||
shared/
|
shared/
|
||||||
types.ts — tipos compartidos server/client
|
types.ts — shared server/client types
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comunidad y contribuciones
|
## Community and contributions
|
||||||
|
|
||||||
ContainerFlow está en desarrollo activo (`v0.x`).
|
ContainerFlow is in active development (`v0.x`).
|
||||||
|
|
||||||
- 🐛 **Bug?** Abre un [issue](https://github.com/RGJorge/containerflow/issues/new?template=bug_report.md)
|
- 🐛 **Bug?** Open an [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)
|
- 💡 **Idea?** Open a [feature request](https://github.com/RGJorge/containerflow/issues/new?template=feature_request.md)
|
||||||
- 🔒 **Vulnerabilidad de seguridad?** Reporta privadamente — ver [SECURITY.md](SECURITY.md)
|
- 💬 **Discussion / question?** Open a [discussion](https://github.com/RGJorge/containerflow/discussions)
|
||||||
- 📜 **Code of Conduct** — ver [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
- 🔒 **Security vulnerability?** Report privately — see [SECURITY.md](SECURITY.md)
|
||||||
- 🛠 **Quiero contribuir código** — ver [CONTRIBUTING.md](CONTRIBUTING.md). Actualmente solo aceptamos issues; PRs se abrirán cuando el proyecto madure.
|
- 📜 **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.
|
||||||
|
|
||||||
Si ContainerFlow te resulta útil, una ⭐ en GitHub ayuda a la visibilidad del proyecto.
|
If ContainerFlow is useful to you, a ⭐ on GitHub helps project visibility.
|
||||||
|
|
||||||
## Licencia
|
## License
|
||||||
|
|
||||||
Copyright (C) 2026 Jorge Gonzalez D. (RGJorge)
|
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"dockerode": "^4",
|
"dockerode": "^4",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"yaml": "^2",
|
"yaml": "^2",
|
||||||
"zod": "^3",
|
"zod": "^3",
|
||||||
@@ -494,6 +495,8 @@
|
|||||||
|
|
||||||
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
"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=="],
|
"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=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
containerflow:
|
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:
|
ports:
|
||||||
- "${EXTERNAL_PORT:-9470}:9470"
|
- "${EXTERNAL_PORT:-9470}:9470"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "containerflow",
|
"name": "containerflow",
|
||||||
"version": "0.1.2",
|
"version": "0.1.10",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"author": "Jorge Gonzalez D. (RGJorge)",
|
"author": "Jorge Gonzalez D. (RGJorge)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"dockerode": "^4",
|
"dockerode": "^4",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"yaml": "^2",
|
"yaml": "^2",
|
||||||
"zod": "^3"
|
"zod": "^3"
|
||||||
|
|||||||
+226
-55
@@ -18,12 +18,13 @@ import { useDocker } from "./hooks/useDocker";
|
|||||||
import { useServerConfig } from "./hooks/useServerConfig";
|
import { useServerConfig } from "./hooks/useServerConfig";
|
||||||
import { I18nProvider, useT } from "./i18n";
|
import { I18nProvider, useT } from "./i18n";
|
||||||
import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore";
|
import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore";
|
||||||
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
|
import { buildLayout, computeEdges, getComposeKey, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
|
||||||
import { DetailPanel } from "./panels/DetailPanel";
|
import { DetailPanel } from "./panels/DetailPanel";
|
||||||
import { NodeContextMenu } from "./components/NodeContextMenu";
|
import { NodeContextMenu } from "./components/NodeContextMenu";
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
import { OffsetEdge } from "./components/OffsetEdge";
|
import { OffsetEdge } from "./components/OffsetEdge";
|
||||||
import { HeaderBar, type Page } from "./components/HeaderBar";
|
import { HeaderBar, type Page } from "./components/HeaderBar";
|
||||||
|
import { ExportPngButton } from "./components/ExportPngButton";
|
||||||
import { EdgeLegend } from "./components/EdgeLegend";
|
import { EdgeLegend } from "./components/EdgeLegend";
|
||||||
import { ActionErrorToast } from "./components/ActionErrorToast";
|
import { ActionErrorToast } from "./components/ActionErrorToast";
|
||||||
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
|
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
|
||||||
@@ -143,6 +144,64 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({});
|
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 [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 });
|
||||||
const [discordEnabled, setDiscordEnabled] = useState(false);
|
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;
|
||||||
|
|
||||||
|
// Project colors — per-project hex color overrides for the group background.
|
||||||
|
const [projectColors, setProjectColors] = useState<Record<string, string>>({});
|
||||||
|
const handleColorChange = useCallback(async (project: string, color: string) => {
|
||||||
|
setProjectColors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (color) next[project] = color;
|
||||||
|
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-colors", {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ project, color }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
fetch("/api/project-colors", { headers })
|
||||||
|
.then((r) => r.ok ? r.json() : {})
|
||||||
|
.then(setProjectColors)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
const handleColorChangeRef = useRef(handleColorChange);
|
||||||
|
handleColorChangeRef.current = handleColorChange;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -150,6 +209,14 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
.then((r) => r.ok ? r.json() : {})
|
.then((r) => r.ok ? r.json() : {})
|
||||||
.then(setContainerSettings)
|
.then(setContainerSettings)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
fetch("/api/project-aliases", { headers })
|
||||||
|
.then((r) => r.ok ? r.json() : {})
|
||||||
|
.then(setProjectAliases)
|
||||||
|
.catch(() => {});
|
||||||
|
fetch("/api/project-colors", { headers })
|
||||||
|
.then((r) => r.ok ? r.json() : {})
|
||||||
|
.then(setProjectColors)
|
||||||
|
.catch(() => {});
|
||||||
fetch("/api/discord-config", { headers })
|
fetch("/api/discord-config", { headers })
|
||||||
.then((r) => r.ok ? r.json() : null)
|
.then((r) => r.ok ? r.json() : null)
|
||||||
.then((c: any) => {
|
.then((c: any) => {
|
||||||
@@ -271,7 +338,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep leftmost child at MIN_X — build parent→children index once
|
// Build parent→children index once
|
||||||
const childrenByParent = new Map<string, number[]>();
|
const childrenByParent = new Map<string, number[]>();
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
const pid = nodes[i].parentId;
|
const pid = nodes[i].parentId;
|
||||||
@@ -281,45 +348,61 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
arr.push(i);
|
arr.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After every drag-end: re-center kids horizontally + vertically within
|
||||||
|
// their group, resize the group to fit, and shift the group on the
|
||||||
|
// canvas by the opposite of the kid shift so visible positions don't jump.
|
||||||
|
const FOOTER_RESERVE = 22;
|
||||||
|
const minW = NODE_W + G_PAD * 3;
|
||||||
|
const groupIdxById = new Map<string, number>();
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (nodes[i].id.startsWith("group-")) groupIdxById.set(nodes[i].id, i);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [gid, kidIdxs] of childrenByParent) {
|
for (const [gid, kidIdxs] of childrenByParent) {
|
||||||
let minChildX = Infinity;
|
if (kidIdxs.length === 0) continue;
|
||||||
for (const ki of kidIdxs) minChildX = Math.min(minChildX, nodes[ki].position.x);
|
let minLeft = Infinity, minTop = Infinity;
|
||||||
if (minChildX !== MIN_X) {
|
let maxRight = 0, maxBottom = 0;
|
||||||
const shift = minChildX - MIN_X;
|
|
||||||
changed = true;
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const n = nodes[i];
|
|
||||||
if (n.id === gid) nodes[i] = { ...n, position: { x: n.position.x + shift, y: n.position.y } };
|
|
||||||
else if (n.parentId === gid) nodes[i] = { ...n, position: { x: n.position.x - shift, y: n.position.y } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize groups to fit children
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const n = nodes[i];
|
|
||||||
if (!n.id.startsWith("group-")) continue;
|
|
||||||
const kidIdxs = childrenByParent.get(n.id);
|
|
||||||
if (!kidIdxs || kidIdxs.length === 0) continue;
|
|
||||||
|
|
||||||
let maxRight = 0;
|
|
||||||
let maxBottom = 0;
|
|
||||||
for (const ki of kidIdxs) {
|
for (const ki of kidIdxs) {
|
||||||
const k = nodes[ki];
|
const k = nodes[ki];
|
||||||
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
|
minLeft = Math.min(minLeft, k.position.x);
|
||||||
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
|
maxRight = Math.max(maxRight, k.position.x + NODE_W);
|
||||||
|
minTop = Math.min(minTop, k.position.y);
|
||||||
|
maxBottom = Math.max(maxBottom, k.position.y + NODE_H);
|
||||||
|
}
|
||||||
|
const contentW = maxRight - minLeft;
|
||||||
|
const contentH = maxBottom - minTop;
|
||||||
|
const newW = Math.max(contentW + G_PAD * 2, minW);
|
||||||
|
const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE;
|
||||||
|
const targetLeft = (newW - contentW) / 2;
|
||||||
|
const targetTop = GROUP_HEADER + G_PAD;
|
||||||
|
const shiftX = targetLeft - minLeft;
|
||||||
|
const shiftY = targetTop - minTop;
|
||||||
|
|
||||||
|
if (shiftX !== 0 || shiftY !== 0) {
|
||||||
|
changed = true;
|
||||||
|
// Shift kids inside the group...
|
||||||
|
for (const ki of kidIdxs) {
|
||||||
|
const k = nodes[ki];
|
||||||
|
nodes[ki] = { ...k, position: { x: k.position.x + shiftX, y: k.position.y + shiftY } };
|
||||||
|
}
|
||||||
|
// ...and shift the group itself by the opposite so canvas-relative
|
||||||
|
// positions stay where the user just dropped them.
|
||||||
|
const gIdx = groupIdxById.get(gid);
|
||||||
|
if (gIdx !== undefined) {
|
||||||
|
const g = nodes[gIdx];
|
||||||
|
nodes[gIdx] = { ...g, position: { x: g.position.x - shiftX, y: g.position.y - shiftY } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const minW = NODE_W + G_PAD * 3;
|
const gIdx = groupIdxById.get(gid);
|
||||||
const newW = Math.max(maxRight, minW);
|
if (gIdx !== undefined) {
|
||||||
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
|
const g = nodes[gIdx];
|
||||||
|
const curW = (g.style?.width as number) || 0;
|
||||||
const curW = (n.style?.width as number) || 0;
|
const curH = (g.style?.height as number) || 0;
|
||||||
const curH = (n.style?.height as number) || 0;
|
|
||||||
|
|
||||||
if (newW !== curW || newH !== curH) {
|
if (newW !== curW || newH !== curH) {
|
||||||
changed = true;
|
changed = true;
|
||||||
nodes[i] = { ...n, style: { ...n.style, width: newW, height: newH } };
|
nodes[gIdx] = { ...g, style: { ...g.style, width: newW, height: newH } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +447,8 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
|
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) {
|
for (const n of newNodes) {
|
||||||
if (n.type === "service") {
|
if (n.type === "service") {
|
||||||
const svc = filteredServices.find((s) => s.uid === n.id);
|
const svc = filteredServices.find((s) => s.uid === n.id);
|
||||||
@@ -375,29 +459,97 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
|
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
|
||||||
(n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : 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).color = projectColors[project];
|
||||||
|
(n.data as any).onAliasChange = handleAliasChangeRef.current;
|
||||||
|
(n.data as any).onColorChange = handleColorChangeRef.current;
|
||||||
|
// Apply custom color to group background/border. Falls back to the
|
||||||
|
// auto-assigned palette in buildLayout when not set.
|
||||||
|
const hex = projectColors[project];
|
||||||
|
if (hex) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
n.style = {
|
||||||
|
...n.style,
|
||||||
|
background: `rgba(${r}, ${g}, ${b}, 0.08)`,
|
||||||
|
border: `1px dashed rgba(${r}, ${g}, ${b}, 0.3)`,
|
||||||
|
color: `rgba(${r}, ${g}, ${b}, 0.8)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initialLayoutDone.current) {
|
if (!initialLayoutDone.current) {
|
||||||
|
// Single-service groups can't be "arranged" — always honor the computed
|
||||||
|
// (centered) position from buildLayout, ignoring any stale saved value.
|
||||||
|
const servicesPerGroup = new Map<string, number>();
|
||||||
|
for (const n of newNodes) {
|
||||||
|
if (n.type === "service" && n.parentId) {
|
||||||
|
servicesPerGroup.set(n.parentId, (servicesPerGroup.get(n.parentId) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
let positioned = newNodes.map((n) => {
|
let positioned = newNodes.map((n) => {
|
||||||
|
if (n.type === "service" && n.parentId && servicesPerGroup.get(n.parentId) === 1) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
const saved = savedPositions.current[n.id];
|
const saved = savedPositions.current[n.id];
|
||||||
if (saved) return { ...n, position: saved };
|
if (saved) return { ...n, position: saved };
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
positioned = positioned.map((n) => {
|
// Resize each group to fit its kids AND recenter content horizontally
|
||||||
if (n.type !== "group") return n;
|
// + vertically. We measure the bounding box of children, then shift them
|
||||||
const kids = positioned.filter((c) => c.parentId === n.id);
|
// as a block so margins are symmetric on all four sides. Preserves the
|
||||||
if (kids.length === 0) return n;
|
// relative spacing between kids (a vertical stack stays a vertical stack,
|
||||||
let maxRight = 0;
|
// just centered). FOOTER_RESERVE accounts for the compose subtitle at
|
||||||
let maxBottom = 0;
|
// the bottom of every group.
|
||||||
for (const k of kids) {
|
const FOOTER_RESERVE = 22;
|
||||||
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
|
const groupKids = new Map<string, Node[]>();
|
||||||
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
|
for (const n of positioned) {
|
||||||
|
if (n.type === "service" && n.parentId) {
|
||||||
|
if (!groupKids.has(n.parentId)) groupKids.set(n.parentId, []);
|
||||||
|
groupKids.get(n.parentId)!.push(n);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const groupDims = new Map<string, { width: number; height: number; shiftX: number; shiftY: number }>();
|
||||||
|
for (const [groupId, kids] of groupKids) {
|
||||||
|
let minLeft = Infinity, minTop = Infinity;
|
||||||
|
let maxRight = 0, maxBottom = 0;
|
||||||
|
for (const k of kids) {
|
||||||
|
minLeft = Math.min(minLeft, k.position.x);
|
||||||
|
maxRight = Math.max(maxRight, k.position.x + NODE_W);
|
||||||
|
minTop = Math.min(minTop, k.position.y);
|
||||||
|
maxBottom = Math.max(maxBottom, k.position.y + NODE_H);
|
||||||
|
}
|
||||||
|
const contentW = maxRight - minLeft;
|
||||||
|
const contentH = maxBottom - minTop;
|
||||||
const minW = NODE_W + G_PAD * 3;
|
const minW = NODE_W + G_PAD * 3;
|
||||||
const newW = Math.max(maxRight, minW);
|
const newW = Math.max(contentW + G_PAD * 2, minW);
|
||||||
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
|
const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE;
|
||||||
return { ...n, style: { ...n.style, width: newW, height: newH } };
|
const shiftX = (newW - contentW) / 2 - minLeft;
|
||||||
|
const shiftY = (GROUP_HEADER + G_PAD) - minTop;
|
||||||
|
groupDims.set(groupId, { width: newW, height: newH, shiftX, shiftY });
|
||||||
|
}
|
||||||
|
positioned = positioned.map((n) => {
|
||||||
|
if (n.type === "service" && n.parentId) {
|
||||||
|
const dim = groupDims.get(n.parentId);
|
||||||
|
if (dim && (dim.shiftX !== 0 || dim.shiftY !== 0)) {
|
||||||
|
return { ...n, position: { x: n.position.x + dim.shiftX, y: n.position.y + dim.shiftY } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
if (n.type === "group") {
|
||||||
|
const dim = groupDims.get(n.id);
|
||||||
|
if (dim) return { ...n, style: { ...n.style, width: dim.width, height: dim.height } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
});
|
});
|
||||||
const { edges, activeHandles } = computeEdges(positioned, filteredConnections);
|
const { edges, activeHandles } = computeEdges(positioned, filteredConnections);
|
||||||
for (const n of positioned) {
|
for (const n of positioned) {
|
||||||
@@ -418,6 +570,17 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
for (const nn of newNodes) {
|
for (const nn of newNodes) {
|
||||||
const existing = prevNodeMap.get(nn.id);
|
const existing = prevNodeMap.get(nn.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
// For groups, accept the new style (color overrides live there)
|
||||||
|
// but preserve current width/height which may reflect a user drag.
|
||||||
|
if (nn.type === "group") {
|
||||||
|
const mergedStyle = {
|
||||||
|
...nn.style,
|
||||||
|
width: (existing.style as any)?.width,
|
||||||
|
height: (existing.style as any)?.height,
|
||||||
|
};
|
||||||
|
result.push({ ...existing, data: nn.data, style: mergedStyle });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Keep position and style, update data
|
// Keep position and style, update data
|
||||||
result.push({ ...existing, data: nn.data });
|
result.push({ ...existing, data: nn.data });
|
||||||
} else {
|
} else {
|
||||||
@@ -458,7 +621,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
|
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled, projectAliases, projectColors]);
|
||||||
|
|
||||||
// Recompute edges + handles on drag end (not every pixel)
|
// Recompute edges + handles on drag end (not every pixel)
|
||||||
const recomputeEdges = useCallback((currentNodes: Node[]) => {
|
const recomputeEdges = useCallback((currentNodes: Node[]) => {
|
||||||
@@ -566,11 +729,11 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
|
<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} />}
|
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
|
||||||
|
|
||||||
{/* Canvas — inset (only visible on dashboard) */}
|
{/* 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
|
<ReactFlow
|
||||||
onInit={(instance) => { reactFlowRef.current = instance; }}
|
onInit={(instance) => { reactFlowRef.current = instance; }}
|
||||||
nodes={dimmedNodes}
|
nodes={dimmedNodes}
|
||||||
@@ -641,7 +804,9 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
>
|
>
|
||||||
<Background color="#374151" gap={30} size={2} />
|
<Background color="#374151" gap={30} size={2} />
|
||||||
<Controls position="bottom-left" />
|
<Controls position="bottom-left">
|
||||||
|
<ExportPngButton onError={(msg) => pushActionError("dashboard", "export", msg)} />
|
||||||
|
</Controls>
|
||||||
<EdgeLegend />
|
<EdgeLegend />
|
||||||
<MiniMap
|
<MiniMap
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
@@ -659,7 +824,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
{/* Project filter */}
|
{/* Project filter */}
|
||||||
{projects.length > 1 && (
|
{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
|
<button
|
||||||
onClick={() => setFilterOpen((v) => !v)}
|
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"
|
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 +836,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
|
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{filterOpen && (
|
{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] max-h-[280px] overflow-y-auto">
|
<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 */}
|
{/* Select/Deselect all */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -702,11 +867,14 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const projectServices = services.filter((s) => s.project === p);
|
const projectServices = services.filter((s) => s.project === p);
|
||||||
const running = projectServices.filter((s) => s.state === "running").length;
|
const running = projectServices.filter((s) => s.state === "running").length;
|
||||||
const stopped = projectServices.length - running;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => toggleProject(p)}
|
onClick={() => toggleProject(p)}
|
||||||
title={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"
|
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 shrink-0 ${
|
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
|
||||||
@@ -714,7 +882,10 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}`}>
|
}`}>
|
||||||
{active && <Check size={12} className="text-white" />}
|
{active && <Check size={12} className="text-white" />}
|
||||||
</div>
|
</div>
|
||||||
<span className={`flex-1 min-w-0 truncate text-left ${active ? "text-slate-200" : "text-slate-500"}`}>{p}</span>
|
<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="ml-auto flex items-center gap-1.5 text-xs shrink-0">
|
||||||
<span className="text-emerald-500/70">{running}</span>
|
<span className="text-emerald-500/70">{running}</span>
|
||||||
<span className="text-slate-600">/</span>
|
<span className="text-slate-600">/</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
|
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
|
import { useUpdateInfo } from "../hooks/useUpdateInfo";
|
||||||
|
import { UpdateModal } from "./UpdateModal";
|
||||||
|
|
||||||
export type Page = "dashboard" | "monitoring" | "settings";
|
export type Page = "dashboard" | "monitoring" | "settings";
|
||||||
|
|
||||||
@@ -191,6 +193,8 @@ export function HeaderBar({
|
|||||||
onOpenServiceDetail,
|
onOpenServiceDetail,
|
||||||
}: HeaderBarProps) {
|
}: HeaderBarProps) {
|
||||||
const { t, lang, setLang } = useT();
|
const { t, lang, setLang } = useT();
|
||||||
|
const { info: updateInfo, showIndicator } = useUpdateInfo(token);
|
||||||
|
const [updateOpen, setUpdateOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
|
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
|
||||||
@@ -201,7 +205,8 @@ export function HeaderBar({
|
|||||||
<NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
|
<NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Center: Logo */}
|
{/* Center: Logo. The "subtitle" line shows the current version normally,
|
||||||
|
but is replaced by the "update available" badge when an update ships. */}
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
|
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
|
||||||
<img
|
<img
|
||||||
src="/alteonx-logo.webp"
|
src="/alteonx-logo.webp"
|
||||||
@@ -211,7 +216,21 @@ export function HeaderBar({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
|
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
|
||||||
|
{showIndicator && updateInfo?.latest ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setUpdateOpen(true)}
|
||||||
|
className="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-wider text-emerald-400 hover:text-emerald-300 transition-colors whitespace-nowrap -mt-1 animate-pulse hover:animate-none"
|
||||||
|
title={`v${updateInfo.current} → v${updateInfo.latest}`}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" />
|
||||||
|
</span>
|
||||||
|
{t("update.available")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
|
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,6 +286,13 @@ export function HeaderBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{updateOpen && updateInfo && (
|
||||||
|
<UpdateModal
|
||||||
|
info={updateInfo}
|
||||||
|
onClose={() => setUpdateOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X, ExternalLink, Copy, Check, Container, GitBranch, Github, Star } from "lucide-react";
|
||||||
|
import type { UpdateInfo, DeployMode } from "../../shared/types";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
interface UpdateModalProps {
|
||||||
|
info: UpdateInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = Exclude<DeployMode, "unknown">;
|
||||||
|
|
||||||
|
const COMMANDS: Record<TabKey, string> = {
|
||||||
|
ghcr: "docker compose pull && docker compose up -d",
|
||||||
|
source: "git pull && docker compose up -d --build",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdateModal({ info, onClose }: UpdateModalProps) {
|
||||||
|
const { t } = useT();
|
||||||
|
// Default tab: detected mode if it's ghcr or source, otherwise ghcr.
|
||||||
|
const initialTab: TabKey = info.deployMode === "source" ? "source" : "ghcr";
|
||||||
|
const [tab, setTab] = useState<TabKey>(initialTab);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Lock body scroll + block wheel events on canvas (React Flow zooms on wheel)
|
||||||
|
// while the modal is open. Restore on close.
|
||||||
|
useEffect(() => {
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const blockWheel = (e: WheelEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest("[data-update-modal]")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("wheel", blockWheel, { passive: false, capture: true });
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
document.removeEventListener("wheel", blockWheel, { capture: true } as any);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
const text = COMMANDS[tab];
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!ok) {
|
||||||
|
// Fallback for non-secure contexts (HTTP from LAN IP, older browsers).
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.left = "-9999px";
|
||||||
|
ta.style.top = "0";
|
||||||
|
ta.setAttribute("readonly", "");
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
ta.setSelectionRange(0, text.length);
|
||||||
|
ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
console.warn("ContainerFlow: clipboard copy failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseLines = info.releaseNotes ? info.releaseNotes.split("\n").slice(0, 15) : [];
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100000] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-update-modal
|
||||||
|
className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header — 3-row grid so version aligns with "X versions behind"
|
||||||
|
badge, and release-notes link aligns with the repo link. */}
|
||||||
|
<div className="relative grid grid-cols-[1fr_auto] gap-x-4 gap-y-1.5 px-5 pt-5 pb-3 border-b border-slate-800 items-center">
|
||||||
|
{/* Close button — absolutely positioned top-right so it doesn't
|
||||||
|
affect grid row heights. */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Row 1: label / (empty, X lives absolute) */}
|
||||||
|
<div className="text-xs uppercase tracking-wider text-emerald-400 font-semibold">
|
||||||
|
{t("update.available")}
|
||||||
|
</div>
|
||||||
|
<div className="w-5" /> {/* spacer matching X width */}
|
||||||
|
|
||||||
|
{/* Row 2: version / releases-behind badge */}
|
||||||
|
<div className="text-lg font-bold tracking-tight">
|
||||||
|
<span className="text-slate-300">v{info.current}</span>{" "}
|
||||||
|
<span className="text-slate-500">→</span>{" "}
|
||||||
|
<span className="text-emerald-400">v{info.latest}</span>
|
||||||
|
</div>
|
||||||
|
{info.releasesAhead > 1 ? (
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 whitespace-nowrap justify-self-end">
|
||||||
|
{t("update.releasesBehind").replace("{n}", String(info.releasesAhead))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: release notes link / repo link */}
|
||||||
|
{info.releaseUrl ? (
|
||||||
|
<a
|
||||||
|
href={info.releaseUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-cyan-400 hover:text-cyan-300 transition-colors w-fit"
|
||||||
|
>
|
||||||
|
{t("update.fullNotes")}
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={info.repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group inline-flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors whitespace-nowrap justify-self-end"
|
||||||
|
>
|
||||||
|
<Github size={12} />
|
||||||
|
{t("update.viewRepo")}
|
||||||
|
{info.stars !== null && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
· {info.stars.toLocaleString()}
|
||||||
|
<Star size={10} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release notes preview — caps height + scrolls when there are many changes */}
|
||||||
|
{releaseLines.length > 0 && (
|
||||||
|
<div className="px-5 py-4 border-b border-slate-800">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-slate-500 font-semibold mb-2">
|
||||||
|
{t("update.whatsNew")}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 max-h-40 overflow-y-auto pr-1">
|
||||||
|
{releaseLines.map((line, i) => (
|
||||||
|
<li key={i} className="text-sm text-slate-300 leading-relaxed flex gap-2">
|
||||||
|
<span className="text-slate-600 shrink-0">•</span>
|
||||||
|
<span>{line}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* How to update — tabs */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-slate-500 font-semibold mb-2">
|
||||||
|
{t("update.howToUpdate")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab buttons */}
|
||||||
|
<div className="flex items-center gap-1 mb-3 bg-slate-800/50 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("ghcr")}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
tab === "ghcr"
|
||||||
|
? "bg-slate-700 text-white"
|
||||||
|
: "text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Container size={13} />
|
||||||
|
{t("update.tabGhcr")}
|
||||||
|
{info.deployMode === "ghcr" && (
|
||||||
|
<span className="text-[9px] text-emerald-400 ml-0.5">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("source")}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
tab === "source"
|
||||||
|
? "bg-slate-700 text-white"
|
||||||
|
: "text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitBranch size={13} />
|
||||||
|
{t("update.tabSource")}
|
||||||
|
{info.deployMode === "source" && (
|
||||||
|
<span className="text-[9px] text-emerald-400 ml-0.5">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab description */}
|
||||||
|
<div className="text-[11px] text-slate-500 mb-2">
|
||||||
|
{tab === "ghcr" ? t("update.ghcrHint") : t("update.sourceHint")}
|
||||||
|
{info.deployMode === tab && (
|
||||||
|
<span className="text-emerald-400 ml-1.5">· {t("update.detectedMode")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command block */}
|
||||||
|
<div className="relative bg-slate-950 border border-slate-800 rounded-md p-3 pr-12 font-mono text-[11px] text-slate-200 overflow-x-auto">
|
||||||
|
<code className="whitespace-pre">{COMMANDS[tab]}</code>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||||
|
title={copied ? t("update.copied") : t("update.copyCommand")}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
+30
-10
@@ -9,9 +9,15 @@ export const GROUP_PADDING = 28;
|
|||||||
export const GROUP_HEADER = 44;
|
export const GROUP_HEADER = 44;
|
||||||
const GROUP_GAP = 50;
|
const GROUP_GAP = 50;
|
||||||
|
|
||||||
function getComposeKey(file: string): string {
|
export function getComposeKey(file: string): string {
|
||||||
if (!file) return "default";
|
if (!file) return "default";
|
||||||
const match = file.match(/docker-compose\.?(.*)\.yml/);
|
// When multiple compose files are merged (COMPOSE_FILE env var with
|
||||||
|
// multiple paths), the docker `config_files` label is a comma-joined list.
|
||||||
|
// Use the LAST path — in docker-compose, the override file wins, and its
|
||||||
|
// name (e.g. "local", "dev") is the meaningful environment key.
|
||||||
|
const files = file.split(",");
|
||||||
|
const primary = files[files.length - 1] || file;
|
||||||
|
const match = primary.match(/docker-compose\.?(.*)\.yml/);
|
||||||
const key = match?.[1] || "";
|
const key = match?.[1] || "";
|
||||||
if (key === "") return "prod";
|
if (key === "") return "prod";
|
||||||
return key.replace(/^\./, "");
|
return key.replace(/^\./, "");
|
||||||
@@ -107,7 +113,10 @@ export function buildLayout(
|
|||||||
const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X;
|
const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X;
|
||||||
const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y;
|
const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y;
|
||||||
const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3);
|
const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3);
|
||||||
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_HEADER + GROUP_PADDING;
|
// Vertical: header + top padding + content + bottom padding + footer reserve.
|
||||||
|
// Keeps top/bottom margins symmetric and leaves room for the subtitle footer.
|
||||||
|
const FOOTER_RESERVE = 22;
|
||||||
|
const groupHeight = GROUP_HEADER + GROUP_PADDING + contentHeight + GROUP_PADDING + FOOTER_RESERVE;
|
||||||
|
|
||||||
groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight });
|
groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight });
|
||||||
|
|
||||||
@@ -121,18 +130,25 @@ export function buildLayout(
|
|||||||
const bgColor = knownBg || dynamic!.bg;
|
const bgColor = knownBg || dynamic!.bg;
|
||||||
const borderColor = knownBorder || dynamic!.border;
|
const borderColor = knownBorder || dynamic!.border;
|
||||||
|
|
||||||
// Compose file subtitle — show unique compose files in this group
|
// Subtitle: the compose filename(s). For COMPOSE_FILE merges, show the
|
||||||
const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))]
|
// override (last file) since that's what defines the runtime config.
|
||||||
|
// Containers without compose labels (plain `docker run`) fall back to "docker".
|
||||||
|
const composeFiles = [...new Set(svcs.map((s) => {
|
||||||
|
const parts = (s.compose_file || "").split(",");
|
||||||
|
return parts[parts.length - 1] || s.compose_file;
|
||||||
|
}).filter(Boolean))]
|
||||||
.map((f) => f.split("/").pop() || "")
|
.map((f) => f.split("/").pop() || "")
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const subtitle = composeFiles.join(", ");
|
const subtitle = composeFiles.length > 0 ? composeFiles.join(", ") : "docker";
|
||||||
|
|
||||||
// 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({
|
nodes.push({
|
||||||
id: `group-${groupKey}`,
|
id: `group-${groupKey}`,
|
||||||
type: "group",
|
type: "group",
|
||||||
position: { x: groupX, y: 0 },
|
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: {
|
style: {
|
||||||
width: groupWidth,
|
width: groupWidth,
|
||||||
height: groupHeight,
|
height: groupHeight,
|
||||||
@@ -146,11 +162,15 @@ export function buildLayout(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Service nodes inside group (grid layout)
|
// Service nodes inside group (grid layout).
|
||||||
|
// Horizontally center the content within the group: when there's only
|
||||||
|
// one service (or when groupWidth was bumped to its minimum), the row
|
||||||
|
// would otherwise sit left-aligned with extra space on the right.
|
||||||
|
const horizontalCenter = (groupWidth - contentWidth) / 2;
|
||||||
svcs.forEach((svc, i) => {
|
svcs.forEach((svc, i) => {
|
||||||
const col = i % cols;
|
const col = i % cols;
|
||||||
const row = Math.floor(i / cols);
|
const row = Math.floor(i / cols);
|
||||||
const x = GROUP_PADDING + col * (NODE_WIDTH + NODE_GAP_X);
|
const x = horizontalCenter + col * (NODE_WIDTH + NODE_GAP_X);
|
||||||
const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y);
|
const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y);
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
|
|||||||
@@ -1,8 +1,46 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError, EventLogEntry, NotificationLogEntry } from "../../shared/types";
|
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, GraphDiff, ActionError, EventLogEntry, NotificationLogEntry } from "../../shared/types";
|
||||||
import type { StatsStore } from "./useStatsStore";
|
import type { StatsStore } from "./useStatsStore";
|
||||||
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
|
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
|
||||||
|
|
||||||
|
function connKey(c: Connection): string {
|
||||||
|
return `${c.from}|${c.to}|${c.network}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyServicesDiff(current: Service[], diff: GraphDiff): Service[] {
|
||||||
|
let result = [...current];
|
||||||
|
if (diff.servicesRemoved?.length) {
|
||||||
|
const removed = new Set(diff.servicesRemoved);
|
||||||
|
result = result.filter((s) => !removed.has(s.uid));
|
||||||
|
}
|
||||||
|
if (diff.servicesUpdated?.length) {
|
||||||
|
const updated = new Map(diff.servicesUpdated.map((s) => [s.uid, s]));
|
||||||
|
result = result.map((s) => updated.get(s.uid) ?? s);
|
||||||
|
}
|
||||||
|
if (diff.servicesAdded?.length) {
|
||||||
|
const existing = new Set(result.map((s) => s.uid));
|
||||||
|
for (const s of diff.servicesAdded) {
|
||||||
|
if (!existing.has(s.uid)) result.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConnectionsDiff(current: Connection[], diff: GraphDiff): Connection[] {
|
||||||
|
let result = [...current];
|
||||||
|
if (diff.connectionsRemoved?.length) {
|
||||||
|
const removed = new Set(diff.connectionsRemoved);
|
||||||
|
result = result.filter((c) => !removed.has(connKey(c)));
|
||||||
|
}
|
||||||
|
if (diff.connectionsAdded?.length) {
|
||||||
|
const existing = new Set(result.map(connKey));
|
||||||
|
for (const c of diff.connectionsAdded) {
|
||||||
|
if (!existing.has(connKey(c))) result.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [connections, setConnections] = useState<Connection[]>([]);
|
const [connections, setConnections] = useState<Connection[]>([]);
|
||||||
@@ -86,19 +124,24 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (msg.type as WSMessage["type"]) {
|
switch (msg.type as WSMessage["type"]) {
|
||||||
case "services": {
|
case "snapshot": {
|
||||||
lastRawServicesRef.current = msg.data as Service[];
|
lastRawServicesRef.current = msg.data.services as Service[];
|
||||||
const incoming = applyProcessing(msg.data as Service[]);
|
const incoming = applyProcessing(msg.data.services as Service[]);
|
||||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||||
|
setConnections(msg.data.connections as Connection[]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "connections":
|
case "diff": {
|
||||||
setConnections((prev) => {
|
const diff = msg.data as GraphDiff;
|
||||||
if (prev.length === msg.data.length &&
|
const nextRaw = applyServicesDiff(lastRawServicesRef.current, diff);
|
||||||
prev.every((c: any, i: number) => c.from === msg.data[i].from && c.to === msg.data[i].to)) return prev;
|
lastRawServicesRef.current = nextRaw;
|
||||||
return msg.data;
|
const incoming = applyProcessing(nextRaw);
|
||||||
});
|
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||||
|
if (diff.connectionsAdded?.length || diff.connectionsRemoved?.length) {
|
||||||
|
setConnections((prev) => applyConnectionsDiff(prev, diff));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "stats": {
|
case "stats": {
|
||||||
for (const s of msg.data) {
|
for (const s of msg.data) {
|
||||||
statsRef.current.set(s.service, s);
|
statsRef.current.set(s.service, s);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { UpdateInfo } from "../../shared/types";
|
||||||
|
|
||||||
|
/** Hook for in-app update notification.
|
||||||
|
* - Fetches /api/update-info on mount + on window focus (server caches 6h)
|
||||||
|
* - Indicator persists while `updateAvailable` is true — no dismiss option
|
||||||
|
* by design, so users actually update instead of silencing the prompt.
|
||||||
|
*/
|
||||||
|
export function useUpdateInfo(token: string) {
|
||||||
|
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/update-info", { headers });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as UpdateInfo;
|
||||||
|
setInfo(data);
|
||||||
|
} catch {
|
||||||
|
// Network errors are silent — no notification shown
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
const onFocus = () => refetch();
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const showIndicator = Boolean(info?.updateAvailable && info.latest);
|
||||||
|
|
||||||
|
return { info, showIndicator, refetch };
|
||||||
|
}
|
||||||
@@ -20,6 +20,35 @@ const en = {
|
|||||||
"filter.projects": "Projects",
|
"filter.projects": "Projects",
|
||||||
"filter.all": "All",
|
"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",
|
||||||
|
"group.changeColor": "Change color",
|
||||||
|
"group.resetColor": "Reset color",
|
||||||
|
|
||||||
|
// Update notification
|
||||||
|
"update.available": "Update available",
|
||||||
|
"update.whatsNew": "What's new",
|
||||||
|
"update.howToUpdate": "How to update",
|
||||||
|
"update.tabGhcr": "Prebuilt image",
|
||||||
|
"update.tabSource": "Local build",
|
||||||
|
"update.ghcrHint": "If you pulled the image from GitHub Container Registry.",
|
||||||
|
"update.sourceHint": "If you cloned the repo and build from source.",
|
||||||
|
"update.detectedMode": "Detected",
|
||||||
|
"update.copyCommand": "Copy command",
|
||||||
|
"update.copied": "Copied",
|
||||||
|
"update.fullNotes": "See full release notes",
|
||||||
|
"update.viewRepo": "View on GitHub",
|
||||||
|
"update.releasesBehind": "{n} versions behind",
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
"login.connecting": "Connecting...",
|
"login.connecting": "Connecting...",
|
||||||
"login.connect": "Connect",
|
"login.connect": "Connect",
|
||||||
@@ -279,6 +308,35 @@ const es: Record<TranslationKey, string> = {
|
|||||||
"filter.projects": "Proyectos",
|
"filter.projects": "Proyectos",
|
||||||
"filter.all": "Todos",
|
"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.changeColor": "Cambiar color",
|
||||||
|
"group.resetColor": "Restaurar color",
|
||||||
|
|
||||||
|
// Update notification
|
||||||
|
"update.available": "Nueva versión",
|
||||||
|
"update.whatsNew": "Qué hay de nuevo",
|
||||||
|
"update.howToUpdate": "Cómo actualizar",
|
||||||
|
"update.tabGhcr": "Imagen prebuilt",
|
||||||
|
"update.tabSource": "Build local",
|
||||||
|
"update.ghcrHint": "Si descargaste la imagen desde GitHub Container Registry.",
|
||||||
|
"update.sourceHint": "Si clonaste el repo y construís desde código fuente.",
|
||||||
|
"update.detectedMode": "Detectado",
|
||||||
|
"update.copyCommand": "Copiar comando",
|
||||||
|
"update.copied": "Copiado",
|
||||||
|
"update.fullNotes": "Ver notas completas",
|
||||||
|
"update.viewRepo": "Ver repositorio",
|
||||||
|
"update.releasesBehind": "{n} versiones atrás",
|
||||||
|
"group.cancelAlias": "Cancelar",
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
"login.connecting": "Conectando...",
|
"login.connecting": "Conectando...",
|
||||||
"login.connect": "Conectar",
|
"login.connect": "Conectar",
|
||||||
|
|||||||
+307
-17
@@ -1,14 +1,36 @@
|
|||||||
import { memo } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import type { NodeProps } from "@xyflow/react";
|
import type { NodeProps } from "@xyflow/react";
|
||||||
import { Server, Wrench, Rocket, Box, Folder } from "lucide-react";
|
import { Server, Wrench, Rocket, Box, Folder, Container, Pencil, RotateCcw, Check, X, Palette } from "lucide-react";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
interface GroupNodeData {
|
interface GroupNodeData {
|
||||||
label: string;
|
label: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
count?: number;
|
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;
|
||||||
|
/** Current custom hex color (e.g. "#3b82f6"), if any. */
|
||||||
|
color?: string;
|
||||||
|
/** Save handler — called with (project, newAlias). Empty newAlias = reset. */
|
||||||
|
onAliasChange?: (project: string, newAlias: string) => void;
|
||||||
|
/** Color change handler — empty color = reset to default palette. */
|
||||||
|
onColorChange?: (project: string, color: string) => void;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Palette shown when user clicks the color dot. First entry resets to default.
|
||||||
|
const COLOR_PALETTE: { hex: string; name: string }[] = [
|
||||||
|
{ hex: "#3b82f6", name: "blue" },
|
||||||
|
{ hex: "#8b5cf6", name: "purple" },
|
||||||
|
{ hex: "#06b6d4", name: "cyan" },
|
||||||
|
{ hex: "#22c55e", name: "green" },
|
||||||
|
{ hex: "#f59e0b", name: "yellow" },
|
||||||
|
{ hex: "#ef4444", name: "red" },
|
||||||
|
];
|
||||||
|
|
||||||
const groupConfig: Record<string, { icon: typeof Server; color: string; borderColor: string }> = {
|
const groupConfig: Record<string, { icon: typeof Server; color: string; borderColor: string }> = {
|
||||||
INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" },
|
INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" },
|
||||||
DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" },
|
DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" },
|
||||||
@@ -35,33 +57,244 @@ function getProjectColor(label: string) {
|
|||||||
return assignedColors.get(label)!;
|
return assignedColors.get(label)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
|
export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
|
||||||
|
const { t } = useT();
|
||||||
const d = data as unknown as GroupNodeData;
|
const d = data as unknown as GroupNodeData;
|
||||||
// Label is "PROJECT / COMPOSE" — match compose part for known colors
|
// Label is "PROJECT / COMPOSE" (uppercase). We let users alias only the
|
||||||
const parts = d.label.split(" / ");
|
// project portion — the compose suffix (DEV / PROD / INFRA / docker-compose
|
||||||
const composePart = parts.length > 1 ? parts[parts.length - 1] : d.label;
|
// file name) stays as a structural hint and is also used for the icon match.
|
||||||
const known = groupConfig[composePart];
|
const labelParts = d.label.split(" / ");
|
||||||
|
const projectPart = labelParts[0] || d.label;
|
||||||
|
const composePart = labelParts.length > 1 ? labelParts.slice(1).join(" / ") : "";
|
||||||
|
// Standalone containers (no compose) are grouped under project="docker" with
|
||||||
|
// compose key "default". For those, drop the suffix from the title and use a
|
||||||
|
// distinct icon so the group reads as "containers running directly on docker".
|
||||||
|
const isStandalone = d.project === "docker";
|
||||||
|
const iconKey = composePart || d.label;
|
||||||
|
const known = groupConfig[iconKey];
|
||||||
const proj = known ? null : getProjectColor(d.label);
|
const proj = known ? null : getProjectColor(d.label);
|
||||||
const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor };
|
const baseConfig = isStandalone
|
||||||
|
? { icon: Container, color: "#94a3b8", borderColor: "rgba(148, 163, 184, 0.3)" }
|
||||||
|
: known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor };
|
||||||
|
// Override the color when the user has picked a custom one for this project.
|
||||||
|
const config = d.color
|
||||||
|
? { icon: baseConfig.icon, color: d.color, borderColor: hexToRgba(d.color, 0.3) }
|
||||||
|
: baseConfig;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const hasAlias = Boolean(d.alias && d.alias.trim().length > 0);
|
||||||
|
const projectDisplay = hasAlias ? d.alias! : projectPart;
|
||||||
|
const displayName = composePart && !isStandalone ? `${projectDisplay} / ${composePart}` : projectDisplay;
|
||||||
|
const canEdit = Boolean(d.project && d.onAliasChange);
|
||||||
|
const canColor = Boolean(d.project && d.onColorChange);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(projectDisplay);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Color palette popover
|
||||||
|
const colorBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [palettePos, setPalettePos] = useState<{ left: number; top: number } | null>(null);
|
||||||
|
const openPalette = () => {
|
||||||
|
const el = colorBtnRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setPalettePos({ left: rect.left + rect.width / 2, top: rect.bottom + 6 });
|
||||||
|
};
|
||||||
|
const closePalette = () => setPalettePos(null);
|
||||||
|
const pickColor = (hex: string) => {
|
||||||
|
if (!d.project) return;
|
||||||
|
d.onColorChange?.(d.project, hex);
|
||||||
|
closePalette();
|
||||||
|
};
|
||||||
|
// Close palette on outside click / Esc / wheel (zoom) / canvas pan.
|
||||||
|
// Palette uses fixed positioning so it'd visually detach from the button on
|
||||||
|
// pan/zoom — close instead of trying to follow. Use capture phase + pointer
|
||||||
|
// events because React Flow's pan handlers stop mousedown propagation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!palettePos) return;
|
||||||
|
const onDown = (e: Event) => {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.closest("[data-color-palette]") || t.closest("[data-color-btn]")) return;
|
||||||
|
closePalette();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closePalette(); };
|
||||||
|
const onWheel = () => closePalette();
|
||||||
|
document.addEventListener("pointerdown", onDown, true);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
|
||||||
|
window.addEventListener("resize", closePalette);
|
||||||
|
window.addEventListener("blur", closePalette);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onDown, true);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.removeEventListener("wheel", onWheel, { capture: true } as any);
|
||||||
|
window.removeEventListener("resize", closePalette);
|
||||||
|
window.removeEventListener("blur", closePalette);
|
||||||
|
};
|
||||||
|
}, [palettePos]);
|
||||||
|
|
||||||
|
// Footer tooltip: only show when text is actually clipped (`...`).
|
||||||
|
const footerRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const [footerTip, setFooterTip] = useState<{ left: number; top: number } | null>(null);
|
||||||
|
|
||||||
|
const onFooterEnter = () => {
|
||||||
|
const el = footerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollWidth <= el.clientWidth) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setFooterTip({ left: rect.left + rect.width / 2, top: rect.top - 6 });
|
||||||
|
};
|
||||||
|
const onFooterLeave = () => setFooterTip(null);
|
||||||
|
|
||||||
|
// Shared tooltip state for header buttons (pencil / reset / color / save / cancel).
|
||||||
|
// Uses the same visual style as the Tooltip component (slate-700 bg, slate-600 border).
|
||||||
|
const [btnTip, setBtnTip] = useState<{ text: string; left: number; top: number } | null>(null);
|
||||||
|
const showBtnTip = (e: React.MouseEvent<HTMLElement>, text: string) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setBtnTip({ text, left: rect.left + rect.width / 2, top: rect.top - 8 });
|
||||||
|
};
|
||||||
|
const hideBtnTip = () => setBtnTip(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 (
|
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
|
<span
|
||||||
className="text-sm font-semibold tracking-wider uppercase"
|
className="text-sm font-semibold tracking-wider uppercase whitespace-nowrap"
|
||||||
style={{ color: config.color }}
|
style={{ color: config.color }}
|
||||||
>
|
>
|
||||||
{d.label}
|
/ {composePart}
|
||||||
</span>
|
|
||||||
{d.subtitle && (
|
|
||||||
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]">
|
|
||||||
{d.subtitle}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} />
|
<button
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); commit(); }}
|
||||||
|
onMouseEnter={(e) => showBtnTip(e, t("group.saveAlias"))}
|
||||||
|
onMouseLeave={hideBtnTip}
|
||||||
|
className="text-emerald-400 hover:text-emerald-300 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); cancel(); }}
|
||||||
|
onMouseEnter={(e) => showBtnTip(e, t("group.cancelAlias"))}
|
||||||
|
onMouseLeave={hideBtnTip}
|
||||||
|
className="text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold tracking-wider uppercase whitespace-nowrap truncate min-w-0 ${canEdit ? "cursor-pointer hover:opacity-80" : ""}`}
|
||||||
|
style={{ color: config.color }}
|
||||||
|
onClick={canEdit ? startEdit : undefined}
|
||||||
|
title={displayName}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); startEdit(); }}
|
||||||
|
onMouseEnter={(e) => showBtnTip(e, t("group.rename"))}
|
||||||
|
onMouseLeave={hideBtnTip}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity shrink-0"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasAlias && canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); reset(); }}
|
||||||
|
onMouseEnter={(e) => showBtnTip(e, t("group.resetAlias"))}
|
||||||
|
onMouseLeave={hideBtnTip}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity shrink-0"
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canColor && (
|
||||||
|
<button
|
||||||
|
ref={colorBtnRef}
|
||||||
|
data-color-btn
|
||||||
|
onClick={(e) => { e.stopPropagation(); palettePos ? closePalette() : openPalette(); }}
|
||||||
|
onMouseEnter={(e) => showBtnTip(e, t("group.changeColor"))}
|
||||||
|
onMouseLeave={hideBtnTip}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 w-3 h-3 rounded-full border border-slate-600/60 hover:scale-110 transition-transform"
|
||||||
|
style={{ backgroundColor: config.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 h-px min-w-2" style={{ backgroundColor: config.borderColor }} />
|
||||||
{d.count != null && (
|
{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 }} />
|
<Box size={12} style={{ color: config.borderColor }} />
|
||||||
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
|
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
|
||||||
{d.count}
|
{d.count}
|
||||||
@@ -69,5 +302,62 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{d.subtitle && (
|
||||||
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center px-4">
|
||||||
|
<span
|
||||||
|
ref={footerRef}
|
||||||
|
onMouseEnter={onFooterEnter}
|
||||||
|
onMouseLeave={onFooterLeave}
|
||||||
|
className="text-[10px] text-slate-500 hover:text-slate-300 font-mono truncate max-w-[80%] tracking-wide transition-colors cursor-default"
|
||||||
|
>
|
||||||
|
{d.subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{footerTip && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] pointer-events-none px-2 py-0.5 bg-slate-700 border border-slate-600 rounded-md text-[11px] leading-tight text-slate-200 whitespace-nowrap shadow-xl"
|
||||||
|
style={{ left: footerTip.left, top: footerTip.top, transform: "translate(-50%, -100%)" }}
|
||||||
|
>
|
||||||
|
{d.subtitle}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
{btnTip && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] pointer-events-none px-2 py-0.5 bg-slate-700 border border-slate-600 rounded-md text-[11px] leading-tight text-slate-200 whitespace-nowrap shadow-xl"
|
||||||
|
style={{ left: btnTip.left, top: btnTip.top, transform: "translate(-50%, -100%)" }}
|
||||||
|
>
|
||||||
|
{btnTip.text}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
{palettePos && createPortal(
|
||||||
|
<div
|
||||||
|
data-color-palette
|
||||||
|
className="fixed z-50 flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 shadow-lg"
|
||||||
|
style={{ left: palettePos.left, top: palettePos.top, transform: "translateX(-50%)" }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{COLOR_PALETTE.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.hex}
|
||||||
|
onClick={(e) => { e.stopPropagation(); pickColor(c.hex); }}
|
||||||
|
className="w-5 h-5 rounded-full border border-slate-600/80 hover:scale-110 transition-transform"
|
||||||
|
style={{ backgroundColor: c.hex }}
|
||||||
|
title={c.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="w-px h-5 bg-slate-700" />
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); closePalette(); }}
|
||||||
|
className="w-5 h-5 rounded-full border border-slate-600/80 hover:bg-slate-800 flex items-center justify-center text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { StatsCard } from "../components/StatsCard";
|
|||||||
import { ThresholdBar } from "../components/ThresholdBar";
|
import { ThresholdBar } from "../components/ThresholdBar";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { guessIcon } from "../nodes/ServiceNode";
|
import { guessIcon } from "../nodes/ServiceNode";
|
||||||
|
import { getComposeKey } from "../engine/layout";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Math.floor((Date.now() / 1000) - ts);
|
const diff = Math.floor((Date.now() / 1000) - ts);
|
||||||
@@ -85,9 +86,10 @@ interface MonitoringPageProps {
|
|||||||
eventLogStream: EventLogEntry[];
|
eventLogStream: EventLogEntry[];
|
||||||
notificationStream: NotificationLogEntry[];
|
notificationStream: NotificationLogEntry[];
|
||||||
onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void;
|
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 { t } = useT();
|
||||||
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
|
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
|
||||||
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
|
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
|
||||||
@@ -174,7 +176,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
|
|||||||
const projects = new Set<string>();
|
const projects = new Set<string>();
|
||||||
for (const svc of allServiceNames) {
|
for (const svc of allServiceNames) {
|
||||||
const slash = svc.indexOf("/");
|
const slash = svc.indexOf("/");
|
||||||
projects.add(slash >= 0 ? svc.slice(0, slash) : "standalone");
|
projects.add(slash >= 0 ? svc.slice(0, slash) : "docker");
|
||||||
}
|
}
|
||||||
return [...projects].sort();
|
return [...projects].sort();
|
||||||
}, [allServiceNames]);
|
}, [allServiceNames]);
|
||||||
@@ -184,7 +186,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
|
|||||||
if (selectedProjects.size === 0) return allServiceNames;
|
if (selectedProjects.size === 0) return allServiceNames;
|
||||||
return allServiceNames.filter((svc) => {
|
return allServiceNames.filter((svc) => {
|
||||||
const slash = svc.indexOf("/");
|
const slash = svc.indexOf("/");
|
||||||
const project = slash >= 0 ? svc.slice(0, slash) : "standalone";
|
const project = slash >= 0 ? svc.slice(0, slash) : "docker";
|
||||||
return selectedProjects.has(project);
|
return selectedProjects.has(project);
|
||||||
});
|
});
|
||||||
}, [allServiceNames, selectedProjects]);
|
}, [allServiceNames, selectedProjects]);
|
||||||
@@ -235,12 +237,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
|
const aliasOrName = (p: string) => projectAliases[p] || p;
|
||||||
const projectLabel = selectedProjects.size === 0
|
const projectLabel = selectedProjects.size === 0
|
||||||
? t("monitoring.filterProject")
|
? t("monitoring.filterProject")
|
||||||
: selectedProjects.size === allProjects.length
|
: selectedProjects.size === allProjects.length
|
||||||
? t("monitoring.allProjects")
|
? t("monitoring.allProjects")
|
||||||
: selectedProjects.size === 1
|
: selectedProjects.size === 1
|
||||||
? [...selectedProjects][0]
|
? aliasOrName([...selectedProjects][0])
|
||||||
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`;
|
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`;
|
||||||
|
|
||||||
const serviceLabel = selectedServices.size === 0
|
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" />
|
<div className="border-t border-slate-700/50 my-1" />
|
||||||
{allProjects.map((project) => {
|
{allProjects.map((project) => {
|
||||||
const isSelected = selectedProjects.has(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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={project}
|
key={project}
|
||||||
onClick={() => toggleProject(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"
|
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 ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
|
||||||
}`}>
|
}`}>
|
||||||
{isSelected && <Check size={12} className="text-white" />}
|
{isSelected && <Check size={12} className="text-white" />}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -453,7 +462,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
|
|||||||
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
|
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
|
||||||
: `${selectedServices.size} ${t("footer.containers")}`)
|
: `${selectedServices.size} ${t("footer.containers")}`)
|
||||||
: selectedProjects.size === 1
|
: 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
|
: selectedProjects.size === allProjects.length
|
||||||
? t("monitoring.allProjects")
|
? t("monitoring.allProjects")
|
||||||
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`
|
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`
|
||||||
@@ -478,6 +493,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
|
|||||||
globalRange={statsRange}
|
globalRange={statsRange}
|
||||||
fallbackData={filteredHistory[svc] || []}
|
fallbackData={filteredHistory[svc] || []}
|
||||||
token={token}
|
token={token}
|
||||||
|
projectAliases={projectAliases}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +597,7 @@ function MonitoringTotalsCard({
|
|||||||
return (
|
return (
|
||||||
<div className="px-5 py-3 border-b border-slate-700/40 bg-slate-900/40">
|
<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">
|
<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>
|
<span className="text-[10px] text-slate-500">· {containerCount} {t("footer.containers")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -637,6 +653,7 @@ interface MonitoringServiceCardProps {
|
|||||||
globalRange: StatsRange;
|
globalRange: StatsRange;
|
||||||
fallbackData: StatsHistoryPoint[];
|
fallbackData: StatsHistoryPoint[];
|
||||||
token: string;
|
token: string;
|
||||||
|
projectAliases: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MonitoringServiceCard({
|
function MonitoringServiceCard({
|
||||||
@@ -655,6 +672,7 @@ function MonitoringServiceCard({
|
|||||||
globalRange,
|
globalRange,
|
||||||
fallbackData,
|
fallbackData,
|
||||||
token,
|
token,
|
||||||
|
projectAliases,
|
||||||
}: MonitoringServiceCardProps) {
|
}: MonitoringServiceCardProps) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [localRange, setLocalRange] = useState<StatsRange | null>(null);
|
const [localRange, setLocalRange] = useState<StatsRange | null>(null);
|
||||||
@@ -692,9 +710,17 @@ function MonitoringServiceCard({
|
|||||||
<ServiceIcon uid={svc} services={services} />
|
<ServiceIcon uid={svc} services={services} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
|
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
|
||||||
{svc.includes("/") && (
|
{svc.includes("/") && (() => {
|
||||||
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span>
|
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>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Per-card range buttons */}
|
{/* Per-card range buttons */}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { computeGraphDiff, connectionKey } from "./diff";
|
||||||
|
import type { Service, Connection } from "../shared/types";
|
||||||
|
|
||||||
|
function svc(uid: string, overrides: Partial<Service> = {}): Service {
|
||||||
|
return {
|
||||||
|
id: uid,
|
||||||
|
uid,
|
||||||
|
name: uid.split("/")[1] ?? uid,
|
||||||
|
image: "nginx:latest",
|
||||||
|
state: "running",
|
||||||
|
status: "Up 2 hours",
|
||||||
|
ports: [],
|
||||||
|
networks: ["bridge"],
|
||||||
|
network_ips: {},
|
||||||
|
project: uid.split("/")[0] ?? "proj",
|
||||||
|
compose_file: "/app/docker-compose.yml",
|
||||||
|
env: [],
|
||||||
|
restart_policy: "unless-stopped",
|
||||||
|
memory_limit: 0,
|
||||||
|
cpu_quota: 0,
|
||||||
|
health_status: "",
|
||||||
|
health_log: [],
|
||||||
|
exit_code: 0,
|
||||||
|
restart_count: 0,
|
||||||
|
oom_killed: false,
|
||||||
|
mounts: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function conn(from: string, to: string, network = "bridge"): Connection {
|
||||||
|
return { from, to, network };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeGraphDiff", () => {
|
||||||
|
test("no-op returns null", () => {
|
||||||
|
const services = [svc("p/a"), svc("p/b")];
|
||||||
|
const connections = [conn("p/a", "p/b")];
|
||||||
|
expect(computeGraphDiff(services, services, connections, connections)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add only", () => {
|
||||||
|
const prev = [svc("p/a")];
|
||||||
|
const next = [svc("p/a"), svc("p/b")];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff).not.toBeNull();
|
||||||
|
expect(diff!.servicesAdded).toHaveLength(1);
|
||||||
|
expect(diff!.servicesAdded![0].uid).toBe("p/b");
|
||||||
|
expect(diff!.servicesRemoved).toBeUndefined();
|
||||||
|
expect(diff!.servicesUpdated).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remove only", () => {
|
||||||
|
const prev = [svc("p/a"), svc("p/b")];
|
||||||
|
const next = [svc("p/a")];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff!.servicesRemoved).toEqual(["p/b"]);
|
||||||
|
expect(diff!.servicesAdded).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update only — state change", () => {
|
||||||
|
const prev = [svc("p/a", { state: "running" })];
|
||||||
|
const next = [svc("p/a", { state: "exited", exit_code: 1 })];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff!.servicesUpdated).toHaveLength(1);
|
||||||
|
expect(diff!.servicesUpdated![0].state).toBe("exited");
|
||||||
|
expect(diff!.servicesAdded).toBeUndefined();
|
||||||
|
expect(diff!.servicesRemoved).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update only — restart_count increment", () => {
|
||||||
|
const prev = [svc("p/a", { restart_count: 0 })];
|
||||||
|
const next = [svc("p/a", { restart_count: 1 })];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff!.servicesUpdated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status string change does NOT trigger update", () => {
|
||||||
|
const prev = [svc("p/a", { status: "Up 1 minute" })];
|
||||||
|
const next = [svc("p/a", { status: "Up 2 hours" })];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("health_log change does NOT trigger update", () => {
|
||||||
|
const prev = [svc("p/a", { health_log: ["healthy at 10:00"] })];
|
||||||
|
const next = [svc("p/a", { health_log: ["healthy at 11:00"] })];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mixed: add + remove + update", () => {
|
||||||
|
const prev = [svc("p/a"), svc("p/b"), svc("p/c")];
|
||||||
|
const next = [svc("p/a", { state: "exited" }), svc("p/d")];
|
||||||
|
const diff = computeGraphDiff(prev, next, [], []);
|
||||||
|
expect(diff!.servicesAdded?.map((s) => s.uid)).toEqual(["p/d"]);
|
||||||
|
expect(diff!.servicesRemoved?.sort()).toEqual(["p/b", "p/c"]);
|
||||||
|
expect(diff!.servicesUpdated?.map((s) => s.uid)).toEqual(["p/a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connection add only", () => {
|
||||||
|
const svcs = [svc("p/a"), svc("p/b")];
|
||||||
|
const diff = computeGraphDiff(svcs, svcs, [], [conn("p/a", "p/b")]);
|
||||||
|
expect(diff!.connectionsAdded).toHaveLength(1);
|
||||||
|
expect(diff!.connectionsRemoved).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connection remove only", () => {
|
||||||
|
const svcs = [svc("p/a"), svc("p/b")];
|
||||||
|
const diff = computeGraphDiff(svcs, svcs, [conn("p/a", "p/b")], []);
|
||||||
|
expect(diff!.connectionsRemoved).toHaveLength(1);
|
||||||
|
expect(diff!.connectionsRemoved![0]).toBe("p/a|p/b|bridge");
|
||||||
|
expect(diff!.connectionsAdded).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connectionKey format", () => {
|
||||||
|
expect(connectionKey(conn("p/a", "p/b", "mynet"))).toBe("p/a|p/b|mynet");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Service, Connection, GraphDiff } from "../shared/types";
|
||||||
|
|
||||||
|
/** Stable fingerprint of a Service for change detection.
|
||||||
|
* Excludes `status` (verbose uptime string that changes every minute) and
|
||||||
|
* `health_log` (changes on every health-check interval). */
|
||||||
|
function serviceSignature(s: Service): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
s.state, s.image, s.restart_count, s.health_status,
|
||||||
|
s.exit_code, s.oom_killed, s.restart_policy, s.memory_limit, s.cpu_quota,
|
||||||
|
s.ports, s.networks, s.network_ips, s.env, s.mounts, s.compose_file,
|
||||||
|
s.name, s.project,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectionKey(c: Connection): string {
|
||||||
|
return `${c.from}|${c.to}|${c.network}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a GraphDiff between two graph states, or null when nothing changed. */
|
||||||
|
export function computeGraphDiff(
|
||||||
|
prevServices: Service[],
|
||||||
|
nextServices: Service[],
|
||||||
|
prevConnections: Connection[],
|
||||||
|
nextConnections: Connection[],
|
||||||
|
): GraphDiff | null {
|
||||||
|
const prevSvcMap = new Map(prevServices.map((s) => [s.uid, s]));
|
||||||
|
const nextSvcMap = new Map(nextServices.map((s) => [s.uid, s]));
|
||||||
|
|
||||||
|
const servicesAdded = nextServices.filter((s) => !prevSvcMap.has(s.uid));
|
||||||
|
const servicesRemoved = prevServices.filter((s) => !nextSvcMap.has(s.uid)).map((s) => s.uid);
|
||||||
|
const servicesUpdated = nextServices.filter((s) => {
|
||||||
|
const prev = prevSvcMap.get(s.uid);
|
||||||
|
return prev !== undefined && serviceSignature(prev) !== serviceSignature(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevConnKeys = new Set(prevConnections.map(connectionKey));
|
||||||
|
const nextConnKeys = new Set(nextConnections.map(connectionKey));
|
||||||
|
const connectionsAdded = nextConnections.filter((c) => !prevConnKeys.has(connectionKey(c)));
|
||||||
|
const connectionsRemoved = prevConnections.filter((c) => !nextConnKeys.has(connectionKey(c))).map(connectionKey);
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
servicesAdded.length > 0 || servicesRemoved.length > 0 || servicesUpdated.length > 0 ||
|
||||||
|
connectionsAdded.length > 0 || connectionsRemoved.length > 0;
|
||||||
|
|
||||||
|
if (!hasChanges) return null;
|
||||||
|
|
||||||
|
const diff: GraphDiff = {};
|
||||||
|
if (servicesAdded.length > 0) diff.servicesAdded = servicesAdded;
|
||||||
|
if (servicesRemoved.length > 0) diff.servicesRemoved = servicesRemoved;
|
||||||
|
if (servicesUpdated.length > 0) diff.servicesUpdated = servicesUpdated;
|
||||||
|
if (connectionsAdded.length > 0) diff.connectionsAdded = connectionsAdded;
|
||||||
|
if (connectionsRemoved.length > 0) diff.connectionsRemoved = connectionsRemoved;
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
@@ -27,8 +27,12 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
|
|||||||
);
|
);
|
||||||
|
|
||||||
let services: Service[] = containers.map((c, i) => {
|
let services: Service[] = containers.map((c, i) => {
|
||||||
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
|
// Docker Desktop on Windows may return null for Labels/Names/Ports
|
||||||
const project = c.Labels["com.docker.compose.project"] || "standalone";
|
// where the Linux daemon returns {} / [] — defend at the boundary.
|
||||||
|
const labels = c.Labels ?? {};
|
||||||
|
const names = c.Names ?? [];
|
||||||
|
const name = labels["com.docker.compose.service"] || names[0]?.replace("/", "") || "unknown";
|
||||||
|
const project = labels["com.docker.compose.project"] || "docker";
|
||||||
const info = inspections[i] as any;
|
const info = inspections[i] as any;
|
||||||
|
|
||||||
// Extract network IPs
|
// Extract network IPs
|
||||||
@@ -64,7 +68,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
|
|||||||
state,
|
state,
|
||||||
status: c.Status,
|
status: c.Status,
|
||||||
ports: [...new Map(
|
ports: [...new Map(
|
||||||
c.Ports.filter((p) => p.PublicPort).map((p) => [
|
(c.Ports ?? []).filter((p) => p.PublicPort).map((p) => [
|
||||||
`${p.PublicPort}:${p.PrivatePort}`,
|
`${p.PublicPort}:${p.PrivatePort}`,
|
||||||
{ host: p.PublicPort!, container: p.PrivatePort },
|
{ host: p.PublicPort!, container: p.PrivatePort },
|
||||||
])
|
])
|
||||||
@@ -72,7 +76,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
|
|||||||
networks: Object.keys(c.NetworkSettings?.Networks || {}),
|
networks: Object.keys(c.NetworkSettings?.Networks || {}),
|
||||||
network_ips: networkIps,
|
network_ips: networkIps,
|
||||||
project,
|
project,
|
||||||
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
|
compose_file: labels["com.docker.compose.project.config_files"] || "",
|
||||||
env: (info?.Config?.Env || []) as string[],
|
env: (info?.Config?.Env || []) as string[],
|
||||||
restart_policy: info?.HostConfig?.RestartPolicy?.Name || "",
|
restart_policy: info?.HostConfig?.RestartPolicy?.Name || "",
|
||||||
memory_limit: info?.HostConfig?.Memory || 0,
|
memory_limit: info?.HostConfig?.Memory || 0,
|
||||||
|
|||||||
+149
-42
@@ -6,11 +6,16 @@ import path from "path";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||||
import { pollStats, watchDockerEvents } from "./watcher";
|
import { pollStats, watchDockerEvents } from "./watcher";
|
||||||
|
import { computeGraphDiff } from "./diff";
|
||||||
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord";
|
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord";
|
||||||
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
|
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
|
||||||
|
import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
|
||||||
|
import { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors";
|
||||||
|
import { getUpdateInfo } from "./update-check";
|
||||||
|
import pkg from "../../package.json";
|
||||||
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
|
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
|
||||||
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-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";
|
import type { Service, Connection, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
|
||||||
|
|
||||||
/** Directory for persistent data files (SQLite, JSON configs, positions).
|
/** Directory for persistent data files (SQLite, JSON configs, positions).
|
||||||
* Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
|
* Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
|
||||||
@@ -202,7 +207,15 @@ app.get("/api/init", async (c) => {
|
|||||||
// We deliberately do NOT trigger a fresh pollStats here — on cold start
|
// 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
|
// with many containers it can exceed Bun's 10s request timeout and hang
|
||||||
// the dashboard. The first regular poll (within ~3s) populates via WS.
|
// the dashboard. The first regular poll (within ~3s) populates via WS.
|
||||||
return c.json({ services, connections, positions, stats: lastStats });
|
const projectAliases = loadProjectAliases();
|
||||||
|
const projectColors = loadProjectColors();
|
||||||
|
// Update info: best-effort, never block init. If the check fails (offline,
|
||||||
|
// rate-limit), return null so the UI just doesn't show a notification.
|
||||||
|
let updateInfo = null;
|
||||||
|
try {
|
||||||
|
updateInfo = await getUpdateInfo(pkg.version);
|
||||||
|
} catch {}
|
||||||
|
return c.json({ services, connections, positions, stats: lastStats, projectAliases, projectColors, updateInfo });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
|
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
|
||||||
@@ -216,7 +229,7 @@ app.get("/api/config", (c) => {
|
|||||||
|
|
||||||
// ── Helper: get service uid from container inspect info ──
|
// ── Helper: get service uid from container inspect info ──
|
||||||
function getContainerUid(info: any): string {
|
function getContainerUid(info: any): string {
|
||||||
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
|
const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker";
|
||||||
const service = info.Config?.Labels?.["com.docker.compose.service"] || info.Name?.replace(/^\//, "") || "unknown";
|
const service = info.Config?.Labels?.["com.docker.compose.service"] || info.Name?.replace(/^\//, "") || "unknown";
|
||||||
return `${project}/${service}`;
|
return `${project}/${service}`;
|
||||||
}
|
}
|
||||||
@@ -288,7 +301,7 @@ app.post("/api/containers/:id/rebuild", async (c) => {
|
|||||||
if (denied) return c.json({ error: denied }, 403);
|
if (denied) return c.json({ error: denied }, 403);
|
||||||
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
||||||
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
||||||
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
|
const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker";
|
||||||
if (!composeFile || !serviceName) {
|
if (!composeFile || !serviceName) {
|
||||||
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
|
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
|
||||||
}
|
}
|
||||||
@@ -344,7 +357,7 @@ app.post("/api/containers/:id/recreate", async (c) => {
|
|||||||
if (denied) return c.json({ error: denied }, 403);
|
if (denied) return c.json({ error: denied }, 403);
|
||||||
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
||||||
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
||||||
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
|
const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker";
|
||||||
if (!composeFile || !serviceName) {
|
if (!composeFile || !serviceName) {
|
||||||
return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400);
|
return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400);
|
||||||
}
|
}
|
||||||
@@ -615,6 +628,101 @@ 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Project colors ──
|
||||||
|
app.get("/api/project-colors", (c) => {
|
||||||
|
return c.json(loadProjectColors());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/project-colors", async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json() as { project: string; color: string };
|
||||||
|
if (!body.project) {
|
||||||
|
return c.json({ error: "Missing project" }, 400);
|
||||||
|
}
|
||||||
|
const colors = loadProjectColors();
|
||||||
|
const clean = sanitizeColor(body.color || "");
|
||||||
|
if (clean) {
|
||||||
|
colors[body.project] = clean;
|
||||||
|
} else {
|
||||||
|
delete colors[body.project];
|
||||||
|
}
|
||||||
|
saveProjectColors(colors);
|
||||||
|
return c.json({ ok: true, color: clean || null });
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Failed to save" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/project-colors/:project", (c) => {
|
||||||
|
const project = c.req.param("project");
|
||||||
|
if (!project) {
|
||||||
|
return c.json({ error: "Missing project" }, 400);
|
||||||
|
}
|
||||||
|
const colors = loadProjectColors();
|
||||||
|
delete colors[project];
|
||||||
|
saveProjectColors(colors);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update info ──
|
||||||
|
app.get("/api/update-info", async (c) => {
|
||||||
|
try {
|
||||||
|
const info = await getUpdateInfo(pkg.version);
|
||||||
|
return c.json(info);
|
||||||
|
} catch {
|
||||||
|
// Silent fallback — never error the client with this metadata call.
|
||||||
|
return c.json({
|
||||||
|
current: pkg.version,
|
||||||
|
latest: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
releasesAhead: 0,
|
||||||
|
releaseUrl: null,
|
||||||
|
repoUrl: "https://github.com/RGJorge/ContainerFlow",
|
||||||
|
releaseNotes: null,
|
||||||
|
publishedAt: null,
|
||||||
|
deployMode: "unknown",
|
||||||
|
stars: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Stats history ──
|
// ── Stats history ──
|
||||||
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
||||||
|
|
||||||
@@ -695,6 +803,28 @@ function cleanupLogStream(ws: WebSocket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send full graph + stats snapshot to a newly connected client.
|
||||||
|
* Uses cached state if available; falls back to a fresh discover on cold start. */
|
||||||
|
function sendSnapshot(ws: WebSocket): void {
|
||||||
|
if (lastBroadcastedServices.length > 0) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: "snapshot", data: { services: lastBroadcastedServices, connections: lastBroadcastedConnections } }));
|
||||||
|
if (lastStats.length > 0) ws.send(JSON.stringify({ type: "stats", data: lastStats }));
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cold start: no data yet — do a fresh discover
|
||||||
|
discoverServices(ALL, PROJECTS).then(async (services) => {
|
||||||
|
const connections = await discoverConnections(services);
|
||||||
|
lastBroadcastedServices = services;
|
||||||
|
lastBroadcastedConnections = connections;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: "snapshot", data: { services, connections } }));
|
||||||
|
if (lastStats.length > 0) ws.send(JSON.stringify({ type: "stats", data: lastStats }));
|
||||||
|
} catch {}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Docker events ──
|
// ── Docker events ──
|
||||||
let servicesLock = false;
|
let servicesLock = false;
|
||||||
let statsLock = false;
|
let statsLock = false;
|
||||||
@@ -704,18 +834,16 @@ async function refreshServices() {
|
|||||||
servicesLock = true;
|
servicesLock = true;
|
||||||
try {
|
try {
|
||||||
const services = await discoverServices(ALL, PROJECTS);
|
const services = await discoverServices(ALL, PROJECTS);
|
||||||
|
|
||||||
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
|
|
||||||
if (svcHash !== lastServicesHash) {
|
|
||||||
lastServicesHash = svcHash;
|
|
||||||
broadcast({ type: "services", data: services });
|
|
||||||
}
|
|
||||||
|
|
||||||
const connections = await discoverConnections(services);
|
const connections = await discoverConnections(services);
|
||||||
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
|
|
||||||
if (connHash !== lastConnectionsHash) {
|
const diff = computeGraphDiff(
|
||||||
lastConnectionsHash = connHash;
|
lastBroadcastedServices, services,
|
||||||
broadcast({ type: "connections", data: connections });
|
lastBroadcastedConnections, connections,
|
||||||
|
);
|
||||||
|
if (diff) {
|
||||||
|
lastBroadcastedServices = services;
|
||||||
|
lastBroadcastedConnections = connections;
|
||||||
|
broadcast({ type: "diff", data: diff });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats polling is separate — don't block services refresh
|
// Stats polling is separate — don't block services refresh
|
||||||
@@ -773,8 +901,6 @@ async function refreshStats(services: Service[]) {
|
|||||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
function scheduleRefresh() {
|
function scheduleRefresh() {
|
||||||
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
|
|
||||||
lastServicesHash = "";
|
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
clearTimeout(retryTimer);
|
clearTimeout(retryTimer);
|
||||||
refreshTimer = setTimeout(() => {
|
refreshTimer = setTimeout(() => {
|
||||||
@@ -785,7 +911,6 @@ function scheduleRefresh() {
|
|||||||
|
|
||||||
// Immediate refresh after action endpoints (container already changed state)
|
// Immediate refresh after action endpoints (container already changed state)
|
||||||
function immediateRefresh() {
|
function immediateRefresh() {
|
||||||
lastServicesHash = "";
|
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
clearTimeout(retryTimer);
|
clearTimeout(retryTimer);
|
||||||
refreshServices();
|
refreshServices();
|
||||||
@@ -813,9 +938,9 @@ watchDockerEvents((event) => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Stats polling ──
|
// ── Graph state (used for diff computation and new-client snapshots) ──
|
||||||
let lastServicesHash = "";
|
let lastBroadcastedServices: Service[] = [];
|
||||||
let lastConnectionsHash = "";
|
let lastBroadcastedConnections: Connection[] = [];
|
||||||
/** Last stats snapshot — sent in /api/init so frontend has data immediately
|
/** Last stats snapshot — sent in /api/init so frontend has data immediately
|
||||||
* instead of waiting for the next polling cycle (~3s wait). */
|
* instead of waiting for the next polling cycle (~3s wait). */
|
||||||
let lastStats: Stats[] = [];
|
let lastStats: Stats[] = [];
|
||||||
@@ -852,16 +977,7 @@ const server = Bun.serve({
|
|||||||
clients.add(native);
|
clients.add(native);
|
||||||
|
|
||||||
if (!AUTH_TOKEN) {
|
if (!AUTH_TOKEN) {
|
||||||
// No auth required — send data immediately
|
sendSnapshot(native);
|
||||||
discoverServices(ALL, PROJECTS).then(async (services) => {
|
|
||||||
const connections = await discoverConnections(services);
|
|
||||||
const stats = await pollStats(services);
|
|
||||||
try {
|
|
||||||
native.send(JSON.stringify({ type: "services", data: services }));
|
|
||||||
native.send(JSON.stringify({ type: "connections", data: connections }));
|
|
||||||
native.send(JSON.stringify({ type: "stats", data: stats }));
|
|
||||||
} catch {}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
@@ -886,16 +1002,7 @@ const server = Bun.serve({
|
|||||||
if (msg.token === AUTH_TOKEN) {
|
if (msg.token === AUTH_TOKEN) {
|
||||||
authenticatedClients.add(native);
|
authenticatedClients.add(native);
|
||||||
native.send(JSON.stringify({ type: "auth_ok" }));
|
native.send(JSON.stringify({ type: "auth_ok" }));
|
||||||
// Send current services/connections/stats immediately
|
sendSnapshot(native);
|
||||||
discoverServices(ALL, PROJECTS).then(async (services) => {
|
|
||||||
const connections = await discoverConnections(services);
|
|
||||||
const stats = await pollStats(services);
|
|
||||||
try {
|
|
||||||
native.send(JSON.stringify({ type: "services", data: services }));
|
|
||||||
native.send(JSON.stringify({ type: "connections", data: connections }));
|
|
||||||
native.send(JSON.stringify({ type: "stats", data: stats }));
|
|
||||||
} catch {}
|
|
||||||
}).catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
if (AUTH_TOKEN) recordFailedAttempt(wsIp);
|
if (AUTH_TOKEN) recordFailedAttempt(wsIp);
|
||||||
native.send(JSON.stringify({ type: "auth_error" }));
|
native.send(JSON.stringify({ type: "auth_error" }));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
|
||||||
|
const COLORS_FILE = path.join(DATA_DIR, ".dockerflow-project-colors.json");
|
||||||
|
|
||||||
|
export type ProjectColors = Record<string, string>;
|
||||||
|
|
||||||
|
export function loadProjectColors(): ProjectColors {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(COLORS_FILE)) {
|
||||||
|
return JSON.parse(fs.readFileSync(COLORS_FILE, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProjectColors(colors: ProjectColors): void {
|
||||||
|
fs.writeFileSync(COLORS_FILE, JSON.stringify(colors, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept #rrggbb (case-insensitive). Returns normalized "#rrggbb" or "" if invalid.
|
||||||
|
export function sanitizeColor(raw: string): string {
|
||||||
|
const m = raw.trim().match(/^#?([0-9a-fA-F]{6})$/);
|
||||||
|
if (!m) return "";
|
||||||
|
return "#" + m[1].toLowerCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import Docker from "dockerode";
|
||||||
|
|
||||||
|
const REPO = "RGJorge/ContainerFlow";
|
||||||
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
|
const RELEASES_URL = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
|
||||||
|
const REPO_INFO_URL = `https://api.github.com/repos/${REPO}`;
|
||||||
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
||||||
|
|
||||||
|
export type DeployMode = "ghcr" | "source" | "unknown";
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
/** Number of stable releases between current and latest (e.g. 7 if you're on 0.1.0 and latest is 0.1.7). */
|
||||||
|
releasesAhead: number;
|
||||||
|
releaseUrl: string | null;
|
||||||
|
repoUrl: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
deployMode: DeployMode;
|
||||||
|
/** Current star count on the GitHub repo (null if fetch failed). */
|
||||||
|
stars: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
data: UpdateInfo;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: CacheEntry | null = null;
|
||||||
|
let inFlight: Promise<UpdateInfo> | null = null;
|
||||||
|
|
||||||
|
function parseSemver(v: string): [number, number, number] | null {
|
||||||
|
const m = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return [parseInt(m[1]!), parseInt(m[2]!), parseInt(m[3]!)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewer(latest: string, current: string): boolean {
|
||||||
|
const a = parseSemver(latest);
|
||||||
|
const b = parseSemver(current);
|
||||||
|
if (!a || !b) return false;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i]! > b[i]!) return true;
|
||||||
|
if (a[i]! < b[i]!) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectDeployMode(): Promise<DeployMode> {
|
||||||
|
try {
|
||||||
|
const hostname = process.env.HOSTNAME;
|
||||||
|
if (!hostname) return "unknown";
|
||||||
|
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||||
|
const container = await docker.getContainer(hostname).inspect();
|
||||||
|
const image = container.Config?.Image || "";
|
||||||
|
if (image.startsWith("ghcr.io/rgjorge/containerflow")) return "ghcr";
|
||||||
|
if (image === "containerflow:local" || image.startsWith("containerflow:")) return "source";
|
||||||
|
return "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the first ~6 highlight lines from release notes. Captures both
|
||||||
|
// bullet lists and ### / ## headings so any reasonable release format works.
|
||||||
|
function summarizeReleaseNotes(body: string | undefined | null): string | null {
|
||||||
|
if (!body) return null;
|
||||||
|
const SKIP_HEADINGS = /^(what'?s new|changelog|full changelog|notes|highlights)$/i;
|
||||||
|
const lines = body.split(/\r?\n/);
|
||||||
|
const bullets: string[] = [];
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
// Bullet list items
|
||||||
|
if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
|
bullets.push(stripMd(line.replace(/^[-*]\s+/, "")));
|
||||||
|
}
|
||||||
|
// Numbered list items
|
||||||
|
else if (/^\d+\.\s/.test(line)) {
|
||||||
|
bullets.push(stripMd(line.replace(/^\d+\.\s+/, "")));
|
||||||
|
}
|
||||||
|
// ## or ### headings (skip the generic "What's new" wrappers)
|
||||||
|
else if (line.startsWith("### ") || line.startsWith("## ")) {
|
||||||
|
const text = stripMd(line.replace(/^#+\s+/, ""));
|
||||||
|
if (!SKIP_HEADINGS.test(text)) bullets.push(text);
|
||||||
|
}
|
||||||
|
if (bullets.length >= 15) break;
|
||||||
|
}
|
||||||
|
return bullets.length > 0 ? bullets.join("\n") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMd(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/`([^`]+)`/g, "$1") // inline code
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "$1") // bold
|
||||||
|
.replace(/\*([^*]+)\*/g, "$1") // italic
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → text
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyInfo(currentVersion: string, deployMode: DeployMode, stars: number | null = null): UpdateInfo {
|
||||||
|
return {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
releasesAhead: 0,
|
||||||
|
releaseUrl: null,
|
||||||
|
repoUrl: REPO_URL,
|
||||||
|
releaseNotes: null,
|
||||||
|
publishedAt: null,
|
||||||
|
deployMode,
|
||||||
|
stars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStars(currentVersion: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(REPO_INFO_URL, {
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": `ContainerFlow/${currentVersion}`,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { stargazers_count?: number };
|
||||||
|
return typeof data.stargazers_count === "number" ? data.stargazers_count : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatest(currentVersion: string): Promise<UpdateInfo> {
|
||||||
|
const [deployMode, stars] = await Promise.all([detectDeployMode(), fetchStars(currentVersion)]);
|
||||||
|
try {
|
||||||
|
const res = await fetch(RELEASES_URL, {
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": `ContainerFlow/${currentVersion}`,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
|
||||||
|
const releases = (await res.json()) as Array<{
|
||||||
|
tag_name?: string;
|
||||||
|
html_url?: string;
|
||||||
|
body?: string;
|
||||||
|
published_at?: string;
|
||||||
|
prerelease?: boolean;
|
||||||
|
draft?: boolean;
|
||||||
|
}>;
|
||||||
|
if (!Array.isArray(releases) || releases.length === 0) {
|
||||||
|
return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable releases only (no drafts, no prereleases). GitHub returns them
|
||||||
|
// sorted newest first, which is what we want for `latest`.
|
||||||
|
const stable = releases.filter((r) => !r.prerelease && !r.draft && r.tag_name);
|
||||||
|
if (stable.length === 0) return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
|
||||||
|
const latestRelease = stable[0]!;
|
||||||
|
const latest = latestRelease.tag_name!.replace(/^v/, "");
|
||||||
|
const updateAvailable = isNewer(latest, currentVersion);
|
||||||
|
|
||||||
|
// How many stable releases are strictly newer than what the user is running?
|
||||||
|
let releasesAhead = 0;
|
||||||
|
if (updateAvailable) {
|
||||||
|
for (const r of stable) {
|
||||||
|
const v = r.tag_name!.replace(/^v/, "");
|
||||||
|
if (isNewer(v, currentVersion)) releasesAhead++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: currentVersion,
|
||||||
|
latest,
|
||||||
|
updateAvailable,
|
||||||
|
releasesAhead,
|
||||||
|
releaseUrl: latestRelease.html_url || `${REPO_URL}/releases/tag/${latestRelease.tag_name}`,
|
||||||
|
repoUrl: REPO_URL,
|
||||||
|
releaseNotes: summarizeReleaseNotes(latestRelease.body),
|
||||||
|
publishedAt: latestRelease.published_at || null,
|
||||||
|
deployMode,
|
||||||
|
stars,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdateInfo(currentVersion: string): Promise<UpdateInfo> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) return cache.data;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = fetchLatest(currentVersion)
|
||||||
|
.then((data) => {
|
||||||
|
cache = { data, fetchedAt: Date.now() };
|
||||||
|
inFlight = null;
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
inFlight = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
@@ -138,7 +138,7 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
|
|||||||
"unknown";
|
"unknown";
|
||||||
const svcProject =
|
const svcProject =
|
||||||
event.Actor?.Attributes?.["com.docker.compose.project"] ||
|
event.Actor?.Attributes?.["com.docker.compose.project"] ||
|
||||||
"standalone";
|
"docker";
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "docker",
|
type: "docker",
|
||||||
action,
|
action,
|
||||||
|
|||||||
+25
-2
@@ -131,6 +131,21 @@ export interface ServerConfig {
|
|||||||
restrictedMode: boolean;
|
restrictedMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeployMode = "ghcr" | "source" | "unknown";
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
releasesAhead: number;
|
||||||
|
releaseUrl: string | null;
|
||||||
|
repoUrl: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
deployMode: DeployMode;
|
||||||
|
stars: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventLogEntry {
|
export interface EventLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -150,9 +165,17 @@ export interface NotificationLogEntry {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GraphDiff {
|
||||||
|
servicesAdded?: Service[];
|
||||||
|
servicesRemoved?: string[]; // uids
|
||||||
|
servicesUpdated?: Service[]; // full object (includes uid)
|
||||||
|
connectionsAdded?: Connection[];
|
||||||
|
connectionsRemoved?: string[]; // "from|to|network" keys
|
||||||
|
}
|
||||||
|
|
||||||
export type WSMessage =
|
export type WSMessage =
|
||||||
| { type: "services"; data: Service[] }
|
| { type: "snapshot"; data: { services: Service[]; connections: Connection[] } }
|
||||||
| { type: "connections"; data: Connection[] }
|
| { type: "diff"; data: GraphDiff }
|
||||||
| { type: "stats"; data: Stats[] }
|
| { type: "stats"; data: Stats[] }
|
||||||
| { type: "docker_event"; data: DockerEvent }
|
| { type: "docker_event"; data: DockerEvent }
|
||||||
| { type: "subscribe_logs"; container: string }
|
| { type: "subscribe_logs"; container: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user