5 Commits
Author SHA1 Message Date
RGJorge 3b18cad271 v0.1.9 — fix crash on Windows when container Ports is null
Docker Desktop on Windows can return null for c.Ports / c.Labels / c.Names
in the listContainers response (Linux returns empty array/object). The
mapping in discoverServices() assumed array, so .filter() crashed and the
refresh loop kept retrying every 5s, filling the logs.

Closes #7
2026-05-18 17:06:53 +00:00
RGJorge 6fd4440b7a v0.1.8 — in-app update notifications
- Header indicator (pulsating dot + "NEW VERSION") when a newer release
  is published on GitHub. Replaces the version subtitle without growing
  the header.
- Modal with current → latest version, release notes preview (parsed
  from GitHub release body), copy-paste update command, link to repo
  and release notes, current star count, and "versions behind" badge.
- Auto-detects deploy mode (GHCR image vs source build) by inspecting
  the running container. Shows the matching update command, but always
  exposes both via tabs.
- Backend caches the GitHub API response for 6h (~8 calls/day per
  instance, far under the rate limit).
2026-05-17 20:24:47 +00:00
RGJorge 61472e4e04 v0.1.7 — group color picker + centered layout
- New: per-project color picker (palette popover, 6 colors, persisted)
- Layout: groups center their content horizontally + vertically; drag-end recenters and keeps margins symmetric
- Layout: COMPOSE_FILE merges (multi-file) resolve to the override file as the compose key
- UI: standalone group renamed to "docker" with Container icon; compose suffix hidden
- UI: group footer shows compose filename with hover tooltip when truncated
- UI: styled tooltips for header buttons (rename, reset, color)
2026-05-16 04:43:17 +00:00
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
19 changed files with 1575 additions and 115 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.9",
"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"
+227 -56
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,64 @@ 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;
// 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(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -150,6 +209,14 @@ 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/project-colors", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectColors)
.catch(() => {});
fetch("/api/discord-config", { headers })
.then((r) => r.ok ? r.json() : null)
.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[]>();
for (let i = 0; i < nodes.length; i++) {
const pid = nodes[i].parentId;
@@ -281,45 +348,61 @@ function Dashboard({ token }: { token: string }) {
arr.push(i);
}
for (const [gid, kidIdxs] of childrenByParent) {
let minChildX = Infinity;
for (const ki of kidIdxs) minChildX = Math.min(minChildX, nodes[ki].position.x);
if (minChildX !== MIN_X) {
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 } };
}
}
// 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);
}
// 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 [gid, kidIdxs] of childrenByParent) {
if (kidIdxs.length === 0) continue;
let minLeft = Infinity, minTop = Infinity;
let maxRight = 0, maxBottom = 0;
for (const ki of kidIdxs) {
const k = nodes[ki];
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
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 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 newW = Math.max(maxRight, minW);
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
const curW = (n.style?.width as number) || 0;
const curH = (n.style?.height as number) || 0;
if (newW !== curW || newH !== curH) {
changed = true;
nodes[i] = { ...n, style: { ...n.style, width: newW, height: newH } };
const gIdx = groupIdxById.get(gid);
if (gIdx !== undefined) {
const g = nodes[gIdx];
const curW = (g.style?.width as number) || 0;
const curH = (g.style?.height as number) || 0;
if (newW !== curW || newH !== curH) {
changed = true;
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);
// 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,29 +459,97 @@ 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).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) {
// 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) => {
if (n.type === "service" && n.parentId && servicesPerGroup.get(n.parentId) === 1) {
return n;
}
const saved = savedPositions.current[n.id];
if (saved) return { ...n, position: saved };
return n;
});
positioned = positioned.map((n) => {
if (n.type !== "group") return n;
const kids = positioned.filter((c) => c.parentId === n.id);
if (kids.length === 0) return n;
let maxRight = 0;
let maxBottom = 0;
for (const k of kids) {
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
// Resize each group to fit its kids AND recenter content horizontally
// + vertically. We measure the bounding box of children, then shift them
// as a block so margins are symmetric on all four sides. Preserves the
// relative spacing between kids (a vertical stack stays a vertical stack,
// just centered). FOOTER_RESERVE accounts for the compose subtitle at
// the bottom of every group.
const FOOTER_RESERVE = 22;
const groupKids = new Map<string, Node[]>();
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 newW = Math.max(maxRight, minW);
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
return { ...n, style: { ...n.style, width: newW, height: newH } };
const newW = Math.max(contentW + G_PAD * 2, minW);
const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE;
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);
for (const n of positioned) {
@@ -418,6 +570,17 @@ function Dashboard({ token }: { token: string }) {
for (const nn of newNodes) {
const existing = prevNodeMap.get(nn.id);
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
result.push({ ...existing, data: nn.data });
} else {
@@ -458,7 +621,7 @@ function Dashboard({ token }: { token: string }) {
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)
const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -566,11 +729,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 +804,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 +824,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 +836,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 +867,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 +882,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>
);
}
+28 -2
View File
@@ -6,6 +6,8 @@ import {
} from "lucide-react";
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
import { useT } from "../i18n";
import { useUpdateInfo } from "../hooks/useUpdateInfo";
import { UpdateModal } from "./UpdateModal";
export type Page = "dashboard" | "monitoring" | "settings";
@@ -191,6 +193,8 @@ export function HeaderBar({
onOpenServiceDetail,
}: HeaderBarProps) {
const { t, lang, setLang } = useT();
const { info: updateInfo, showIndicator } = useUpdateInfo(token);
const [updateOpen, setUpdateOpen] = useState(false);
return (
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
@@ -201,7 +205,8 @@ export function HeaderBar({
<NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
</nav>
{/* Center: Logo */}
{/* Center: Logo. The "subtitle" line shows the current version normally,
but is replaced by the "update available" badge when an update ships. */}
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
<img
src="/alteonx-logo.webp"
@@ -211,7 +216,21 @@ export function HeaderBar({
/>
<div className="flex flex-col items-end">
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
{showIndicator && updateInfo?.latest ? (
<button
onClick={() => setUpdateOpen(true)}
className="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-wider text-emerald-400 hover:text-emerald-300 transition-colors whitespace-nowrap -mt-1 animate-pulse hover:animate-none"
title={`v${updateInfo.current} → v${updateInfo.latest}`}
>
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" />
</span>
{t("update.available")}
</button>
) : (
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
)}
</div>
</div>
@@ -267,6 +286,13 @@ export function HeaderBar({
</button>
)}
</div>
{updateOpen && updateInfo && (
<UpdateModal
info={updateInfo}
onClose={() => setUpdateOpen(false)}
/>
)}
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X, ExternalLink, Copy, Check, Container, GitBranch, Github, Star } from "lucide-react";
import type { UpdateInfo, DeployMode } from "../../shared/types";
import { useT } from "../i18n";
interface UpdateModalProps {
info: UpdateInfo;
onClose: () => void;
}
type TabKey = Exclude<DeployMode, "unknown">;
const COMMANDS: Record<TabKey, string> = {
ghcr: "docker compose pull && docker compose up -d",
source: "git pull && docker compose up -d --build",
};
export function UpdateModal({ info, onClose }: UpdateModalProps) {
const { t } = useT();
// Default tab: detected mode if it's ghcr or source, otherwise ghcr.
const initialTab: TabKey = info.deployMode === "source" ? "source" : "ghcr";
const [tab, setTab] = useState<TabKey>(initialTab);
const [copied, setCopied] = useState(false);
// Lock body scroll + block wheel events on canvas (React Flow zooms on wheel)
// while the modal is open. Restore on close.
useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const blockWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (!target.closest("[data-update-modal]")) {
e.preventDefault();
e.stopPropagation();
}
};
document.addEventListener("wheel", blockWheel, { passive: false, capture: true });
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("wheel", blockWheel, { capture: true } as any);
};
}, []);
const copy = async () => {
const text = COMMANDS[tab];
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch {}
if (!ok) {
// Fallback for non-secure contexts (HTTP from LAN IP, older browsers).
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
ta.style.top = "0";
ta.setAttribute("readonly", "");
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length);
ok = document.execCommand("copy");
document.body.removeChild(ta);
} catch {}
}
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
console.warn("ContainerFlow: clipboard copy failed");
}
};
const releaseLines = info.releaseNotes ? info.releaseNotes.split("\n").slice(0, 15) : [];
return createPortal(
<div
className="fixed inset-0 z-[100000] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
data-update-modal
className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header — 3-row grid so version aligns with "X versions behind"
badge, and release-notes link aligns with the repo link. */}
<div className="relative grid grid-cols-[1fr_auto] gap-x-4 gap-y-1.5 px-5 pt-5 pb-3 border-b border-slate-800 items-center">
{/* Close button — absolutely positioned top-right so it doesn't
affect grid row heights. */}
<button
onClick={onClose}
className="absolute top-3 right-3 text-slate-500 hover:text-slate-300 transition-colors"
>
<X size={18} />
</button>
{/* Row 1: label / (empty, X lives absolute) */}
<div className="text-xs uppercase tracking-wider text-emerald-400 font-semibold">
{t("update.available")}
</div>
<div className="w-5" /> {/* spacer matching X width */}
{/* Row 2: version / releases-behind badge */}
<div className="text-lg font-bold tracking-tight">
<span className="text-slate-300">v{info.current}</span>{" "}
<span className="text-slate-500"></span>{" "}
<span className="text-emerald-400">v{info.latest}</span>
</div>
{info.releasesAhead > 1 ? (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 whitespace-nowrap justify-self-end">
{t("update.releasesBehind").replace("{n}", String(info.releasesAhead))}
</span>
) : (
<div />
)}
{/* Row 3: release notes link / repo link */}
{info.releaseUrl ? (
<a
href={info.releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-cyan-400 hover:text-cyan-300 transition-colors w-fit"
>
{t("update.fullNotes")}
<ExternalLink size={11} />
</a>
) : (
<div />
)}
<a
href={info.repoUrl}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors whitespace-nowrap justify-self-end"
>
<Github size={12} />
{t("update.viewRepo")}
{info.stars !== null && (
<span className="inline-flex items-center gap-0.5">
· {info.stars.toLocaleString()}
<Star size={10} />
</span>
)}
</a>
</div>
{/* Release notes preview — caps height + scrolls when there are many changes */}
{releaseLines.length > 0 && (
<div className="px-5 py-4 border-b border-slate-800">
<div className="text-[11px] uppercase tracking-wider text-slate-500 font-semibold mb-2">
{t("update.whatsNew")}
</div>
<ul className="space-y-1 max-h-40 overflow-y-auto pr-1">
{releaseLines.map((line, i) => (
<li key={i} className="text-sm text-slate-300 leading-relaxed flex gap-2">
<span className="text-slate-600 shrink-0"></span>
<span>{line}</span>
</li>
))}
</ul>
</div>
)}
{/* How to update — tabs */}
<div className="px-5 py-4">
<div className="text-[11px] uppercase tracking-wider text-slate-500 font-semibold mb-2">
{t("update.howToUpdate")}
</div>
{/* Tab buttons */}
<div className="flex items-center gap-1 mb-3 bg-slate-800/50 rounded-lg p-1">
<button
onClick={() => setTab("ghcr")}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
tab === "ghcr"
? "bg-slate-700 text-white"
: "text-slate-400 hover:text-slate-200"
}`}
>
<Container size={13} />
{t("update.tabGhcr")}
{info.deployMode === "ghcr" && (
<span className="text-[9px] text-emerald-400 ml-0.5"></span>
)}
</button>
<button
onClick={() => setTab("source")}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
tab === "source"
? "bg-slate-700 text-white"
: "text-slate-400 hover:text-slate-200"
}`}
>
<GitBranch size={13} />
{t("update.tabSource")}
{info.deployMode === "source" && (
<span className="text-[9px] text-emerald-400 ml-0.5"></span>
)}
</button>
</div>
{/* Tab description */}
<div className="text-[11px] text-slate-500 mb-2">
{tab === "ghcr" ? t("update.ghcrHint") : t("update.sourceHint")}
{info.deployMode === tab && (
<span className="text-emerald-400 ml-1.5">· {t("update.detectedMode")}</span>
)}
</div>
{/* Command block */}
<div className="relative bg-slate-950 border border-slate-800 rounded-md p-3 pr-12 font-mono text-[11px] text-slate-200 overflow-x-auto">
<code className="whitespace-pre">{COMMANDS[tab]}</code>
<button
onClick={copy}
className="absolute top-2 right-2 p-1.5 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-colors"
title={copied ? t("update.copied") : t("update.copyCommand")}
>
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
+30 -10
View File
@@ -9,9 +9,15 @@ 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/);
// 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] || "";
if (key === "") return "prod";
return key.replace(/^\./, "");
@@ -107,7 +113,10 @@ export function buildLayout(
const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X;
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 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 });
@@ -121,18 +130,25 @@ export function buildLayout(
const bgColor = knownBg || dynamic!.bg;
const borderColor = knownBorder || dynamic!.border;
// Compose file subtitle — show unique compose files in this group
const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))]
// Subtitle: the compose filename(s). For COMPOSE_FILE merges, show the
// 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() || "")
.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({
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,
@@ -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) => {
const col = 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);
nodes.push({
+35
View File
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from "react";
import type { UpdateInfo } from "../../shared/types";
/** Hook for in-app update notification.
* - Fetches /api/update-info on mount + on window focus (server caches 6h)
* - Indicator persists while `updateAvailable` is true — no dismiss option
* by design, so users actually update instead of silencing the prompt.
*/
export function useUpdateInfo(token: string) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const refetch = useCallback(async () => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
const res = await fetch("/api/update-info", { headers });
if (!res.ok) return;
const data = (await res.json()) as UpdateInfo;
setInfo(data);
} catch {
// Network errors are silent — no notification shown
}
}, [token]);
useEffect(() => {
refetch();
const onFocus = () => refetch();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [refetch]);
const showIndicator = Boolean(info?.updateAvailable && info.latest);
return { info, showIndicator, refetch };
}
+58
View File
@@ -20,6 +20,35 @@ 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",
"group.changeColor": "Change color",
"group.resetColor": "Reset color",
// Update notification
"update.available": "Update available",
"update.whatsNew": "What's new",
"update.howToUpdate": "How to update",
"update.tabGhcr": "Prebuilt image",
"update.tabSource": "Local build",
"update.ghcrHint": "If you pulled the image from GitHub Container Registry.",
"update.sourceHint": "If you cloned the repo and build from source.",
"update.detectedMode": "Detected",
"update.copyCommand": "Copy command",
"update.copied": "Copied",
"update.fullNotes": "See full release notes",
"update.viewRepo": "View on GitHub",
"update.releasesBehind": "{n} versions behind",
// Login
"login.connecting": "Connecting...",
"login.connect": "Connect",
@@ -279,6 +308,35 @@ 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.changeColor": "Cambiar color",
"group.resetColor": "Restaurar color",
// Update notification
"update.available": "Nueva versión",
"update.whatsNew": "Qué hay de nuevo",
"update.howToUpdate": "Cómo actualizar",
"update.tabGhcr": "Imagen prebuilt",
"update.tabSource": "Build local",
"update.ghcrHint": "Si descargaste la imagen desde GitHub Container Registry.",
"update.sourceHint": "Si clonaste el repo y construís desde código fuente.",
"update.detectedMode": "Detectado",
"update.copyCommand": "Copiar comando",
"update.copied": "Copiado",
"update.fullNotes": "Ver notas completas",
"update.viewRepo": "Ver repositorio",
"update.releasesBehind": "{n} versiones atrás",
"group.cancelAlias": "Cancelar",
// Login
"login.connecting": "Conectando...",
"login.connect": "Conectar",
+316 -26
View File
@@ -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 { 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 {
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;
/** 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;
}
// 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 }> = {
INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" },
DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" },
@@ -35,39 +57,307 @@ function getProjectColor(label: string) {
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) {
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(" / ") : "";
// 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 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 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 (
<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 whitespace-nowrap"
style={{ color: config.color }}
>
/ {composePart}
</span>
)}
<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 && (
<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}
</span>
</div>
)}
</div>
{d.subtitle && (
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]">
{d.subtitle}
</span>
)}
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} />
{d.count != null && (
<div className="flex items-center gap-1.5">
<Box size={12} style={{ color: config.borderColor }} />
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
{d.count}
<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>
)}
</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
)}
</>
);
});
+37 -11
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");
@@ -174,7 +176,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
const projects = new Set<string>();
for (const svc of allServiceNames) {
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();
}, [allServiceNames]);
@@ -184,7 +186,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
if (selectedProjects.size === 0) return allServiceNames;
return allServiceNames.filter((svc) => {
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);
});
}, [allServiceNames, selectedProjects]);
@@ -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);
}
+8 -4
View File
@@ -27,8 +27,12 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
);
let services: Service[] = containers.map((c, i) => {
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
const project = c.Labels["com.docker.compose.project"] || "standalone";
// Docker Desktop on Windows may return null for Labels/Names/Ports
// where the Linux daemon returns {} / [] — defend at the boundary.
const labels = c.Labels ?? {};
const names = c.Names ?? [];
const name = labels["com.docker.compose.service"] || names[0]?.replace("/", "") || "unknown";
const project = labels["com.docker.compose.project"] || "docker";
const info = inspections[i] as any;
// Extract network IPs
@@ -64,7 +68,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
state,
status: c.Status,
ports: [...new Map(
c.Ports.filter((p) => p.PublicPort).map((p) => [
(c.Ports ?? []).filter((p) => p.PublicPort).map((p) => [
`${p.PublicPort}:${p.PrivatePort}`,
{ host: p.PublicPort!, container: p.PrivatePort },
])
@@ -72,7 +76,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
networks: Object.keys(c.NetworkSettings?.Networks || {}),
network_ips: networkIps,
project,
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
compose_file: labels["com.docker.compose.project.config_files"] || "",
env: (info?.Config?.Env || []) as string[],
restart_policy: info?.HostConfig?.RestartPolicy?.Name || "",
memory_limit: info?.HostConfig?.Memory || 0,
+111 -4
View File
@@ -8,6 +8,10 @@ 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 { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors";
import { getUpdateInfo } from "./update-check";
import pkg from "../../package.json";
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import { 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 +206,15 @@ 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();
const projectColors = loadProjectColors();
// Update info: best-effort, never block init. If the check fails (offline,
// rate-limit), return null so the UI just doesn't show a notification.
let updateInfo = null;
try {
updateInfo = await getUpdateInfo(pkg.version);
} catch {}
return c.json({ services, connections, positions, stats: lastStats, projectAliases, projectColors, updateInfo });
});
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
@@ -216,7 +228,7 @@ app.get("/api/config", (c) => {
// ── Helper: get service uid from container inspect info ──
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";
return `${project}/${service}`;
}
@@ -288,7 +300,7 @@ app.post("/api/containers/:id/rebuild", async (c) => {
if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
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) {
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
}
@@ -344,7 +356,7 @@ app.post("/api/containers/:id/recreate", async (c) => {
if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
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) {
return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400);
}
@@ -615,6 +627,101 @@ app.put("/api/container-settings", async (c) => {
}
});
// ── Project aliases ──
app.get("/api/project-aliases", (c) => {
return c.json(loadProjectAliases());
});
app.put("/api/project-aliases", async (c) => {
try {
const body = await c.req.json() as { project: string; alias: string };
if (!body.project) {
return c.json({ error: "Missing project" }, 400);
}
const aliases = loadProjectAliases();
const clean = sanitizeAlias(body.alias || "");
if (clean) {
aliases[body.project] = clean;
} else {
// Empty alias = reset to original (remove the entry)
delete aliases[body.project];
}
saveProjectAliases(aliases);
return c.json({ ok: true, alias: clean || null });
} catch {
return c.json({ error: "Failed to save" }, 500);
}
});
app.delete("/api/project-aliases/:project", (c) => {
const project = c.req.param("project");
if (!project) {
return c.json({ error: "Missing project" }, 400);
}
const aliases = loadProjectAliases();
delete aliases[project];
saveProjectAliases(aliases);
return c.json({ ok: true });
});
// ── Project colors ──
app.get("/api/project-colors", (c) => {
return c.json(loadProjectColors());
});
app.put("/api/project-colors", async (c) => {
try {
const body = await c.req.json() as { project: string; color: string };
if (!body.project) {
return c.json({ error: "Missing project" }, 400);
}
const colors = loadProjectColors();
const clean = sanitizeColor(body.color || "");
if (clean) {
colors[body.project] = clean;
} else {
delete colors[body.project];
}
saveProjectColors(colors);
return c.json({ ok: true, color: clean || null });
} catch {
return c.json({ error: "Failed to save" }, 500);
}
});
app.delete("/api/project-colors/:project", (c) => {
const project = c.req.param("project");
if (!project) {
return c.json({ error: "Missing project" }, 400);
}
const colors = loadProjectColors();
delete colors[project];
saveProjectColors(colors);
return c.json({ ok: true });
});
// ── Update info ──
app.get("/api/update-info", async (c) => {
try {
const info = await getUpdateInfo(pkg.version);
return c.json(info);
} catch {
// Silent fallback — never error the client with this metadata call.
return c.json({
current: pkg.version,
latest: null,
updateAvailable: false,
releasesAhead: 0,
releaseUrl: null,
repoUrl: "https://github.com/RGJorge/ContainerFlow",
releaseNotes: null,
publishedAt: null,
deployMode: "unknown",
stars: null,
});
}
});
// ── Stats history ──
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);
}
+27
View File
@@ -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();
}
+209
View File
@@ -0,0 +1,209 @@
import Docker from "dockerode";
const REPO = "RGJorge/ContainerFlow";
const REPO_URL = `https://github.com/${REPO}`;
const RELEASES_URL = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
const REPO_INFO_URL = `https://api.github.com/repos/${REPO}`;
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
export type DeployMode = "ghcr" | "source" | "unknown";
export interface UpdateInfo {
current: string;
latest: string | null;
updateAvailable: boolean;
/** Number of stable releases between current and latest (e.g. 7 if you're on 0.1.0 and latest is 0.1.7). */
releasesAhead: number;
releaseUrl: string | null;
repoUrl: string;
releaseNotes: string | null;
publishedAt: string | null;
deployMode: DeployMode;
/** Current star count on the GitHub repo (null if fetch failed). */
stars: number | null;
}
interface CacheEntry {
data: UpdateInfo;
fetchedAt: number;
}
let cache: CacheEntry | null = null;
let inFlight: Promise<UpdateInfo> | null = null;
function parseSemver(v: string): [number, number, number] | null {
const m = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return [parseInt(m[1]!), parseInt(m[2]!), parseInt(m[3]!)];
}
function isNewer(latest: string, current: string): boolean {
const a = parseSemver(latest);
const b = parseSemver(current);
if (!a || !b) return false;
for (let i = 0; i < 3; i++) {
if (a[i]! > b[i]!) return true;
if (a[i]! < b[i]!) return false;
}
return false;
}
async function detectDeployMode(): Promise<DeployMode> {
try {
const hostname = process.env.HOSTNAME;
if (!hostname) return "unknown";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const container = await docker.getContainer(hostname).inspect();
const image = container.Config?.Image || "";
if (image.startsWith("ghcr.io/rgjorge/containerflow")) return "ghcr";
if (image === "containerflow:local" || image.startsWith("containerflow:")) return "source";
return "unknown";
} catch {
return "unknown";
}
}
// Pull the first ~6 highlight lines from release notes. Captures both
// bullet lists and ### / ## headings so any reasonable release format works.
function summarizeReleaseNotes(body: string | undefined | null): string | null {
if (!body) return null;
const SKIP_HEADINGS = /^(what'?s new|changelog|full changelog|notes|highlights)$/i;
const lines = body.split(/\r?\n/);
const bullets: string[] = [];
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
// Bullet list items
if (line.startsWith("- ") || line.startsWith("* ")) {
bullets.push(stripMd(line.replace(/^[-*]\s+/, "")));
}
// Numbered list items
else if (/^\d+\.\s/.test(line)) {
bullets.push(stripMd(line.replace(/^\d+\.\s+/, "")));
}
// ## or ### headings (skip the generic "What's new" wrappers)
else if (line.startsWith("### ") || line.startsWith("## ")) {
const text = stripMd(line.replace(/^#+\s+/, ""));
if (!SKIP_HEADINGS.test(text)) bullets.push(text);
}
if (bullets.length >= 15) break;
}
return bullets.length > 0 ? bullets.join("\n") : null;
}
function stripMd(s: string): string {
return s
.replace(/`([^`]+)`/g, "$1") // inline code
.replace(/\*\*([^*]+)\*\*/g, "$1") // bold
.replace(/\*([^*]+)\*/g, "$1") // italic
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → text
.trim();
}
function emptyInfo(currentVersion: string, deployMode: DeployMode, stars: number | null = null): UpdateInfo {
return {
current: currentVersion,
latest: null,
updateAvailable: false,
releasesAhead: 0,
releaseUrl: null,
repoUrl: REPO_URL,
releaseNotes: null,
publishedAt: null,
deployMode,
stars,
};
}
async function fetchStars(currentVersion: string): Promise<number | null> {
try {
const res = await fetch(REPO_INFO_URL, {
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": `ContainerFlow/${currentVersion}`,
},
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = (await res.json()) as { stargazers_count?: number };
return typeof data.stargazers_count === "number" ? data.stargazers_count : null;
} catch {
return null;
}
}
async function fetchLatest(currentVersion: string): Promise<UpdateInfo> {
const [deployMode, stars] = await Promise.all([detectDeployMode(), fetchStars(currentVersion)]);
try {
const res = await fetch(RELEASES_URL, {
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": `ContainerFlow/${currentVersion}`,
},
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return emptyInfo(currentVersion, deployMode, stars);
const releases = (await res.json()) as Array<{
tag_name?: string;
html_url?: string;
body?: string;
published_at?: string;
prerelease?: boolean;
draft?: boolean;
}>;
if (!Array.isArray(releases) || releases.length === 0) {
return emptyInfo(currentVersion, deployMode, stars);
}
// Stable releases only (no drafts, no prereleases). GitHub returns them
// sorted newest first, which is what we want for `latest`.
const stable = releases.filter((r) => !r.prerelease && !r.draft && r.tag_name);
if (stable.length === 0) return emptyInfo(currentVersion, deployMode, stars);
const latestRelease = stable[0]!;
const latest = latestRelease.tag_name!.replace(/^v/, "");
const updateAvailable = isNewer(latest, currentVersion);
// How many stable releases are strictly newer than what the user is running?
let releasesAhead = 0;
if (updateAvailable) {
for (const r of stable) {
const v = r.tag_name!.replace(/^v/, "");
if (isNewer(v, currentVersion)) releasesAhead++;
}
}
return {
current: currentVersion,
latest,
updateAvailable,
releasesAhead,
releaseUrl: latestRelease.html_url || `${REPO_URL}/releases/tag/${latestRelease.tag_name}`,
repoUrl: REPO_URL,
releaseNotes: summarizeReleaseNotes(latestRelease.body),
publishedAt: latestRelease.published_at || null,
deployMode,
stars,
};
} catch {
return emptyInfo(currentVersion, deployMode, stars);
}
}
export async function getUpdateInfo(currentVersion: string): Promise<UpdateInfo> {
const now = Date.now();
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) return cache.data;
if (inFlight) return inFlight;
inFlight = fetchLatest(currentVersion)
.then((data) => {
cache = { data, fetchedAt: Date.now() };
inFlight = null;
return data;
})
.catch((err) => {
inFlight = null;
throw err;
});
return inFlight;
}
+1 -1
View File
@@ -138,7 +138,7 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
"unknown";
const svcProject =
event.Actor?.Attributes?.["com.docker.compose.project"] ||
"standalone";
"docker";
onEvent({
type: "docker",
action,
+15
View File
@@ -131,6 +131,21 @@ export interface ServerConfig {
restrictedMode: boolean;
}
export type DeployMode = "ghcr" | "source" | "unknown";
export interface UpdateInfo {
current: string;
latest: string | null;
updateAvailable: boolean;
releasesAhead: number;
releaseUrl: string | null;
repoUrl: string;
releaseNotes: string | null;
publishedAt: string | null;
deployMode: DeployMode;
stars: number | null;
}
export interface EventLogEntry {
id: number;
timestamp: number;