2 Commits
Author SHA1 Message Date
RGJorge 71b8f94e21 v0.1.5 — export graph as PNG (closes #4)
Adds a download button to the bottom-left React Flow controls that
exports the current canvas view as a PNG with the dot grid preserved.

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

- New workflow: .github/workflows/docker-release.yml
- docker-compose.yml: uses the prebuilt image from GHCR by default
- New docker-compose.local.yml: override for building from source (devs)
- .env.example: simplified + adds COMPOSE_FILE toggle for local build
- README EN + ES: quick-start updated to no-clone flow
- .gitignore: ignore task/ planning notes dir
2026-05-16 01:26:28 +00:00
13 changed files with 354 additions and 48 deletions
+23 -37
View File
@@ -1,60 +1,46 @@
# ──────────────────────────────────────────────────────────────
# Servidor
# Server
# ──────────────────────────────────────────────────────────────
# Puerto del servidor (por defecto: 9470)
PORT=9470
# Token de autenticacion — dejar vacio para acceso solo en localhost (sin login)
# Poner un valor para activar auth + acceso remoto (0.0.0.0)
# Auth token. Empty = localhost only, no login.
# Set a value = login enabled + remote access (0.0.0.0).
AUTH_TOKEN=
# ──────────────────────────────────────────────────────────────
# Persistencia
# Persistence
# ──────────────────────────────────────────────────────────────
# Directorio donde se guardan archivos persistentes (SQLite de stats,
# config Discord, container settings, posiciones de nodos, env file overrides).
# Default nativo: ./data (relativo al cwd). En docker-compose.yml se setea
# a /app/data (montado en el volumen containerflow-data).
# El directorio se crea automaticamente al startup si no existe.
# Where SQLite, configs, node positions and env file overrides are stored.
# Default native: ./data. In docker: /app/data (containerflow-data volume).
# DATA_DIR=/app/data
# ──────────────────────────────────────────────────────────────
# Acceso a compose files (rebuild / remove)
# Compose file access (rebuild / remove)
# ──────────────────────────────────────────────────────────────
# Path adicional a montar en el container de ContainerFlow para que
# pueda leer compose files fuera de los defaults (/home, /opt, /srv, /root).
# Solo necesario si tus proyectos viven en una ruta no estandar.
# Ejemplo: HOST_PROJECTS_DIR=/data/apps
# Extra host path to mount so rebuild/remove can read compose files
# outside /home, /opt, /srv, /root. Example: /data/apps
# HOST_PROJECTS_DIR=
# ──────────────────────────────────────────────────────────────
# Control de acceso por path (multi-usuario)
# Access control (multi-tenant)
# ──────────────────────────────────────────────────────────────
# Lista separada por ":" de prefijos donde se permiten acciones
# (start/stop/restart/rebuild/remove/exec). Visualizacion, stats y
# logs siempre disponibles para todos los containers.
#
# Vacio = modo permisivo (todas las acciones permitidas).
# Con valores = modo estricto (containers fuera de estas rutas
# aparecen con candado y acciones deshabilitadas).
#
# Ejemplo single-user:
# ALLOWED_PATHS=/home/jorge
#
# Ejemplo multi-path:
# ALLOWED_PATHS=/home/jorge:/srv/myapp:/opt/legacy
# Empty = permissive (all actions allowed).
# Set = strict (only containers under these paths are actionable; rest locked).
# Colon-separated. Example: /home/jorge:/srv/myapp
# ALLOWED_PATHS=
# Solo aplica cuando ALLOWED_PATHS esta activo. Si ALLOWED_PATHS esta
# vacio, esta variable no tiene efecto (todo es accionable por default).
#
# Cuando ALLOWED_PATHS esta activo, controla si containers no-compose
# (corridos con `docker run` directo, sin labels de compose) permiten
# acciones:
# false (default) = bloqueados, aparecen con candado
# true = permitidos (util para watchtower, traefik, etc.)
# Only applies when ALLOWED_PATHS is active.
# false = block actions on non-compose containers.
# true = allow them (useful for watchtower, traefik, etc.).
# ALLOW_NON_COMPOSE=false
# ──────────────────────────────────────────────────────────────
# Dev mode (build from source instead of pulling the image)
# ──────────────────────────────────────────────────────────────
# Uncomment, then run: docker compose up -d --build
# COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
+48
View File
@@ -0,0 +1,48 @@
name: Docker Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/containerflow
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3
View File
@@ -18,3 +18,6 @@ recomendaciones.md
docs/reddit.md
# AI assistant context — internal, not for public repo
CLAUDE.md
# Planning notes — internal, not for public repo
task/
+17 -3
View File
@@ -29,16 +29,30 @@ Las herramientas existentes te muestran números. ContainerFlow además:
## Quick start
No necesitas clonar el repo. Descarga la imagen pre-built desde GHCR:
```bash
git clone https://github.com/RGJorge/containerflow.git
cd containerflow
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/.env.example
cp .env.example .env
docker compose up -d
```
Abre `http://localhost:9470`. Listo.
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
+17 -3
View File
@@ -29,16 +29,30 @@ Existing tools show you numbers. ContainerFlow also:
## Quick start
No clone needed. Pull the prebuilt image from GHCR:
```bash
git clone https://github.com/RGJorge/containerflow.git
cd containerflow
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/RGJorge/ContainerFlow/main/.env.example
cp .env.example .env
docker compose up -d
```
Open `http://localhost:9470`. Done.
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
+3
View File
@@ -8,6 +8,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"dockerode": "^4",
"hono": "^4",
"html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"yaml": "^2",
"zod": "^3",
@@ -494,6 +495,8 @@
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+15
View File
@@ -0,0 +1,15 @@
# Override compose file for local development / building from source.
# Activated via COMPOSE_FILE env var in .env (see .env.example).
#
# Usage:
# 1. In .env, set: COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
# 2. Run: docker compose up -d --build
#
# This overrides the `image:` from docker-compose.yml with a local build,
# tagging it as `containerflow:local` so it's distinguishable from
# prebuilt versions.
services:
containerflow:
build: .
image: containerflow:local
+3 -1
View File
@@ -1,6 +1,8 @@
services:
containerflow:
build: .
# Pulls the latest prebuilt image from GHCR.
# To build from source locally instead, see docker-compose.local.yml + COMPOSE_FILE in .env.
image: ghcr.io/rgjorge/containerflow:latest
ports:
- "${EXTERNAL_PORT:-9470}:9470"
volumes:
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "containerflow",
"version": "0.1.3",
"version": "0.1.5",
"license": "AGPL-3.0-or-later",
"author": "Jorge Gonzalez D. (RGJorge)",
"type": "module",
@@ -21,6 +21,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"dockerode": "^4",
"hono": "^4",
"html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"yaml": "^2",
"zod": "^3"
+6 -3
View File
@@ -24,6 +24,7 @@ import { NodeContextMenu } from "./components/NodeContextMenu";
import { LoginScreen } from "./components/LoginScreen";
import { OffsetEdge } from "./components/OffsetEdge";
import { HeaderBar, type Page } from "./components/HeaderBar";
import { ExportPngButton } from "./components/ExportPngButton";
import { EdgeLegend } from "./components/EdgeLegend";
import { ActionErrorToast } from "./components/ActionErrorToast";
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
@@ -570,7 +571,7 @@ function Dashboard({ token }: { token: string }) {
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
{/* Canvas — inset (only visible on dashboard) */}
<div className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<div id="dashboard-canvas" className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<ReactFlow
onInit={(instance) => { reactFlowRef.current = instance; }}
nodes={dimmedNodes}
@@ -641,7 +642,9 @@ function Dashboard({ token }: { token: string }) {
proOptions={{ hideAttribution: true }}
>
<Background color="#374151" gap={30} size={2} />
<Controls position="bottom-left" />
<Controls position="bottom-left">
<ExportPngButton onError={(msg) => pushActionError("dashboard", "export", msg)} />
</Controls>
<EdgeLegend />
<MiniMap
position="bottom-right"
@@ -659,7 +662,7 @@ function Dashboard({ token }: { token: string }) {
{/* Project filter */}
{projects.length > 1 && (
<div className="absolute top-3 right-3 z-10" ref={filterRef}>
<div data-no-export="true" className="absolute top-3 right-3 z-10" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 backdrop-blur-sm hover:bg-slate-700/80 border border-slate-700/50 px-3 py-1.5 rounded-md transition-colors"
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import { ControlButton } from "@xyflow/react";
import { Download, Loader2 } from "lucide-react";
import { useT } from "../i18n";
import { exportGraphAsPng, downloadPng } from "../utils/exportPng";
interface Props {
onError?: (message: string) => void;
}
export function ExportPngButton({ onError }: Props) {
const { t } = useT();
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
try {
const { dataUrl } = await exportGraphAsPng();
downloadPng(dataUrl, window.location.hostname);
} catch (err) {
const code = err instanceof Error ? err.message : "UNKNOWN";
const message =
code === "EMPTY_GRAPH"
? t("controls.exportPng.empty")
: code === "GRAPH_TOO_LARGE"
? t("controls.exportPng.tooLarge")
: t("controls.exportPng.failed");
onError?.(message);
} finally {
setBusy(false);
}
};
return (
<ControlButton onClick={handleClick} title={t("controls.exportPng")} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <Download />}
</ControlButton>
);
}
+12
View File
@@ -20,6 +20,12 @@ const en = {
"filter.projects": "Projects",
"filter.all": "All",
// Canvas controls
"controls.exportPng": "Export graph as PNG",
"controls.exportPng.empty": "No containers to export",
"controls.exportPng.tooLarge": "Graph is too large to export at full quality",
"controls.exportPng.failed": "Failed to export graph",
// Login
"login.connecting": "Connecting...",
"login.connect": "Connect",
@@ -279,6 +285,12 @@ const es: Record<TranslationKey, string> = {
"filter.projects": "Proyectos",
"filter.all": "Todos",
// Canvas controls
"controls.exportPng": "Exportar grafo como PNG",
"controls.exportPng.empty": "No hay containers para exportar",
"controls.exportPng.tooLarge": "El grafo es muy grande para exportar en alta calidad",
"controls.exportPng.failed": "Error al exportar el grafo",
// Login
"login.connecting": "Conectando...",
"login.connect": "Conectar",
+165
View File
@@ -0,0 +1,165 @@
import { toPng } from "html-to-image";
const PIXEL_RATIO = 3;
const BACKGROUND = "#0f172a"; // slate-900 (matches dashboard)
const DOT_COLOR = "rgba(55, 65, 81, 0.7)"; // slate-700 at 70% — matches the perceptual softness of the SVG pattern
const DOT_GAP = 30;
const DOT_SIZE = 2;
// Hard cap to avoid browser canvas memory issues. 100M pixels ≈ 800 MB RAM.
const MAX_PIXELS = 100_000_000;
export interface ExportPngOptions {
pixelRatio?: number;
backgroundColor?: string;
}
export interface ExportPngResult {
dataUrl: string;
width: number;
height: number;
}
/**
* Captures the dashboard canvas (React Flow area) as a PNG data URL.
*
* The React Flow `<Background>` component renders dots as an SVG `<pattern>`,
* which html-to-image does not rasterize reliably across browsers. To get a
* deterministic output we:
* 1. Capture the React Flow area with transparent background (nodes/edges
* only) and skip the buggy SVG pattern via `filter`.
* 2. Paint our own background + dot grid onto a canvas at the correct
* pixel ratio.
* 3. Draw the captured layer on top.
*
* Overlay UI (Controls, MiniMap, EdgeLegend, anything with `data-no-export`)
* is excluded so the export is just the graph itself.
*/
export async function exportGraphAsPng(opts: ExportPngOptions = {}): Promise<ExportPngResult> {
const pixelRatio = opts.pixelRatio ?? PIXEL_RATIO;
const backgroundColor = opts.backgroundColor ?? BACKGROUND;
const target = document.querySelector(".react-flow") as HTMLElement | null;
if (!target) {
throw new Error("CANVAS_NOT_FOUND");
}
const rect = target.getBoundingClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
if (width === 0 || height === 0) {
throw new Error("EMPTY_GRAPH");
}
if (width * height * pixelRatio * pixelRatio > MAX_PIXELS) {
throw new Error("GRAPH_TOO_LARGE");
}
// 1. Temporarily disable CSS effects that don't translate well to a
// rasterized PNG: Tailwind's `ring-*` (box-shadow halo around rounded
// corners shows as harsh edges without backdrop-blur underneath) and
// `backdrop-filter` (browsers don't capture it at all). Restored in
// the `finally` block.
const tempStyle = document.createElement("style");
tempStyle.dataset.exportPngOverride = "true";
tempStyle.textContent = `
/* Only target service (container) nodes group headers stay transparent
so the group's outline / border remains visible at the top. */
.react-flow__node:not(.react-flow__node-group) > * {
--tw-ring-shadow: 0 0 #0000 !important;
backdrop-filter: none !important;
/* Solid dark fill so the dot grid doesn't bleed through node bodies.
State is still indicated by the border colors and the inner state dot. */
background-color: rgb(15 23 42 / 0.85) !important;
}
`;
document.head.appendChild(tempStyle);
let nodesDataUrl: string;
try {
// 2. Capture nodes/edges with transparent background.
nodesDataUrl = await toPng(target, {
width,
height,
pixelRatio,
backgroundColor: undefined,
filter: (node) => {
// node is typed as HTMLElement but at runtime can be any Element (incl. SVG).
// We rely on Element-level APIs which exist on both HTML and SVG.
const el = node as Element;
const cl = el.classList;
if (!cl) return true;
// Custom: anything explicitly marked
if ((node as HTMLElement).dataset?.noExport === "true") return false;
// React Flow overlays
if (cl.contains("react-flow__controls")) return false;
if (cl.contains("react-flow__minimap")) return false;
if (cl.contains("react-flow__attribution")) return false;
if (cl.contains("react-flow__panel")) return false;
// We re-render the dots manually below, skip React Flow's SVG pattern.
if (cl.contains("react-flow__background")) return false;
return true;
},
});
} finally {
document.head.removeChild(tempStyle);
}
// 2. Load the captured image so we can composite it onto a canvas.
const layer = await loadImage(nodesDataUrl);
// 3. Composite: solid bg + dot grid + captured layer.
const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("CANVAS_CONTEXT_FAILED");
}
// Solid base
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Dot grid (matches the React Flow <Background> config: gap 30, size 2)
ctx.fillStyle = DOT_COLOR;
const gap = DOT_GAP * pixelRatio;
const radius = (DOT_SIZE * pixelRatio) / 2;
for (let x = gap; x < canvas.width; x += gap) {
for (let y = gap; y < canvas.height; y += gap) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Captured nodes/edges on top
ctx.drawImage(layer, 0, 0, canvas.width, canvas.height);
return { dataUrl: canvas.toDataURL("image/png"), width, height };
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error(`Image load failed: ${e}`));
img.src = src;
});
}
/**
* Triggers a browser download of a data URL with a filename of the form
* `containerflow-<hostname>-<timestamp>.png`.
*/
export function downloadPng(dataUrl: string, hostname?: string): void {
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const host = hostname?.replace(/[^a-z0-9-]/gi, "").toLowerCase() || "graph";
const filename = `containerflow-${host}-${ts}.png`;
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}