mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61472e4e04 | ||
|
|
b079422de8 | ||
|
|
71b8f94e21 | ||
|
|
2a660c1828 |
+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/
|
||||||
|
|||||||
+17
-3
@@ -29,16 +29,30 @@ Las herramientas existentes te muestran números. ContainerFlow además:
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
No necesitas clonar el repo. Descarga la imagen pre-built desde GHCR:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/RGJorge/containerflow.git
|
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/docker-compose.yml
|
||||||
cd containerflow
|
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/.env.example
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Abre `http://localhost:9470`. Listo.
|
Abre `http://localhost:9470`. Listo.
|
||||||
|
|
||||||
Para desarrollo nativo (hot reload): `bun install && bun run dev`.
|
### 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
|
## Documentación
|
||||||
|
|
||||||
|
|||||||
@@ -29,16 +29,30 @@ Existing tools show you numbers. ContainerFlow also:
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
No clone needed. Pull the prebuilt image from GHCR:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/RGJorge/containerflow.git
|
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/docker-compose.yml
|
||||||
cd containerflow
|
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/.env.example
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:9470`. Done.
|
Open `http://localhost:9470`. Done.
|
||||||
|
|
||||||
For native development (hot reload): `bun install && bun run dev`.
|
### Build from source instead
|
||||||
|
|
||||||
|
If you cloned the repo and want to build locally (e.g. you modified the code):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/RGJorge/containerflow.git
|
||||||
|
cd containerflow
|
||||||
|
cp .env.example .env
|
||||||
|
# In .env, uncomment: COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
For native development (hot reload, no Docker): `bun install && bun run dev`.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -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.3",
|
"version": "0.1.7",
|
||||||
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+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({
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ 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",
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
"login.connecting": "Connecting...",
|
"login.connecting": "Connecting...",
|
||||||
"login.connect": "Connect",
|
"login.connect": "Connect",
|
||||||
@@ -279,6 +293,20 @@ 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",
|
||||||
|
"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);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ 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";
|
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
|
||||||
const project = c.Labels["com.docker.compose.project"] || "standalone";
|
const project = c.Labels["com.docker.compose.project"] || "docker";
|
||||||
const info = inspections[i] as any;
|
const info = inspections[i] as any;
|
||||||
|
|
||||||
// Extract network IPs
|
// Extract network IPs
|
||||||
|
|||||||
+81
-4
@@ -8,6 +8,8 @@ import { docker, discoverServices, discoverConnections, getContainerLogs, stream
|
|||||||
import { pollStats, watchDockerEvents } from "./watcher";
|
import { pollStats, watchDockerEvents } from "./watcher";
|
||||||
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 { 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, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
|
||||||
@@ -202,7 +204,9 @@ 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();
|
||||||
|
return c.json({ services, connections, positions, stats: lastStats, projectAliases, projectColors });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 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 +220,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 +292,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 +348,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 +619,79 @@ 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 });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Stats history ──
|
// ── Stats history ──
|
||||||
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user