mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b8f94e21 | ||
|
|
2a660c1828 |
+23
-37
@@ -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
|
||||
|
||||
@@ -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
|
||||
# AI assistant context — internal, not for public repo
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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:
|
||||
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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user