6 Commits
Author SHA1 Message Date
RGJorge d38b8a9ff0 v0.1.10 — WebSocket incremental graph diffs 2026-05-30 21:02:07 +00:00
RGJorge fae265727c perf: WebSocket graph diffs — broadcast only what changed 2026-05-30 20:57:44 +00:00
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
19 changed files with 1623 additions and 162 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "containerflow", "name": "containerflow",
"version": "0.1.5", "version": "0.1.10",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": "Jorge Gonzalez D. (RGJorge)", "author": "Jorge Gonzalez D. (RGJorge)",
"type": "module", "type": "module",
+221 -53
View File
@@ -18,7 +18,7 @@ import { useDocker } from "./hooks/useDocker";
import { useServerConfig } from "./hooks/useServerConfig"; import { useServerConfig } from "./hooks/useServerConfig";
import { I18nProvider, useT } from "./i18n"; import { I18nProvider, useT } from "./i18n";
import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore"; import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore";
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout"; import { buildLayout, computeEdges, getComposeKey, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
import { DetailPanel } from "./panels/DetailPanel"; import { DetailPanel } from "./panels/DetailPanel";
import { NodeContextMenu } from "./components/NodeContextMenu"; import { NodeContextMenu } from "./components/NodeContextMenu";
import { LoginScreen } from "./components/LoginScreen"; import { LoginScreen } from "./components/LoginScreen";
@@ -144,6 +144,64 @@ function Dashboard({ token }: { token: string }) {
const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({}); const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({});
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 }); const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 });
const [discordEnabled, setDiscordEnabled] = useState(false); const [discordEnabled, setDiscordEnabled] = useState(false);
// Project aliases — friendly names for cryptic project keys (Coolify, Dokploy, etc.)
const [projectAliases, setProjectAliases] = useState<Record<string, string>>({});
// Save / reset handler — passed to GroupNode via node data. Wrapped in a ref
// so the layout effect doesn't have to recompute every time the callback
// identity changes; GroupNode always gets the latest version.
const handleAliasChange = useCallback(async (project: string, newAlias: string) => {
const trimmed = newAlias.trim();
setProjectAliases((prev) => {
const next = { ...prev };
if (trimmed) next[project] = trimmed;
else delete next[project];
return next;
});
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
await fetch("/api/project-aliases", {
method: "PUT",
headers,
body: JSON.stringify({ project, alias: trimmed }),
});
} catch {
// On error, refetch from server to revert optimistic update.
fetch("/api/project-aliases", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectAliases)
.catch(() => {});
}
}, [token]);
const handleAliasChangeRef = useRef(handleAliasChange);
handleAliasChangeRef.current = handleAliasChange;
// Project colors — per-project hex color overrides for the group background.
const [projectColors, setProjectColors] = useState<Record<string, string>>({});
const handleColorChange = useCallback(async (project: string, color: string) => {
setProjectColors((prev) => {
const next = { ...prev };
if (color) next[project] = color;
else delete next[project];
return next;
});
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
await fetch("/api/project-colors", {
method: "PUT",
headers,
body: JSON.stringify({ project, color }),
});
} catch {
fetch("/api/project-colors", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectColors)
.catch(() => {});
}
}, [token]);
const handleColorChangeRef = useRef(handleColorChange);
handleColorChangeRef.current = handleColorChange;
useEffect(() => { useEffect(() => {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -151,6 +209,14 @@ function Dashboard({ token }: { token: string }) {
.then((r) => r.ok ? r.json() : {}) .then((r) => r.ok ? r.json() : {})
.then(setContainerSettings) .then(setContainerSettings)
.catch(() => {}); .catch(() => {});
fetch("/api/project-aliases", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectAliases)
.catch(() => {});
fetch("/api/project-colors", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setProjectColors)
.catch(() => {});
fetch("/api/discord-config", { headers }) fetch("/api/discord-config", { headers })
.then((r) => r.ok ? r.json() : null) .then((r) => r.ok ? r.json() : null)
.then((c: any) => { .then((c: any) => {
@@ -272,7 +338,7 @@ function Dashboard({ token }: { token: string }) {
} }
} }
// Keep leftmost child at MIN_X — build parent→children index once // Build parent→children index once
const childrenByParent = new Map<string, number[]>(); const childrenByParent = new Map<string, number[]>();
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
const pid = nodes[i].parentId; const pid = nodes[i].parentId;
@@ -282,45 +348,61 @@ function Dashboard({ token }: { token: string }) {
arr.push(i); arr.push(i);
} }
for (const [gid, kidIdxs] of childrenByParent) { // After every drag-end: re-center kids horizontally + vertically within
let minChildX = Infinity; // their group, resize the group to fit, and shift the group on the
for (const ki of kidIdxs) minChildX = Math.min(minChildX, nodes[ki].position.x); // canvas by the opposite of the kid shift so visible positions don't jump.
if (minChildX !== MIN_X) { const FOOTER_RESERVE = 22;
const shift = minChildX - MIN_X; const minW = NODE_W + G_PAD * 3;
changed = true; const groupIdxById = new Map<string, number>();
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]; if (nodes[i].id.startsWith("group-")) groupIdxById.set(nodes[i].id, i);
if (n.id === gid) nodes[i] = { ...n, position: { x: n.position.x + shift, y: n.position.y } };
else if (n.parentId === gid) nodes[i] = { ...n, position: { x: n.position.x - shift, y: n.position.y } };
}
}
} }
// Resize groups to fit children for (const [gid, kidIdxs] of childrenByParent) {
for (let i = 0; i < nodes.length; i++) { if (kidIdxs.length === 0) continue;
const n = nodes[i]; let minLeft = Infinity, minTop = Infinity;
if (!n.id.startsWith("group-")) continue; let maxRight = 0, maxBottom = 0;
const kidIdxs = childrenByParent.get(n.id);
if (!kidIdxs || kidIdxs.length === 0) continue;
let maxRight = 0;
let maxBottom = 0;
for (const ki of kidIdxs) { for (const ki of kidIdxs) {
const k = nodes[ki]; const k = nodes[ki];
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); minLeft = Math.min(minLeft, k.position.x);
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); maxRight = Math.max(maxRight, k.position.x + NODE_W);
minTop = Math.min(minTop, k.position.y);
maxBottom = Math.max(maxBottom, k.position.y + NODE_H);
}
const contentW = maxRight - minLeft;
const contentH = maxBottom - minTop;
const newW = Math.max(contentW + G_PAD * 2, minW);
const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE;
const targetLeft = (newW - contentW) / 2;
const targetTop = GROUP_HEADER + G_PAD;
const shiftX = targetLeft - minLeft;
const shiftY = targetTop - minTop;
if (shiftX !== 0 || shiftY !== 0) {
changed = true;
// Shift kids inside the group...
for (const ki of kidIdxs) {
const k = nodes[ki];
nodes[ki] = { ...k, position: { x: k.position.x + shiftX, y: k.position.y + shiftY } };
}
// ...and shift the group itself by the opposite so canvas-relative
// positions stay where the user just dropped them.
const gIdx = groupIdxById.get(gid);
if (gIdx !== undefined) {
const g = nodes[gIdx];
nodes[gIdx] = { ...g, position: { x: g.position.x - shiftX, y: g.position.y - shiftY } };
}
} }
const minW = NODE_W + G_PAD * 3; const gIdx = groupIdxById.get(gid);
const newW = Math.max(maxRight, minW); if (gIdx !== undefined) {
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); const g = nodes[gIdx];
const curW = (g.style?.width as number) || 0;
const curW = (n.style?.width as number) || 0; const curH = (g.style?.height as number) || 0;
const curH = (n.style?.height as number) || 0; if (newW !== curW || newH !== curH) {
changed = true;
if (newW !== curW || newH !== curH) { nodes[gIdx] = { ...g, style: { ...g.style, width: newW, height: newH } };
changed = true; }
nodes[i] = { ...n, style: { ...n.style, width: newW, height: newH } };
} }
} }
@@ -365,7 +447,8 @@ function Dashboard({ token }: { token: string }) {
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections); const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
// Mark service nodes as locked + inject effective thresholds for progress bar coloring // Mark service nodes as locked + inject effective thresholds for progress bar coloring.
// For group nodes, inject the alias (if any) + the change handler.
for (const n of newNodes) { for (const n of newNodes) {
if (n.type === "service") { if (n.type === "service") {
const svc = filteredServices.find((s) => s.uid === n.id); const svc = filteredServices.find((s) => s.uid === n.id);
@@ -376,29 +459,97 @@ function Dashboard({ token }: { token: string }) {
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined; (n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
(n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined; (n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined;
} }
} else if (n.type === "group") {
// Use the raw `project` from the layout (NOT the groupKey which includes
// the compose suffix). This way the alias matches what `service.project`
// is on individual services, and the filter dropdown shares the same key.
const project = (n.data as any).project as string | undefined;
if (project) {
(n.data as any).alias = projectAliases[project];
(n.data as any).color = projectColors[project];
(n.data as any).onAliasChange = handleAliasChangeRef.current;
(n.data as any).onColorChange = handleColorChangeRef.current;
// Apply custom color to group background/border. Falls back to the
// auto-assigned palette in buildLayout when not set.
const hex = projectColors[project];
if (hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
n.style = {
...n.style,
background: `rgba(${r}, ${g}, ${b}, 0.08)`,
border: `1px dashed rgba(${r}, ${g}, ${b}, 0.3)`,
color: `rgba(${r}, ${g}, ${b}, 0.8)`,
};
}
}
} }
} }
if (!initialLayoutDone.current) { if (!initialLayoutDone.current) {
// Single-service groups can't be "arranged" — always honor the computed
// (centered) position from buildLayout, ignoring any stale saved value.
const servicesPerGroup = new Map<string, number>();
for (const n of newNodes) {
if (n.type === "service" && n.parentId) {
servicesPerGroup.set(n.parentId, (servicesPerGroup.get(n.parentId) || 0) + 1);
}
}
let positioned = newNodes.map((n) => { let positioned = newNodes.map((n) => {
if (n.type === "service" && n.parentId && servicesPerGroup.get(n.parentId) === 1) {
return n;
}
const saved = savedPositions.current[n.id]; const saved = savedPositions.current[n.id];
if (saved) return { ...n, position: saved }; if (saved) return { ...n, position: saved };
return n; return n;
}); });
positioned = positioned.map((n) => { // Resize each group to fit its kids AND recenter content horizontally
if (n.type !== "group") return n; // + vertically. We measure the bounding box of children, then shift them
const kids = positioned.filter((c) => c.parentId === n.id); // as a block so margins are symmetric on all four sides. Preserves the
if (kids.length === 0) return n; // relative spacing between kids (a vertical stack stays a vertical stack,
let maxRight = 0; // just centered). FOOTER_RESERVE accounts for the compose subtitle at
let maxBottom = 0; // the bottom of every group.
for (const k of kids) { const FOOTER_RESERVE = 22;
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); const groupKids = new Map<string, Node[]>();
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); for (const n of positioned) {
if (n.type === "service" && n.parentId) {
if (!groupKids.has(n.parentId)) groupKids.set(n.parentId, []);
groupKids.get(n.parentId)!.push(n);
} }
}
const groupDims = new Map<string, { width: number; height: number; shiftX: number; shiftY: number }>();
for (const [groupId, kids] of groupKids) {
let minLeft = Infinity, minTop = Infinity;
let maxRight = 0, maxBottom = 0;
for (const k of kids) {
minLeft = Math.min(minLeft, k.position.x);
maxRight = Math.max(maxRight, k.position.x + NODE_W);
minTop = Math.min(minTop, k.position.y);
maxBottom = Math.max(maxBottom, k.position.y + NODE_H);
}
const contentW = maxRight - minLeft;
const contentH = maxBottom - minTop;
const minW = NODE_W + G_PAD * 3; const minW = NODE_W + G_PAD * 3;
const newW = Math.max(maxRight, minW); const newW = Math.max(contentW + G_PAD * 2, minW);
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE;
return { ...n, style: { ...n.style, width: newW, height: newH } }; const shiftX = (newW - contentW) / 2 - minLeft;
const shiftY = (GROUP_HEADER + G_PAD) - minTop;
groupDims.set(groupId, { width: newW, height: newH, shiftX, shiftY });
}
positioned = positioned.map((n) => {
if (n.type === "service" && n.parentId) {
const dim = groupDims.get(n.parentId);
if (dim && (dim.shiftX !== 0 || dim.shiftY !== 0)) {
return { ...n, position: { x: n.position.x + dim.shiftX, y: n.position.y + dim.shiftY } };
}
return n;
}
if (n.type === "group") {
const dim = groupDims.get(n.id);
if (dim) return { ...n, style: { ...n.style, width: dim.width, height: dim.height } };
}
return n;
}); });
const { edges, activeHandles } = computeEdges(positioned, filteredConnections); const { edges, activeHandles } = computeEdges(positioned, filteredConnections);
for (const n of positioned) { for (const n of positioned) {
@@ -419,6 +570,17 @@ function Dashboard({ token }: { token: string }) {
for (const nn of newNodes) { for (const nn of newNodes) {
const existing = prevNodeMap.get(nn.id); const existing = prevNodeMap.get(nn.id);
if (existing) { if (existing) {
// For groups, accept the new style (color overrides live there)
// but preserve current width/height which may reflect a user drag.
if (nn.type === "group") {
const mergedStyle = {
...nn.style,
width: (existing.style as any)?.width,
height: (existing.style as any)?.height,
};
result.push({ ...existing, data: nn.data, style: mergedStyle });
continue;
}
// Keep position and style, update data // Keep position and style, update data
result.push({ ...existing, data: nn.data }); result.push({ ...existing, data: nn.data });
} else { } else {
@@ -459,7 +621,7 @@ function Dashboard({ token }: { token: string }) {
return result; return result;
}); });
} }
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]); }, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled, projectAliases, projectColors]);
// Recompute edges + handles on drag end (not every pixel) // Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => { const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -567,7 +729,7 @@ function Dashboard({ token }: { token: string }) {
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} /> <ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} />} {activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} projectAliases={projectAliases} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />} {activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
{/* Canvas — inset (only visible on dashboard) */} {/* Canvas — inset (only visible on dashboard) */}
@@ -674,7 +836,7 @@ function Dashboard({ token }: { token: string }) {
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} /> <ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
</button> </button>
{filterOpen && ( {filterOpen && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[220px] max-h-[280px] overflow-y-auto"> <div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[280px] max-h-[280px] overflow-y-auto">
{/* Select/Deselect all */} {/* Select/Deselect all */}
<button <button
onClick={() => { onClick={() => {
@@ -705,11 +867,14 @@ function Dashboard({ token }: { token: string }) {
const projectServices = services.filter((s) => s.project === p); const projectServices = services.filter((s) => s.project === p);
const running = projectServices.filter((s) => s.state === "running").length; const running = projectServices.filter((s) => s.state === "running").length;
const stopped = projectServices.length - running; const stopped = projectServices.length - running;
const display = projectAliases[p] || p;
const composeKeys = [...new Set(projectServices.map((s) => getComposeKey(s.compose_file)))];
const composeSuffix = composeKeys.join(" - ");
return ( return (
<button <button
key={p} key={p}
onClick={() => toggleProject(p)} onClick={() => toggleProject(p)}
title={p} title={composeSuffix ? `${display} / ${composeSuffix}` : display}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors" className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
> >
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${ <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
@@ -717,7 +882,10 @@ function Dashboard({ token }: { token: string }) {
}`}> }`}>
{active && <Check size={12} className="text-white" />} {active && <Check size={12} className="text-white" />}
</div> </div>
<span className={`flex-1 min-w-0 truncate text-left ${active ? "text-slate-200" : "text-slate-500"}`}>{p}</span> <span className={`flex-1 min-w-0 truncate text-left uppercase ${active ? "text-slate-200" : "text-slate-500"}`}>
{display}
{composeSuffix && <span className="ml-1 text-xs text-slate-500">/ {composeSuffix}</span>}
</span>
<span className="ml-auto flex items-center gap-1.5 text-xs shrink-0"> <span className="ml-auto flex items-center gap-1.5 text-xs shrink-0">
<span className="text-emerald-500/70">{running}</span> <span className="text-emerald-500/70">{running}</span>
<span className="text-slate-600">/</span> <span className="text-slate-600">/</span>
+28 -2
View File
@@ -6,6 +6,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types"; import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useUpdateInfo } from "../hooks/useUpdateInfo";
import { UpdateModal } from "./UpdateModal";
export type Page = "dashboard" | "monitoring" | "settings"; export type Page = "dashboard" | "monitoring" | "settings";
@@ -191,6 +193,8 @@ export function HeaderBar({
onOpenServiceDetail, onOpenServiceDetail,
}: HeaderBarProps) { }: HeaderBarProps) {
const { t, lang, setLang } = useT(); const { t, lang, setLang } = useT();
const { info: updateInfo, showIndicator } = useUpdateInfo(token);
const [updateOpen, setUpdateOpen] = useState(false);
return ( return (
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]"> <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")} /> <NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
</nav> </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"> <div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
<img <img
src="/alteonx-logo.webp" src="/alteonx-logo.webp"
@@ -211,7 +216,21 @@ export function HeaderBar({
/> />
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span> <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>
</div> </div>
@@ -267,6 +286,13 @@ export function HeaderBar({
</button> </button>
)} )}
</div> </div>
{updateOpen && updateInfo && (
<UpdateModal
info={updateInfo}
onClose={() => setUpdateOpen(false)}
/>
)}
</div> </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; export const GROUP_HEADER = 44;
const GROUP_GAP = 50; const GROUP_GAP = 50;
function getComposeKey(file: string): string { export function getComposeKey(file: string): string {
if (!file) return "default"; if (!file) return "default";
const match = file.match(/docker-compose\.?(.*)\.yml/); // When multiple compose files are merged (COMPOSE_FILE env var with
// multiple paths), the docker `config_files` label is a comma-joined list.
// Use the LAST path — in docker-compose, the override file wins, and its
// name (e.g. "local", "dev") is the meaningful environment key.
const files = file.split(",");
const primary = files[files.length - 1] || file;
const match = primary.match(/docker-compose\.?(.*)\.yml/);
const key = match?.[1] || ""; const key = match?.[1] || "";
if (key === "") return "prod"; if (key === "") return "prod";
return key.replace(/^\./, ""); return key.replace(/^\./, "");
@@ -107,7 +113,10 @@ export function buildLayout(
const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X; const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X;
const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y; const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y;
const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3); const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3);
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_HEADER + GROUP_PADDING; // Vertical: header + top padding + content + bottom padding + footer reserve.
// Keeps top/bottom margins symmetric and leaves room for the subtitle footer.
const FOOTER_RESERVE = 22;
const groupHeight = GROUP_HEADER + GROUP_PADDING + contentHeight + GROUP_PADDING + FOOTER_RESERVE;
groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight }); groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight });
@@ -121,18 +130,25 @@ export function buildLayout(
const bgColor = knownBg || dynamic!.bg; const bgColor = knownBg || dynamic!.bg;
const borderColor = knownBorder || dynamic!.border; const borderColor = knownBorder || dynamic!.border;
// Compose file subtitle — show unique compose files in this group // Subtitle: the compose filename(s). For COMPOSE_FILE merges, show the
const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))] // override (last file) since that's what defines the runtime config.
// Containers without compose labels (plain `docker run`) fall back to "docker".
const composeFiles = [...new Set(svcs.map((s) => {
const parts = (s.compose_file || "").split(",");
return parts[parts.length - 1] || s.compose_file;
}).filter(Boolean))]
.map((f) => f.split("/").pop() || "") .map((f) => f.split("/").pop() || "")
.filter(Boolean); .filter(Boolean);
const subtitle = composeFiles.join(", "); const subtitle = composeFiles.length > 0 ? composeFiles.join(", ") : "docker";
// Group node // Group node. `project` is the raw project key (without the compose part);
// it's what the alias system uses so the same alias applies across all
// compose files of the same project and matches the filter dropdown.
nodes.push({ nodes.push({
id: `group-${groupKey}`, id: `group-${groupKey}`,
type: "group", type: "group",
position: { x: groupX, y: 0 }, position: { x: groupX, y: 0 },
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length }, data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length, project: svcs[0]?.project },
style: { style: {
width: groupWidth, width: groupWidth,
height: groupHeight, height: groupHeight,
@@ -146,11 +162,15 @@ export function buildLayout(
}, },
}); });
// Service nodes inside group (grid layout) // Service nodes inside group (grid layout).
// Horizontally center the content within the group: when there's only
// one service (or when groupWidth was bumped to its minimum), the row
// would otherwise sit left-aligned with extra space on the right.
const horizontalCenter = (groupWidth - contentWidth) / 2;
svcs.forEach((svc, i) => { svcs.forEach((svc, i) => {
const col = i % cols; const col = i % cols;
const row = Math.floor(i / cols); const row = Math.floor(i / cols);
const x = GROUP_PADDING + col * (NODE_WIDTH + NODE_GAP_X); const x = horizontalCenter + col * (NODE_WIDTH + NODE_GAP_X);
const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y); const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y);
nodes.push({ nodes.push({
+53 -10
View File
@@ -1,8 +1,46 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError, EventLogEntry, NotificationLogEntry } from "../../shared/types"; import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, GraphDiff, ActionError, EventLogEntry, NotificationLogEntry } from "../../shared/types";
import type { StatsStore } from "./useStatsStore"; import type { StatsStore } from "./useStatsStore";
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing"; import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
function connKey(c: Connection): string {
return `${c.from}|${c.to}|${c.network}`;
}
function applyServicesDiff(current: Service[], diff: GraphDiff): Service[] {
let result = [...current];
if (diff.servicesRemoved?.length) {
const removed = new Set(diff.servicesRemoved);
result = result.filter((s) => !removed.has(s.uid));
}
if (diff.servicesUpdated?.length) {
const updated = new Map(diff.servicesUpdated.map((s) => [s.uid, s]));
result = result.map((s) => updated.get(s.uid) ?? s);
}
if (diff.servicesAdded?.length) {
const existing = new Set(result.map((s) => s.uid));
for (const s of diff.servicesAdded) {
if (!existing.has(s.uid)) result.push(s);
}
}
return result;
}
function applyConnectionsDiff(current: Connection[], diff: GraphDiff): Connection[] {
let result = [...current];
if (diff.connectionsRemoved?.length) {
const removed = new Set(diff.connectionsRemoved);
result = result.filter((c) => !removed.has(connKey(c)));
}
if (diff.connectionsAdded?.length) {
const existing = new Set(result.map(connKey));
for (const c of diff.connectionsAdded) {
if (!existing.has(connKey(c))) result.push(c);
}
}
return result;
}
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) { export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
const [services, setServices] = useState<Service[]>([]); const [services, setServices] = useState<Service[]>([]);
const [connections, setConnections] = useState<Connection[]>([]); const [connections, setConnections] = useState<Connection[]>([]);
@@ -86,19 +124,24 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
} }
switch (msg.type as WSMessage["type"]) { switch (msg.type as WSMessage["type"]) {
case "services": { case "snapshot": {
lastRawServicesRef.current = msg.data as Service[]; lastRawServicesRef.current = msg.data.services as Service[];
const incoming = applyProcessing(msg.data as Service[]); const incoming = applyProcessing(msg.data.services as Service[]);
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming); setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
setConnections(msg.data.connections as Connection[]);
break; break;
} }
case "connections": case "diff": {
setConnections((prev) => { const diff = msg.data as GraphDiff;
if (prev.length === msg.data.length && const nextRaw = applyServicesDiff(lastRawServicesRef.current, diff);
prev.every((c: any, i: number) => c.from === msg.data[i].from && c.to === msg.data[i].to)) return prev; lastRawServicesRef.current = nextRaw;
return msg.data; const incoming = applyProcessing(nextRaw);
}); setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
if (diff.connectionsAdded?.length || diff.connectionsRemoved?.length) {
setConnections((prev) => applyConnectionsDiff(prev, diff));
}
break; break;
}
case "stats": { case "stats": {
for (const s of msg.data) { for (const s of msg.data) {
statsRef.current.set(s.service, s); statsRef.current.set(s.service, s);
+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 };
}
+46
View File
@@ -26,6 +26,29 @@ const en = {
"controls.exportPng.tooLarge": "Graph is too large to export at full quality", "controls.exportPng.tooLarge": "Graph is too large to export at full quality",
"controls.exportPng.failed": "Failed to export graph", "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
"login.connecting": "Connecting...", "login.connecting": "Connecting...",
"login.connect": "Connect", "login.connect": "Connect",
@@ -291,6 +314,29 @@ const es: Record<TranslationKey, string> = {
"controls.exportPng.tooLarge": "El grafo es muy grande para exportar en alta calidad", "controls.exportPng.tooLarge": "El grafo es muy grande para exportar en alta calidad",
"controls.exportPng.failed": "Error al exportar el grafo", "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
"login.connecting": "Conectando...", "login.connecting": "Conectando...",
"login.connect": "Conectar", "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 type { NodeProps } from "@xyflow/react";
import { Server, Wrench, Rocket, Box, Folder } from "lucide-react"; import { Server, Wrench, Rocket, Box, Folder, Container, Pencil, RotateCcw, Check, X, Palette } from "lucide-react";
import { useT } from "../i18n";
interface GroupNodeData { interface GroupNodeData {
label: string; label: string;
subtitle?: string; subtitle?: string;
count?: number; count?: number;
/** Original (raw) project key used to look up / save the alias. */
project?: string;
/** Current alias if set, else undefined / empty string. */
alias?: string;
/** Current custom hex color (e.g. "#3b82f6"), if any. */
color?: string;
/** Save handler — called with (project, newAlias). Empty newAlias = reset. */
onAliasChange?: (project: string, newAlias: string) => void;
/** Color change handler — empty color = reset to default palette. */
onColorChange?: (project: string, color: string) => void;
[key: string]: unknown; [key: string]: unknown;
} }
// Palette shown when user clicks the color dot. First entry resets to default.
const COLOR_PALETTE: { hex: string; name: string }[] = [
{ hex: "#3b82f6", name: "blue" },
{ hex: "#8b5cf6", name: "purple" },
{ hex: "#06b6d4", name: "cyan" },
{ hex: "#22c55e", name: "green" },
{ hex: "#f59e0b", name: "yellow" },
{ hex: "#ef4444", name: "red" },
];
const groupConfig: Record<string, { icon: typeof Server; color: string; borderColor: string }> = { const groupConfig: Record<string, { icon: typeof Server; color: string; borderColor: string }> = {
INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" }, INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" },
DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" }, DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" },
@@ -35,39 +57,307 @@ function getProjectColor(label: string) {
return assignedColors.get(label)!; return assignedColors.get(label)!;
} }
function hexToRgba(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export const GroupNode = memo(function GroupNode({ data }: NodeProps) { export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
const { t } = useT();
const d = data as unknown as GroupNodeData; const d = data as unknown as GroupNodeData;
// Label is "PROJECT / COMPOSE" — match compose part for known colors // Label is "PROJECT / COMPOSE" (uppercase). We let users alias only the
const parts = d.label.split(" / "); // project portion — the compose suffix (DEV / PROD / INFRA / docker-compose
const composePart = parts.length > 1 ? parts[parts.length - 1] : d.label; // file name) stays as a structural hint and is also used for the icon match.
const known = groupConfig[composePart]; const labelParts = d.label.split(" / ");
const projectPart = labelParts[0] || d.label;
const composePart = labelParts.length > 1 ? labelParts.slice(1).join(" / ") : "";
// Standalone containers (no compose) are grouped under project="docker" with
// compose key "default". For those, drop the suffix from the title and use a
// distinct icon so the group reads as "containers running directly on docker".
const isStandalone = d.project === "docker";
const iconKey = composePart || d.label;
const known = groupConfig[iconKey];
const proj = known ? null : getProjectColor(d.label); const proj = known ? null : getProjectColor(d.label);
const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor }; const baseConfig = isStandalone
? { icon: Container, color: "#94a3b8", borderColor: "rgba(148, 163, 184, 0.3)" }
: known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor };
// Override the color when the user has picked a custom one for this project.
const config = d.color
? { icon: baseConfig.icon, color: d.color, borderColor: hexToRgba(d.color, 0.3) }
: baseConfig;
const Icon = config.icon; const Icon = config.icon;
const hasAlias = Boolean(d.alias && d.alias.trim().length > 0);
const projectDisplay = hasAlias ? d.alias! : projectPart;
const displayName = composePart && !isStandalone ? `${projectDisplay} / ${composePart}` : projectDisplay;
const canEdit = Boolean(d.project && d.onAliasChange);
const canColor = Boolean(d.project && d.onColorChange);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(projectDisplay);
const inputRef = useRef<HTMLInputElement | null>(null);
// Color palette popover
const colorBtnRef = useRef<HTMLButtonElement | null>(null);
const [palettePos, setPalettePos] = useState<{ left: number; top: number } | null>(null);
const openPalette = () => {
const el = colorBtnRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPalettePos({ left: rect.left + rect.width / 2, top: rect.bottom + 6 });
};
const closePalette = () => setPalettePos(null);
const pickColor = (hex: string) => {
if (!d.project) return;
d.onColorChange?.(d.project, hex);
closePalette();
};
// Close palette on outside click / Esc / wheel (zoom) / canvas pan.
// Palette uses fixed positioning so it'd visually detach from the button on
// pan/zoom — close instead of trying to follow. Use capture phase + pointer
// events because React Flow's pan handlers stop mousedown propagation.
useEffect(() => {
if (!palettePos) return;
const onDown = (e: Event) => {
const t = e.target as HTMLElement;
if (t.closest("[data-color-palette]") || t.closest("[data-color-btn]")) return;
closePalette();
};
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closePalette(); };
const onWheel = () => closePalette();
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
window.addEventListener("resize", closePalette);
window.addEventListener("blur", closePalette);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
document.removeEventListener("wheel", onWheel, { capture: true } as any);
window.removeEventListener("resize", closePalette);
window.removeEventListener("blur", closePalette);
};
}, [palettePos]);
// Footer tooltip: only show when text is actually clipped (`...`).
const footerRef = useRef<HTMLSpanElement | null>(null);
const [footerTip, setFooterTip] = useState<{ left: number; top: number } | null>(null);
const onFooterEnter = () => {
const el = footerRef.current;
if (!el) return;
if (el.scrollWidth <= el.clientWidth) return;
const rect = el.getBoundingClientRect();
setFooterTip({ left: rect.left + rect.width / 2, top: rect.top - 6 });
};
const onFooterLeave = () => setFooterTip(null);
// Shared tooltip state for header buttons (pencil / reset / color / save / cancel).
// Uses the same visual style as the Tooltip component (slate-700 bg, slate-600 border).
const [btnTip, setBtnTip] = useState<{ text: string; left: number; top: number } | null>(null);
const showBtnTip = (e: React.MouseEvent<HTMLElement>, text: string) => {
const rect = e.currentTarget.getBoundingClientRect();
setBtnTip({ text, left: rect.left + rect.width / 2, top: rect.top - 8 });
};
const hideBtnTip = () => setBtnTip(null);
// Focus input when entering edit mode. Cursor lands at the end of the
// current draft — no selection highlight, so users can just type to append.
useEffect(() => {
if (editing && inputRef.current) {
const el = inputRef.current;
el.focus();
const len = el.value.length;
el.setSelectionRange(len, len);
}
}, [editing]);
// Keep draft in sync if alias changes externally while not editing
useEffect(() => {
if (!editing) setDraft(projectDisplay);
}, [projectDisplay, editing]);
const startEdit = () => {
if (!canEdit) return;
// Short names (≤25 chars) → prefill so user can tweak (e.g. add a suffix).
// Long names (cryptic IDs from Coolify/Dokploy/etc) → start blank for a fresh alias.
setDraft(projectDisplay.length <= 25 ? projectDisplay : "");
setEditing(true);
};
const commit = () => {
if (!canEdit || !d.project) return;
// Reset if the user types the original project (case-insensitive) —
// no point storing an alias that's identical to the source key.
const finalAlias = draft.trim().toLowerCase() === projectPart.trim().toLowerCase() ? "" : draft;
d.onAliasChange?.(d.project, finalAlias);
setEditing(false);
};
const cancel = () => {
setEditing(false);
setDraft(projectDisplay);
};
const reset = () => {
if (!canEdit || !d.project) return;
d.onAliasChange?.(d.project, "");
};
return ( return (
<div className="absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5"> <>
<Icon size={16} style={{ color: config.color }} /> <div className="group absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
<span <Icon size={16} style={{ color: config.color }} className="shrink-0" />
className="text-sm font-semibold tracking-wider uppercase" {editing ? (
style={{ color: config.color }} <>
> <input
{d.label} ref={inputRef}
</span> 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 && ( {d.subtitle && (
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]"> <div className="absolute bottom-2 left-0 right-0 flex justify-center px-4">
{d.subtitle} <span
</span> ref={footerRef}
)} onMouseEnter={onFooterEnter}
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} /> onMouseLeave={onFooterLeave}
{d.count != null && ( className="text-[10px] text-slate-500 hover:text-slate-300 font-mono truncate max-w-[80%] tracking-wide transition-colors cursor-default"
<div className="flex items-center gap-1.5"> >
<Box size={12} style={{ color: config.borderColor }} /> {d.subtitle}
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
{d.count}
</span> </span>
</div> </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 { ThresholdBar } from "../components/ThresholdBar";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { guessIcon } from "../nodes/ServiceNode"; import { guessIcon } from "../nodes/ServiceNode";
import { getComposeKey } from "../engine/layout";
function timeAgo(ts: number): string { function timeAgo(ts: number): string {
const diff = Math.floor((Date.now() / 1000) - ts); const diff = Math.floor((Date.now() / 1000) - ts);
@@ -85,9 +86,10 @@ interface MonitoringPageProps {
eventLogStream: EventLogEntry[]; eventLogStream: EventLogEntry[];
notificationStream: NotificationLogEntry[]; notificationStream: NotificationLogEntry[];
onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void; onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void;
projectAliases?: Record<string, string>;
} }
export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail }: MonitoringPageProps) { export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail, projectAliases = {} }: MonitoringPageProps) {
const { t } = useT(); const { t } = useT();
const [statsRange, setStatsRange] = useState<StatsRange>("1h"); const [statsRange, setStatsRange] = useState<StatsRange>("1h");
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history"); const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
@@ -174,7 +176,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
const projects = new Set<string>(); const projects = new Set<string>();
for (const svc of allServiceNames) { for (const svc of allServiceNames) {
const slash = svc.indexOf("/"); const slash = svc.indexOf("/");
projects.add(slash >= 0 ? svc.slice(0, slash) : "standalone"); projects.add(slash >= 0 ? svc.slice(0, slash) : "docker");
} }
return [...projects].sort(); return [...projects].sort();
}, [allServiceNames]); }, [allServiceNames]);
@@ -184,7 +186,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
if (selectedProjects.size === 0) return allServiceNames; if (selectedProjects.size === 0) return allServiceNames;
return allServiceNames.filter((svc) => { return allServiceNames.filter((svc) => {
const slash = svc.indexOf("/"); const slash = svc.indexOf("/");
const project = slash >= 0 ? svc.slice(0, slash) : "standalone"; const project = slash >= 0 ? svc.slice(0, slash) : "docker";
return selectedProjects.has(project); return selectedProjects.has(project);
}); });
}, [allServiceNames, selectedProjects]); }, [allServiceNames, selectedProjects]);
@@ -235,12 +237,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
}; };
// Labels // Labels
const aliasOrName = (p: string) => projectAliases[p] || p;
const projectLabel = selectedProjects.size === 0 const projectLabel = selectedProjects.size === 0
? t("monitoring.filterProject") ? t("monitoring.filterProject")
: selectedProjects.size === allProjects.length : selectedProjects.size === allProjects.length
? t("monitoring.allProjects") ? t("monitoring.allProjects")
: selectedProjects.size === 1 : selectedProjects.size === 1
? [...selectedProjects][0] ? aliasOrName([...selectedProjects][0])
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`; : `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`;
const serviceLabel = selectedServices.size === 0 const serviceLabel = selectedServices.size === 0
@@ -308,18 +311,24 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
<div className="border-t border-slate-700/50 my-1" /> <div className="border-t border-slate-700/50 my-1" />
{allProjects.map((project) => { {allProjects.map((project) => {
const isSelected = selectedProjects.has(project); const isSelected = selectedProjects.has(project);
const composeKeys = [...new Set(services.filter((s) => s.project === project).map((s) => getComposeKey(s.compose_file)))];
const composeSuffix = composeKeys.join(" - ");
return ( return (
<button <button
key={project} key={project}
onClick={() => toggleProject(project)} onClick={() => toggleProject(project)}
title={composeSuffix ? `${aliasOrName(project)} / ${composeSuffix}` : aliasOrName(project)}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors" className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
> >
<div className={`w-4 h-4 rounded border flex items-center justify-center ${ <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-cyan-500 border-cyan-500" : "border-slate-600" isSelected ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
}`}> }`}>
{isSelected && <Check size={12} className="text-white" />} {isSelected && <Check size={12} className="text-white" />}
</div> </div>
<span className={isSelected ? "text-slate-200" : "text-slate-400"}>{project}</span> <span className={`min-w-0 truncate uppercase ${isSelected ? "text-slate-200" : "text-slate-400"}`}>
{aliasOrName(project)}
{composeSuffix && <span className="ml-1 text-xs text-slate-500">/ {composeSuffix}</span>}
</span>
</button> </button>
); );
})} })}
@@ -453,7 +462,13 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0]) ? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
: `${selectedServices.size} ${t("footer.containers")}`) : `${selectedServices.size} ${t("footer.containers")}`)
: selectedProjects.size === 1 : selectedProjects.size === 1
? [...selectedProjects][0] ? (() => {
const proj = [...selectedProjects][0];
const display = aliasOrName(proj);
const composeKeys = [...new Set(services.filter((s) => s.project === proj).map((s) => getComposeKey(s.compose_file)))];
const suffix = composeKeys.join(" - ");
return suffix ? `${display} / ${suffix.toUpperCase()}` : display;
})()
: selectedProjects.size === allProjects.length : selectedProjects.size === allProjects.length
? t("monitoring.allProjects") ? t("monitoring.allProjects")
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}` : `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`
@@ -478,6 +493,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi
globalRange={statsRange} globalRange={statsRange}
fallbackData={filteredHistory[svc] || []} fallbackData={filteredHistory[svc] || []}
token={token} token={token}
projectAliases={projectAliases}
/> />
))} ))}
</div> </div>
@@ -581,7 +597,7 @@ function MonitoringTotalsCard({
return ( return (
<div className="px-5 py-3 border-b border-slate-700/40 bg-slate-900/40"> <div className="px-5 py-3 border-b border-slate-700/40 bg-slate-900/40">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-slate-300 font-medium truncate">{title}</span> <span className="text-xs text-slate-300 font-medium truncate uppercase">{title}</span>
<span className="text-[10px] text-slate-500">· {containerCount} {t("footer.containers")}</span> <span className="text-[10px] text-slate-500">· {containerCount} {t("footer.containers")}</span>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -637,6 +653,7 @@ interface MonitoringServiceCardProps {
globalRange: StatsRange; globalRange: StatsRange;
fallbackData: StatsHistoryPoint[]; fallbackData: StatsHistoryPoint[];
token: string; token: string;
projectAliases: Record<string, string>;
} }
function MonitoringServiceCard({ function MonitoringServiceCard({
@@ -655,6 +672,7 @@ function MonitoringServiceCard({
globalRange, globalRange,
fallbackData, fallbackData,
token, token,
projectAliases,
}: MonitoringServiceCardProps) { }: MonitoringServiceCardProps) {
const { t } = useT(); const { t } = useT();
const [localRange, setLocalRange] = useState<StatsRange | null>(null); const [localRange, setLocalRange] = useState<StatsRange | null>(null);
@@ -692,9 +710,17 @@ function MonitoringServiceCard({
<ServiceIcon uid={svc} services={services} /> <ServiceIcon uid={svc} services={services} />
<div className="min-w-0"> <div className="min-w-0">
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span> <span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
{svc.includes("/") && ( {svc.includes("/") && (() => {
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span> const proj = svc.split("/")[0];
)} const display = projectAliases[proj] || proj;
// Only show THIS service's compose (not the whole project's list).
const thisCompose = svcData ? getComposeKey(svcData.compose_file) : "";
return (
<span className="text-[10px] text-slate-500 truncate block leading-tight uppercase">
{display}{thisCompose && <span> / {thisCompose}</span>}
</span>
);
})()}
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{/* Per-card range buttons */} {/* Per-card range buttons */}
+120
View File
@@ -0,0 +1,120 @@
import { describe, test, expect } from "vitest";
import { computeGraphDiff, connectionKey } from "./diff";
import type { Service, Connection } from "../shared/types";
function svc(uid: string, overrides: Partial<Service> = {}): Service {
return {
id: uid,
uid,
name: uid.split("/")[1] ?? uid,
image: "nginx:latest",
state: "running",
status: "Up 2 hours",
ports: [],
networks: ["bridge"],
network_ips: {},
project: uid.split("/")[0] ?? "proj",
compose_file: "/app/docker-compose.yml",
env: [],
restart_policy: "unless-stopped",
memory_limit: 0,
cpu_quota: 0,
health_status: "",
health_log: [],
exit_code: 0,
restart_count: 0,
oom_killed: false,
mounts: [],
...overrides,
};
}
function conn(from: string, to: string, network = "bridge"): Connection {
return { from, to, network };
}
describe("computeGraphDiff", () => {
test("no-op returns null", () => {
const services = [svc("p/a"), svc("p/b")];
const connections = [conn("p/a", "p/b")];
expect(computeGraphDiff(services, services, connections, connections)).toBeNull();
});
test("add only", () => {
const prev = [svc("p/a")];
const next = [svc("p/a"), svc("p/b")];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff).not.toBeNull();
expect(diff!.servicesAdded).toHaveLength(1);
expect(diff!.servicesAdded![0].uid).toBe("p/b");
expect(diff!.servicesRemoved).toBeUndefined();
expect(diff!.servicesUpdated).toBeUndefined();
});
test("remove only", () => {
const prev = [svc("p/a"), svc("p/b")];
const next = [svc("p/a")];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff!.servicesRemoved).toEqual(["p/b"]);
expect(diff!.servicesAdded).toBeUndefined();
});
test("update only — state change", () => {
const prev = [svc("p/a", { state: "running" })];
const next = [svc("p/a", { state: "exited", exit_code: 1 })];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff!.servicesUpdated).toHaveLength(1);
expect(diff!.servicesUpdated![0].state).toBe("exited");
expect(diff!.servicesAdded).toBeUndefined();
expect(diff!.servicesRemoved).toBeUndefined();
});
test("update only — restart_count increment", () => {
const prev = [svc("p/a", { restart_count: 0 })];
const next = [svc("p/a", { restart_count: 1 })];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff!.servicesUpdated).toHaveLength(1);
});
test("status string change does NOT trigger update", () => {
const prev = [svc("p/a", { status: "Up 1 minute" })];
const next = [svc("p/a", { status: "Up 2 hours" })];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff).toBeNull();
});
test("health_log change does NOT trigger update", () => {
const prev = [svc("p/a", { health_log: ["healthy at 10:00"] })];
const next = [svc("p/a", { health_log: ["healthy at 11:00"] })];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff).toBeNull();
});
test("mixed: add + remove + update", () => {
const prev = [svc("p/a"), svc("p/b"), svc("p/c")];
const next = [svc("p/a", { state: "exited" }), svc("p/d")];
const diff = computeGraphDiff(prev, next, [], []);
expect(diff!.servicesAdded?.map((s) => s.uid)).toEqual(["p/d"]);
expect(diff!.servicesRemoved?.sort()).toEqual(["p/b", "p/c"]);
expect(diff!.servicesUpdated?.map((s) => s.uid)).toEqual(["p/a"]);
});
test("connection add only", () => {
const svcs = [svc("p/a"), svc("p/b")];
const diff = computeGraphDiff(svcs, svcs, [], [conn("p/a", "p/b")]);
expect(diff!.connectionsAdded).toHaveLength(1);
expect(diff!.connectionsRemoved).toBeUndefined();
});
test("connection remove only", () => {
const svcs = [svc("p/a"), svc("p/b")];
const diff = computeGraphDiff(svcs, svcs, [conn("p/a", "p/b")], []);
expect(diff!.connectionsRemoved).toHaveLength(1);
expect(diff!.connectionsRemoved![0]).toBe("p/a|p/b|bridge");
expect(diff!.connectionsAdded).toBeUndefined();
});
test("connectionKey format", () => {
expect(connectionKey(conn("p/a", "p/b", "mynet"))).toBe("p/a|p/b|mynet");
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { Service, Connection, GraphDiff } from "../shared/types";
/** Stable fingerprint of a Service for change detection.
* Excludes `status` (verbose uptime string that changes every minute) and
* `health_log` (changes on every health-check interval). */
function serviceSignature(s: Service): string {
return JSON.stringify([
s.state, s.image, s.restart_count, s.health_status,
s.exit_code, s.oom_killed, s.restart_policy, s.memory_limit, s.cpu_quota,
s.ports, s.networks, s.network_ips, s.env, s.mounts, s.compose_file,
s.name, s.project,
]);
}
export function connectionKey(c: Connection): string {
return `${c.from}|${c.to}|${c.network}`;
}
/** Returns a GraphDiff between two graph states, or null when nothing changed. */
export function computeGraphDiff(
prevServices: Service[],
nextServices: Service[],
prevConnections: Connection[],
nextConnections: Connection[],
): GraphDiff | null {
const prevSvcMap = new Map(prevServices.map((s) => [s.uid, s]));
const nextSvcMap = new Map(nextServices.map((s) => [s.uid, s]));
const servicesAdded = nextServices.filter((s) => !prevSvcMap.has(s.uid));
const servicesRemoved = prevServices.filter((s) => !nextSvcMap.has(s.uid)).map((s) => s.uid);
const servicesUpdated = nextServices.filter((s) => {
const prev = prevSvcMap.get(s.uid);
return prev !== undefined && serviceSignature(prev) !== serviceSignature(s);
});
const prevConnKeys = new Set(prevConnections.map(connectionKey));
const nextConnKeys = new Set(nextConnections.map(connectionKey));
const connectionsAdded = nextConnections.filter((c) => !prevConnKeys.has(connectionKey(c)));
const connectionsRemoved = prevConnections.filter((c) => !nextConnKeys.has(connectionKey(c))).map(connectionKey);
const hasChanges =
servicesAdded.length > 0 || servicesRemoved.length > 0 || servicesUpdated.length > 0 ||
connectionsAdded.length > 0 || connectionsRemoved.length > 0;
if (!hasChanges) return null;
const diff: GraphDiff = {};
if (servicesAdded.length > 0) diff.servicesAdded = servicesAdded;
if (servicesRemoved.length > 0) diff.servicesRemoved = servicesRemoved;
if (servicesUpdated.length > 0) diff.servicesUpdated = servicesUpdated;
if (connectionsAdded.length > 0) diff.connectionsAdded = connectionsAdded;
if (connectionsRemoved.length > 0) diff.connectionsRemoved = connectionsRemoved;
return diff;
}
+8 -4
View File
@@ -27,8 +27,12 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
); );
let services: Service[] = containers.map((c, i) => { let services: Service[] = containers.map((c, i) => {
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown"; // Docker Desktop on Windows may return null for Labels/Names/Ports
const project = c.Labels["com.docker.compose.project"] || "standalone"; // 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; const info = inspections[i] as any;
// Extract network IPs // Extract network IPs
@@ -64,7 +68,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
state, state,
status: c.Status, status: c.Status,
ports: [...new Map( ports: [...new Map(
c.Ports.filter((p) => p.PublicPort).map((p) => [ (c.Ports ?? []).filter((p) => p.PublicPort).map((p) => [
`${p.PublicPort}:${p.PrivatePort}`, `${p.PublicPort}:${p.PrivatePort}`,
{ host: p.PublicPort!, container: 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 || {}), networks: Object.keys(c.NetworkSettings?.Networks || {}),
network_ips: networkIps, network_ips: networkIps,
project, 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[], env: (info?.Config?.Env || []) as string[],
restart_policy: info?.HostConfig?.RestartPolicy?.Name || "", restart_policy: info?.HostConfig?.RestartPolicy?.Name || "",
memory_limit: info?.HostConfig?.Memory || 0, memory_limit: info?.HostConfig?.Memory || 0,
+149 -42
View File
@@ -6,11 +6,16 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker"; import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
import { pollStats, watchDockerEvents } from "./watcher"; import { pollStats, watchDockerEvents } from "./watcher";
import { computeGraphDiff } from "./diff";
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord"; import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord";
import { loadContainerSettings, saveContainerSettings } from "./container-settings"; import { loadContainerSettings, saveContainerSettings } from "./container-settings";
import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
import { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors";
import { getUpdateInfo } from "./update-check";
import pkg from "../../package.json";
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db"; import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db"; import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db";
import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; import type { Service, Connection, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
/** Directory for persistent data files (SQLite, JSON configs, positions). /** Directory for persistent data files (SQLite, JSON configs, positions).
* Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */ * Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
@@ -202,7 +207,15 @@ app.get("/api/init", async (c) => {
// We deliberately do NOT trigger a fresh pollStats here — on cold start // We deliberately do NOT trigger a fresh pollStats here — on cold start
// with many containers it can exceed Bun's 10s request timeout and hang // with many containers it can exceed Bun's 10s request timeout and hang
// the dashboard. The first regular poll (within ~3s) populates via WS. // the dashboard. The first regular poll (within ~3s) populates via WS.
return c.json({ services, connections, positions, stats: lastStats }); const projectAliases = loadProjectAliases();
const projectColors = loadProjectColors();
// 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) ── // ── Server config (read by frontend to disable buttons for non-allowed paths) ──
@@ -216,7 +229,7 @@ app.get("/api/config", (c) => {
// ── Helper: get service uid from container inspect info ── // ── Helper: get service uid from container inspect info ──
function getContainerUid(info: any): string { function getContainerUid(info: any): string {
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker";
const service = info.Config?.Labels?.["com.docker.compose.service"] || info.Name?.replace(/^\//, "") || "unknown"; const service = info.Config?.Labels?.["com.docker.compose.service"] || info.Name?.replace(/^\//, "") || "unknown";
return `${project}/${service}`; return `${project}/${service}`;
} }
@@ -288,7 +301,7 @@ app.post("/api/containers/:id/rebuild", async (c) => {
if (denied) return c.json({ error: denied }, 403); if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker";
if (!composeFile || !serviceName) { if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400); return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
} }
@@ -344,7 +357,7 @@ app.post("/api/containers/:id/recreate", async (c) => {
if (denied) return c.json({ error: denied }, 403); if (denied) return c.json({ error: denied }, 403);
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker";
if (!composeFile || !serviceName) { if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400); return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400);
} }
@@ -615,6 +628,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 ── // ── Stats history ──
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]); const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
@@ -695,6 +803,28 @@ function cleanupLogStream(ws: WebSocket) {
} }
} }
/** Send full graph + stats snapshot to a newly connected client.
* Uses cached state if available; falls back to a fresh discover on cold start. */
function sendSnapshot(ws: WebSocket): void {
if (lastBroadcastedServices.length > 0) {
try {
ws.send(JSON.stringify({ type: "snapshot", data: { services: lastBroadcastedServices, connections: lastBroadcastedConnections } }));
if (lastStats.length > 0) ws.send(JSON.stringify({ type: "stats", data: lastStats }));
} catch {}
return;
}
// Cold start: no data yet — do a fresh discover
discoverServices(ALL, PROJECTS).then(async (services) => {
const connections = await discoverConnections(services);
lastBroadcastedServices = services;
lastBroadcastedConnections = connections;
try {
ws.send(JSON.stringify({ type: "snapshot", data: { services, connections } }));
if (lastStats.length > 0) ws.send(JSON.stringify({ type: "stats", data: lastStats }));
} catch {}
}).catch(() => {});
}
// ── Docker events ── // ── Docker events ──
let servicesLock = false; let servicesLock = false;
let statsLock = false; let statsLock = false;
@@ -704,18 +834,16 @@ async function refreshServices() {
servicesLock = true; servicesLock = true;
try { try {
const services = await discoverServices(ALL, PROJECTS); const services = await discoverServices(ALL, PROJECTS);
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
if (svcHash !== lastServicesHash) {
lastServicesHash = svcHash;
broadcast({ type: "services", data: services });
}
const connections = await discoverConnections(services); const connections = await discoverConnections(services);
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
if (connHash !== lastConnectionsHash) { const diff = computeGraphDiff(
lastConnectionsHash = connHash; lastBroadcastedServices, services,
broadcast({ type: "connections", data: connections }); lastBroadcastedConnections, connections,
);
if (diff) {
lastBroadcastedServices = services;
lastBroadcastedConnections = connections;
broadcast({ type: "diff", data: diff });
} }
// Stats polling is separate — don't block services refresh // Stats polling is separate — don't block services refresh
@@ -773,8 +901,6 @@ async function refreshStats(services: Service[]) {
let refreshTimer: ReturnType<typeof setTimeout> | undefined; let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined; let retryTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRefresh() { function scheduleRefresh() {
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
lastServicesHash = "";
clearTimeout(refreshTimer); clearTimeout(refreshTimer);
clearTimeout(retryTimer); clearTimeout(retryTimer);
refreshTimer = setTimeout(() => { refreshTimer = setTimeout(() => {
@@ -785,7 +911,6 @@ function scheduleRefresh() {
// Immediate refresh after action endpoints (container already changed state) // Immediate refresh after action endpoints (container already changed state)
function immediateRefresh() { function immediateRefresh() {
lastServicesHash = "";
clearTimeout(refreshTimer); clearTimeout(refreshTimer);
clearTimeout(retryTimer); clearTimeout(retryTimer);
refreshServices(); refreshServices();
@@ -813,9 +938,9 @@ watchDockerEvents((event) => {
} catch {} } catch {}
}); });
// ── Stats polling ── // ── Graph state (used for diff computation and new-client snapshots) ──
let lastServicesHash = ""; let lastBroadcastedServices: Service[] = [];
let lastConnectionsHash = ""; let lastBroadcastedConnections: Connection[] = [];
/** Last stats snapshot — sent in /api/init so frontend has data immediately /** Last stats snapshot — sent in /api/init so frontend has data immediately
* instead of waiting for the next polling cycle (~3s wait). */ * instead of waiting for the next polling cycle (~3s wait). */
let lastStats: Stats[] = []; let lastStats: Stats[] = [];
@@ -852,16 +977,7 @@ const server = Bun.serve({
clients.add(native); clients.add(native);
if (!AUTH_TOKEN) { if (!AUTH_TOKEN) {
// No auth required — send data immediately sendSnapshot(native);
discoverServices(ALL, PROJECTS).then(async (services) => {
const connections = await discoverConnections(services);
const stats = await pollStats(services);
try {
native.send(JSON.stringify({ type: "services", data: services }));
native.send(JSON.stringify({ type: "connections", data: connections }));
native.send(JSON.stringify({ type: "stats", data: stats }));
} catch {}
}).catch(() => {});
} }
}, },
close(ws) { close(ws) {
@@ -886,16 +1002,7 @@ const server = Bun.serve({
if (msg.token === AUTH_TOKEN) { if (msg.token === AUTH_TOKEN) {
authenticatedClients.add(native); authenticatedClients.add(native);
native.send(JSON.stringify({ type: "auth_ok" })); native.send(JSON.stringify({ type: "auth_ok" }));
// Send current services/connections/stats immediately sendSnapshot(native);
discoverServices(ALL, PROJECTS).then(async (services) => {
const connections = await discoverConnections(services);
const stats = await pollStats(services);
try {
native.send(JSON.stringify({ type: "services", data: services }));
native.send(JSON.stringify({ type: "connections", data: connections }));
native.send(JSON.stringify({ type: "stats", data: stats }));
} catch {}
}).catch(() => {});
} else { } else {
if (AUTH_TOKEN) recordFailedAttempt(wsIp); if (AUTH_TOKEN) recordFailedAttempt(wsIp);
native.send(JSON.stringify({ type: "auth_error" })); native.send(JSON.stringify({ type: "auth_error" }));
+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"; "unknown";
const svcProject = const svcProject =
event.Actor?.Attributes?.["com.docker.compose.project"] || event.Actor?.Attributes?.["com.docker.compose.project"] ||
"standalone"; "docker";
onEvent({ onEvent({
type: "docker", type: "docker",
action, action,
+25 -2
View File
@@ -131,6 +131,21 @@ export interface ServerConfig {
restrictedMode: boolean; 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 { export interface EventLogEntry {
id: number; id: number;
timestamp: number; timestamp: number;
@@ -150,9 +165,17 @@ export interface NotificationLogEntry {
message: string; message: string;
} }
export interface GraphDiff {
servicesAdded?: Service[];
servicesRemoved?: string[]; // uids
servicesUpdated?: Service[]; // full object (includes uid)
connectionsAdded?: Connection[];
connectionsRemoved?: string[]; // "from|to|network" keys
}
export type WSMessage = export type WSMessage =
| { type: "services"; data: Service[] } | { type: "snapshot"; data: { services: Service[]; connections: Connection[] } }
| { type: "connections"; data: Connection[] } | { type: "diff"; data: GraphDiff }
| { type: "stats"; data: Stats[] } | { type: "stats"; data: Stats[] }
| { type: "docker_event"; data: DockerEvent } | { type: "docker_event"; data: DockerEvent }
| { type: "subscribe_logs"; container: string } | { type: "subscribe_logs"; container: string }