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

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

- New util src/client/utils/exportPng.ts: html-to-image capture +
  manual canvas composition (solid bg + dot grid + captured layer).
  Works around html-to-image's SVG <pattern> limitations and produces
  a deterministic 3x retina PNG.
- During capture, temporary CSS disables ring-shadow halos and forces
  service node bodies to a solid dark fill so the dot grid doesn't
  bleed through. State is still indicated by border colors and the
  inner state dot. Group node headers are excluded so their outlines
  stay intact.
- New component src/client/components/ExportPngButton.tsx integrated
  as a custom ControlButton inside <Controls> (bottom-left).
- App.tsx: id="dashboard-canvas" + data-no-export on project filter.
- i18n: 4 keys (EN + ES) for the button and 3 error states.
- New dependency: html-to-image@1.11.13
2026-05-16 02:08:22 +00:00
11 changed files with 546 additions and 39 deletions
+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=="],
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "containerflow",
"version": "0.1.4",
"version": "0.1.6",
"license": "AGPL-3.0-or-later",
"author": "Jorge Gonzalez D. (RGJorge)",
"type": "module",
@@ -21,6 +21,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"dockerode": "^4",
"hono": "^4",
"html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"yaml": "^2",
"zod": "^3"
+64 -10
View File
@@ -18,12 +18,13 @@ import { useDocker } from "./hooks/useDocker";
import { useServerConfig } from "./hooks/useServerConfig";
import { I18nProvider, useT } from "./i18n";
import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore";
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
import { buildLayout, computeEdges, getComposeKey, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
import { DetailPanel } from "./panels/DetailPanel";
import { NodeContextMenu } from "./components/NodeContextMenu";
import { LoginScreen } from "./components/LoginScreen";
import { OffsetEdge } from "./components/OffsetEdge";
import { HeaderBar, type Page } from "./components/HeaderBar";
import { ExportPngButton } from "./components/ExportPngButton";
import { EdgeLegend } from "./components/EdgeLegend";
import { ActionErrorToast } from "./components/ActionErrorToast";
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
@@ -143,6 +144,37 @@ function Dashboard({ token }: { token: string }) {
const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({});
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 });
const [discordEnabled, setDiscordEnabled] = useState(false);
// Project aliases — friendly names for cryptic project keys (Coolify, Dokploy, etc.)
const [projectAliases, setProjectAliases] = useState<Record<string, string>>({});
// Save / reset handler — passed to GroupNode via node data. Wrapped in a ref
// so the layout effect doesn't have to recompute every time the callback
// identity changes; GroupNode always gets the latest version.
const handleAliasChange = useCallback(async (project: string, newAlias: string) => {
const trimmed = newAlias.trim();
setProjectAliases((prev) => {
const next = { ...prev };
if (trimmed) next[project] = trimmed;
else delete next[project];
return next;
});
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
await fetch("/api/project-aliases", {
method: "PUT",
headers,
body: JSON.stringify({ project, alias: trimmed }),
});
} catch {
// On error, refetch from server to revert optimistic update.
fetch("/api/project-aliases", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectAliases)
.catch(() => {});
}
}, [token]);
const handleAliasChangeRef = useRef(handleAliasChange);
handleAliasChangeRef.current = handleAliasChange;
useEffect(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -150,6 +182,10 @@ function Dashboard({ token }: { token: string }) {
.then((r) => r.ok ? r.json() : {})
.then(setContainerSettings)
.catch(() => {});
fetch("/api/project-aliases", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectAliases)
.catch(() => {});
fetch("/api/discord-config", { headers })
.then((r) => r.ok ? r.json() : null)
.then((c: any) => {
@@ -364,7 +400,8 @@ function Dashboard({ token }: { token: string }) {
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
// Mark service nodes as locked + inject effective thresholds for progress bar coloring
// Mark service nodes as locked + inject effective thresholds for progress bar coloring.
// For group nodes, inject the alias (if any) + the change handler.
for (const n of newNodes) {
if (n.type === "service") {
const svc = filteredServices.find((s) => s.uid === n.id);
@@ -375,6 +412,15 @@ function Dashboard({ token }: { token: string }) {
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
(n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined;
}
} else if (n.type === "group") {
// Use the raw `project` from the layout (NOT the groupKey which includes
// the compose suffix). This way the alias matches what `service.project`
// is on individual services, and the filter dropdown shares the same key.
const project = (n.data as any).project as string | undefined;
if (project) {
(n.data as any).alias = projectAliases[project];
(n.data as any).onAliasChange = handleAliasChangeRef.current;
}
}
}
@@ -458,7 +504,7 @@ function Dashboard({ token }: { token: string }) {
return result;
});
}
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled, projectAliases]);
// Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -566,11 +612,11 @@ function Dashboard({ token }: { token: string }) {
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} />}
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} projectAliases={projectAliases} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
{/* Canvas — inset (only visible on dashboard) */}
<div className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<div id="dashboard-canvas" className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
<ReactFlow
onInit={(instance) => { reactFlowRef.current = instance; }}
nodes={dimmedNodes}
@@ -641,7 +687,9 @@ function Dashboard({ token }: { token: string }) {
proOptions={{ hideAttribution: true }}
>
<Background color="#374151" gap={30} size={2} />
<Controls position="bottom-left" />
<Controls position="bottom-left">
<ExportPngButton onError={(msg) => pushActionError("dashboard", "export", msg)} />
</Controls>
<EdgeLegend />
<MiniMap
position="bottom-right"
@@ -659,7 +707,7 @@ function Dashboard({ token }: { token: string }) {
{/* Project filter */}
{projects.length > 1 && (
<div className="absolute top-3 right-3 z-10" ref={filterRef}>
<div data-no-export="true" className="absolute top-3 right-3 z-10" ref={filterRef}>
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 backdrop-blur-sm hover:bg-slate-700/80 border border-slate-700/50 px-3 py-1.5 rounded-md transition-colors"
@@ -671,7 +719,7 @@ function Dashboard({ token }: { token: string }) {
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button>
{filterOpen && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[220px] 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 */}
<button
onClick={() => {
@@ -702,11 +750,14 @@ function Dashboard({ token }: { token: string }) {
const projectServices = services.filter((s) => s.project === p);
const running = projectServices.filter((s) => s.state === "running").length;
const stopped = projectServices.length - running;
const display = projectAliases[p] || p;
const composeKeys = [...new Set(projectServices.map((s) => getComposeKey(s.compose_file)))];
const composeSuffix = composeKeys.join(" - ");
return (
<button
key={p}
onClick={() => toggleProject(p)}
title={p}
title={composeSuffix ? `${display} / ${composeSuffix}` : display}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
@@ -714,7 +765,10 @@ function Dashboard({ token }: { token: string }) {
}`}>
{active && <Check size={12} className="text-white" />}
</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="text-emerald-500/70">{running}</span>
<span className="text-slate-600">/</span>
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import { ControlButton } from "@xyflow/react";
import { Download, Loader2 } from "lucide-react";
import { useT } from "../i18n";
import { exportGraphAsPng, downloadPng } from "../utils/exportPng";
interface Props {
onError?: (message: string) => void;
}
export function ExportPngButton({ onError }: Props) {
const { t } = useT();
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
try {
const { dataUrl } = await exportGraphAsPng();
downloadPng(dataUrl, window.location.hostname);
} catch (err) {
const code = err instanceof Error ? err.message : "UNKNOWN";
const message =
code === "EMPTY_GRAPH"
? t("controls.exportPng.empty")
: code === "GRAPH_TOO_LARGE"
? t("controls.exportPng.tooLarge")
: t("controls.exportPng.failed");
onError?.(message);
} finally {
setBusy(false);
}
};
return (
<ControlButton onClick={handleClick} title={t("controls.exportPng")} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <Download />}
</ControlButton>
);
}
+5 -3
View File
@@ -9,7 +9,7 @@ export const GROUP_PADDING = 28;
export const GROUP_HEADER = 44;
const GROUP_GAP = 50;
function getComposeKey(file: string): string {
export function getComposeKey(file: string): string {
if (!file) return "default";
const match = file.match(/docker-compose\.?(.*)\.yml/);
const key = match?.[1] || "";
@@ -127,12 +127,14 @@ export function buildLayout(
.filter(Boolean);
const subtitle = composeFiles.join(", ");
// Group node
// Group node. `project` is the raw project key (without the compose part);
// it's what the alias system uses so the same alias applies across all
// compose files of the same project and matches the filter dropdown.
nodes.push({
id: `group-${groupKey}`,
type: "group",
position: { x: groupX, y: 0 },
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length },
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length, project: svcs[0]?.project },
style: {
width: groupWidth,
height: groupHeight,
+24
View File
@@ -20,6 +20,18 @@ const en = {
"filter.projects": "Projects",
"filter.all": "All",
// Canvas controls
"controls.exportPng": "Export graph as PNG",
"controls.exportPng.empty": "No containers to export",
"controls.exportPng.tooLarge": "Graph is too large to export at full quality",
"controls.exportPng.failed": "Failed to export graph",
// Group alias
"group.rename": "Rename project",
"group.resetAlias": "Reset to original name",
"group.saveAlias": "Save",
"group.cancelAlias": "Cancel",
// Login
"login.connecting": "Connecting...",
"login.connect": "Connect",
@@ -279,6 +291,18 @@ const es: Record<TranslationKey, string> = {
"filter.projects": "Proyectos",
"filter.all": "Todos",
// Canvas controls
"controls.exportPng": "Exportar grafo como PNG",
"controls.exportPng.empty": "No hay containers para exportar",
"controls.exportPng.tooLarge": "El grafo es muy grande para exportar en alta calidad",
"controls.exportPng.failed": "Error al exportar el grafo",
// Group alias
"group.rename": "Renombrar proyecto",
"group.resetAlias": "Restaurar nombre original",
"group.saveAlias": "Guardar",
"group.cancelAlias": "Cancelar",
// Login
"login.connecting": "Conectando...",
"login.connect": "Conectar",
+139 -15
View File
@@ -1,11 +1,18 @@
import { memo } from "react";
import { memo, useEffect, useRef, useState } from "react";
import type { NodeProps } from "@xyflow/react";
import { Server, Wrench, Rocket, Box, Folder } from "lucide-react";
import { Server, Wrench, Rocket, Box, Folder, Pencil, RotateCcw, Check, X } from "lucide-react";
import { useT } from "../i18n";
interface GroupNodeData {
label: string;
subtitle?: string;
count?: number;
/** Original (raw) project key used to look up / save the alias. */
project?: string;
/** Current alias if set, else undefined / empty string. */
alias?: string;
/** Save handler — called with (project, newAlias). Empty newAlias = reset. */
onAliasChange?: (project: string, newAlias: string) => void;
[key: string]: unknown;
}
@@ -36,24 +43,141 @@ function getProjectColor(label: string) {
}
export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
const { t } = useT();
const d = data as unknown as GroupNodeData;
// Label is "PROJECT / COMPOSE" — match compose part for known colors
const parts = d.label.split(" / ");
const composePart = parts.length > 1 ? parts[parts.length - 1] : d.label;
const known = groupConfig[composePart];
// Label is "PROJECT / COMPOSE" (uppercase). We let users alias only the
// project portion — the compose suffix (DEV / PROD / INFRA / docker-compose
// file name) stays as a structural hint and is also used for the icon match.
const labelParts = d.label.split(" / ");
const projectPart = labelParts[0] || d.label;
const composePart = labelParts.length > 1 ? labelParts.slice(1).join(" / ") : "";
const iconKey = composePart || d.label;
const known = groupConfig[iconKey];
const proj = known ? null : getProjectColor(d.label);
const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor };
const Icon = config.icon;
const hasAlias = Boolean(d.alias && d.alias.trim().length > 0);
const projectDisplay = hasAlias ? d.alias! : projectPart;
const displayName = composePart ? `${projectDisplay} / ${composePart}` : projectDisplay;
const canEdit = Boolean(d.project && d.onAliasChange);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(projectDisplay);
const inputRef = useRef<HTMLInputElement | null>(null);
// Focus input when entering edit mode. Cursor lands at the end of the
// current draft — no selection highlight, so users can just type to append.
useEffect(() => {
if (editing && inputRef.current) {
const el = inputRef.current;
el.focus();
const len = el.value.length;
el.setSelectionRange(len, len);
}
}, [editing]);
// Keep draft in sync if alias changes externally while not editing
useEffect(() => {
if (!editing) setDraft(projectDisplay);
}, [projectDisplay, editing]);
const startEdit = () => {
if (!canEdit) return;
// Short names (≤25 chars) → prefill so user can tweak (e.g. add a suffix).
// Long names (cryptic IDs from Coolify/Dokploy/etc) → start blank for a fresh alias.
setDraft(projectDisplay.length <= 25 ? projectDisplay : "");
setEditing(true);
};
const commit = () => {
if (!canEdit || !d.project) return;
// Reset if the user types the original project (case-insensitive) —
// no point storing an alias that's identical to the source key.
const finalAlias = draft.trim().toLowerCase() === projectPart.trim().toLowerCase() ? "" : draft;
d.onAliasChange?.(d.project, finalAlias);
setEditing(false);
};
const cancel = () => {
setEditing(false);
setDraft(projectDisplay);
};
const reset = () => {
if (!canEdit || !d.project) return;
d.onAliasChange?.(d.project, "");
};
return (
<div className="absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
<Icon size={16} style={{ color: config.color }} />
<span
className="text-sm font-semibold tracking-wider uppercase"
style={{ color: config.color }}
>
{d.label}
</span>
<div className="group absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
<Icon size={16} style={{ color: config.color }} className="shrink-0" />
{editing ? (
<>
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") cancel();
}}
onBlur={commit}
maxLength={64}
className="text-sm font-semibold tracking-wider uppercase bg-transparent border-none outline-none p-0 min-w-0"
style={{ color: config.color, width: `${Math.max(draft.length * 9 + 8, 100)}px` }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
{composePart && (
<span
className="text-sm font-semibold tracking-wider uppercase"
style={{ color: config.color }}
>
/ {composePart}
</span>
)}
<button
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); commit(); }}
className="text-emerald-400 hover:text-emerald-300 transition-colors"
title={t("group.saveAlias")}
>
<Check size={14} />
</button>
<button
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); cancel(); }}
className="text-slate-500 hover:text-slate-300 transition-colors"
title={t("group.cancelAlias")}
>
<X size={14} />
</button>
</>
) : (
<>
<span
className={`text-sm font-semibold tracking-wider uppercase ${canEdit ? "cursor-pointer hover:opacity-80" : ""}`}
style={{ color: config.color }}
onClick={canEdit ? startEdit : undefined}
>
{displayName}
</span>
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); startEdit(); }}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity"
title={t("group.rename")}
>
<Pencil size={11} />
</button>
)}
{hasAlias && canEdit && (
<button
onClick={(e) => { e.stopPropagation(); reset(); }}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity"
title={t("group.resetAlias")}
>
<RotateCcw size={11} />
</button>
)}
</>
)}
{d.subtitle && (
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]">
{d.subtitle}
@@ -61,7 +185,7 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
)}
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} />
{d.count != null && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
<Box size={12} style={{ color: config.borderColor }} />
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
{d.count}
+35 -9
View File
@@ -7,6 +7,7 @@ import { StatsCard } from "../components/StatsCard";
import { ThresholdBar } from "../components/ThresholdBar";
import { Tooltip } from "../components/Tooltip";
import { guessIcon } from "../nodes/ServiceNode";
import { getComposeKey } from "../engine/layout";
function timeAgo(ts: number): string {
const diff = Math.floor((Date.now() / 1000) - ts);
@@ -85,9 +86,10 @@ interface MonitoringPageProps {
eventLogStream: EventLogEntry[];
notificationStream: NotificationLogEntry[];
onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void;
projectAliases?: Record<string, string>;
}
export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail }: MonitoringPageProps) {
export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail, projectAliases = {} }: MonitoringPageProps) {
const { t } = useT();
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
@@ -235,12 +237,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
};
// Labels
const aliasOrName = (p: string) => projectAliases[p] || p;
const projectLabel = selectedProjects.size === 0
? t("monitoring.filterProject")
: selectedProjects.size === allProjects.length
? t("monitoring.allProjects")
: selectedProjects.size === 1
? [...selectedProjects][0]
? aliasOrName([...selectedProjects][0])
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`;
const serviceLabel = selectedServices.size === 0
@@ -308,18 +311,24 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
<div className="border-t border-slate-700/50 my-1" />
{allProjects.map((project) => {
const isSelected = selectedProjects.has(project);
const composeKeys = [...new Set(services.filter((s) => s.project === project).map((s) => getComposeKey(s.compose_file)))];
const composeSuffix = composeKeys.join(" - ");
return (
<button
key={project}
onClick={() => toggleProject(project)}
title={composeSuffix ? `${aliasOrName(project)} / ${composeSuffix}` : aliasOrName(project)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}>
{isSelected && <Check size={12} className="text-white" />}
</div>
<span className={isSelected ? "text-slate-200" : "text-slate-400"}>{project}</span>
<span className={`min-w-0 truncate uppercase ${isSelected ? "text-slate-200" : "text-slate-400"}`}>
{aliasOrName(project)}
{composeSuffix && <span className="ml-1 text-xs text-slate-500">/ {composeSuffix}</span>}
</span>
</button>
);
})}
@@ -453,7 +462,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
: `${selectedServices.size} ${t("footer.containers")}`)
: selectedProjects.size === 1
? [...selectedProjects][0]
? (() => {
const proj = [...selectedProjects][0];
const display = aliasOrName(proj);
const composeKeys = [...new Set(services.filter((s) => s.project === proj).map((s) => getComposeKey(s.compose_file)))];
const suffix = composeKeys.join(" - ");
return suffix ? `${display} / ${suffix.toUpperCase()}` : display;
})()
: selectedProjects.size === allProjects.length
? t("monitoring.allProjects")
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`
@@ -478,6 +493,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
globalRange={statsRange}
fallbackData={filteredHistory[svc] || []}
token={token}
projectAliases={projectAliases}
/>
))}
</div>
@@ -581,7 +597,7 @@ function MonitoringTotalsCard({
return (
<div className="px-5 py-3 border-b border-slate-700/40 bg-slate-900/40">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-slate-300 font-medium truncate">{title}</span>
<span className="text-xs text-slate-300 font-medium truncate uppercase">{title}</span>
<span className="text-[10px] text-slate-500">· {containerCount} {t("footer.containers")}</span>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -637,6 +653,7 @@ interface MonitoringServiceCardProps {
globalRange: StatsRange;
fallbackData: StatsHistoryPoint[];
token: string;
projectAliases: Record<string, string>;
}
function MonitoringServiceCard({
@@ -655,6 +672,7 @@ function MonitoringServiceCard({
globalRange,
fallbackData,
token,
projectAliases,
}: MonitoringServiceCardProps) {
const { t } = useT();
const [localRange, setLocalRange] = useState<StatsRange | null>(null);
@@ -692,9 +710,17 @@ function MonitoringServiceCard({
<ServiceIcon uid={svc} services={services} />
<div className="min-w-0">
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
{svc.includes("/") && (
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span>
)}
{svc.includes("/") && (() => {
const proj = svc.split("/")[0];
const display = projectAliases[proj] || proj;
// Only show THIS service's compose (not the whole project's list).
const thisCompose = svcData ? getComposeKey(svcData.compose_file) : "";
return (
<span className="text-[10px] text-slate-500 truncate block leading-tight uppercase">
{display}{thisCompose && <span> / {thisCompose}</span>}
</span>
);
})()}
</div>
<div className="flex-1" />
{/* Per-card range buttons */}
+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);
}
+40 -1
View File
@@ -8,6 +8,7 @@ import { docker, discoverServices, discoverConnections, getContainerLogs, stream
import { pollStats, watchDockerEvents } from "./watcher";
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord";
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db";
import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
@@ -202,7 +203,8 @@ app.get("/api/init", async (c) => {
// We deliberately do NOT trigger a fresh pollStats here — on cold start
// with many containers it can exceed Bun's 10s request timeout and hang
// the dashboard. The first regular poll (within ~3s) populates via WS.
return c.json({ services, connections, positions, stats: lastStats });
const projectAliases = loadProjectAliases();
return c.json({ services, connections, positions, stats: lastStats, projectAliases });
});
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
@@ -615,6 +617,43 @@ app.put("/api/container-settings", async (c) => {
}
});
// ── Project aliases ──
app.get("/api/project-aliases", (c) => {
return c.json(loadProjectAliases());
});
app.put("/api/project-aliases", async (c) => {
try {
const body = await c.req.json() as { project: string; alias: string };
if (!body.project) {
return c.json({ error: "Missing project" }, 400);
}
const aliases = loadProjectAliases();
const clean = sanitizeAlias(body.alias || "");
if (clean) {
aliases[body.project] = clean;
} else {
// Empty alias = reset to original (remove the entry)
delete aliases[body.project];
}
saveProjectAliases(aliases);
return c.json({ ok: true, alias: clean || null });
} catch {
return c.json({ error: "Failed to save" }, 500);
}
});
app.delete("/api/project-aliases/:project", (c) => {
const project = c.req.param("project");
if (!project) {
return c.json({ error: "Missing project" }, 400);
}
const aliases = loadProjectAliases();
delete aliases[project];
saveProjectAliases(aliases);
return c.json({ ok: true });
});
// ── Stats history ──
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
+29
View File
@@ -0,0 +1,29 @@
import fs from "fs";
import path from "path";
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const ALIASES_FILE = path.join(DATA_DIR, ".dockerflow-project-aliases.json");
const MAX_ALIAS_LENGTH = 64;
export type ProjectAliases = Record<string, string>;
export function loadProjectAliases(): ProjectAliases {
try {
if (fs.existsSync(ALIASES_FILE)) {
return JSON.parse(fs.readFileSync(ALIASES_FILE, "utf-8"));
}
} catch {}
return {};
}
export function saveProjectAliases(aliases: ProjectAliases): void {
fs.writeFileSync(ALIASES_FILE, JSON.stringify(aliases, null, 2));
}
export function sanitizeAlias(raw: string): string {
return raw
.replace(/[\x00-\x1f\x7f]/g, "") // strip control chars
.trim()
.slice(0, MAX_ALIAS_LENGTH);
}