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)
This commit is contained in:
RGJorge
2026-05-16 04:43:17 +00:00
parent b079422de8
commit 61472e4e04
10 changed files with 514 additions and 144 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "containerflow", "name": "containerflow",
"version": "0.1.6", "version": "0.1.7",
"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",
+164 -47
View File
@@ -175,6 +175,33 @@ function Dashboard({ token }: { token: string }) {
}, [token]); }, [token]);
const handleAliasChangeRef = useRef(handleAliasChange); const handleAliasChangeRef = useRef(handleAliasChange);
handleAliasChangeRef.current = 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}`;
@@ -186,6 +213,10 @@ function Dashboard({ token }: { token: string }) {
.then((r) => r.ok ? r.json() : {}) .then((r) => r.ok ? r.json() : {})
.then(setProjectAliases) .then(setProjectAliases)
.catch(() => {}); .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) => {
@@ -307,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;
@@ -317,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 } };
} }
} }
@@ -419,31 +466,90 @@ function Dashboard({ token }: { token: string }) {
const project = (n.data as any).project as string | undefined; const project = (n.data as any).project as string | undefined;
if (project) { if (project) {
(n.data as any).alias = projectAliases[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).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) {
@@ -464,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 {
@@ -504,7 +621,7 @@ function Dashboard({ token }: { token: string }) {
return result; return result;
}); });
} }
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled, projectAliases]); }, [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[]) => {
+25 -7
View File
@@ -11,7 +11,13 @@ const GROUP_GAP = 50;
export 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,11 +130,16 @@ 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. `project` is the raw project key (without the compose part); // 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 // it's what the alias system uses so the same alias applies across all
@@ -148,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({
+4
View File
@@ -31,6 +31,8 @@ const en = {
"group.resetAlias": "Reset to original name", "group.resetAlias": "Reset to original name",
"group.saveAlias": "Save", "group.saveAlias": "Save",
"group.cancelAlias": "Cancel", "group.cancelAlias": "Cancel",
"group.changeColor": "Change color",
"group.resetColor": "Reset color",
// Login // Login
"login.connecting": "Connecting...", "login.connecting": "Connecting...",
@@ -301,6 +303,8 @@ const es: Record<TranslationKey, string> = {
"group.rename": "Renombrar proyecto", "group.rename": "Renombrar proyecto",
"group.resetAlias": "Restaurar nombre original", "group.resetAlias": "Restaurar nombre original",
"group.saveAlias": "Guardar", "group.saveAlias": "Guardar",
"group.changeColor": "Cambiar color",
"group.resetColor": "Restaurar color",
"group.cancelAlias": "Cancelar", "group.cancelAlias": "Cancelar",
// Login // Login
+247 -81
View File
@@ -1,6 +1,7 @@
import { memo, useEffect, useRef, useState } 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, Pencil, RotateCcw, Check, X } from "lucide-react"; import { Server, Wrench, Rocket, Box, Folder, Container, Pencil, RotateCcw, Check, X, Palette } from "lucide-react";
import { useT } from "../i18n"; import { useT } from "../i18n";
interface GroupNodeData { interface GroupNodeData {
@@ -11,11 +12,25 @@ interface GroupNodeData {
project?: string; project?: string;
/** Current alias if set, else undefined / empty string. */ /** Current alias if set, else undefined / empty string. */
alias?: string; alias?: string;
/** Current custom hex color (e.g. "#3b82f6"), if any. */
color?: string;
/** Save handler — called with (project, newAlias). Empty newAlias = reset. */ /** Save handler — called with (project, newAlias). Empty newAlias = reset. */
onAliasChange?: (project: string, newAlias: string) => void; 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)" },
@@ -42,6 +57,13 @@ 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 { t } = useT();
const d = data as unknown as GroupNodeData; const d = data as unknown as GroupNodeData;
@@ -51,21 +73,96 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
const labelParts = d.label.split(" / "); const labelParts = d.label.split(" / ");
const projectPart = labelParts[0] || d.label; const projectPart = labelParts[0] || d.label;
const composePart = labelParts.length > 1 ? labelParts.slice(1).join(" / ") : ""; 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 iconKey = composePart || d.label;
const known = groupConfig[iconKey]; 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 hasAlias = Boolean(d.alias && d.alias.trim().length > 0);
const projectDisplay = hasAlias ? d.alias! : projectPart; const projectDisplay = hasAlias ? d.alias! : projectPart;
const displayName = composePart ? `${projectDisplay} / ${composePart}` : projectDisplay; const displayName = composePart && !isStandalone ? `${projectDisplay} / ${composePart}` : projectDisplay;
const canEdit = Boolean(d.project && d.onAliasChange); const canEdit = Boolean(d.project && d.onAliasChange);
const canColor = Boolean(d.project && d.onColorChange);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(projectDisplay); const [draft, setDraft] = useState(projectDisplay);
const inputRef = useRef<HTMLInputElement | null>(null); 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 // 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. // current draft — no selection highlight, so users can just type to append.
useEffect(() => { useEffect(() => {
@@ -107,91 +204,160 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
}; };
return ( return (
<div className="group absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5"> <>
<Icon size={16} style={{ color: config.color }} className="shrink-0" /> <div className="group absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
{editing ? ( <Icon size={16} style={{ color: config.color }} className="shrink-0" />
<> {editing ? (
<input <>
ref={inputRef} <input
value={draft} ref={inputRef}
onChange={(e) => setDraft(e.target.value)} value={draft}
onKeyDown={(e) => { onChange={(e) => setDraft(e.target.value)}
if (e.key === "Enter") commit(); onKeyDown={(e) => {
if (e.key === "Escape") cancel(); if (e.key === "Enter") commit();
}} if (e.key === "Escape") cancel();
onBlur={commit} }}
maxLength={64} onBlur={commit}
className="text-sm font-semibold tracking-wider uppercase bg-transparent border-none outline-none p-0 min-w-0" maxLength={64}
style={{ color: config.color, width: `${Math.max(draft.length * 9 + 8, 100)}px` }} className="text-sm font-semibold tracking-wider uppercase bg-transparent border-none outline-none p-0 min-w-0"
onMouseDown={(e) => e.stopPropagation()} style={{ color: config.color, width: `${Math.max(draft.length * 9 + 8, 100)}px` }}
onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
/> onClick={(e) => e.stopPropagation()}
{composePart && ( />
{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 <span
className="text-sm font-semibold tracking-wider uppercase" className={`text-sm font-semibold tracking-wider uppercase whitespace-nowrap truncate min-w-0 ${canEdit ? "cursor-pointer hover:opacity-80" : ""}`}
style={{ color: config.color }} style={{ color: config.color }}
onClick={canEdit ? startEdit : undefined}
title={displayName}
> >
/ {composePart} {displayName}
</span> </span>
)} {canEdit && (
<button <button
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); commit(); }} onClick={(e) => { e.stopPropagation(); startEdit(); }}
className="text-emerald-400 hover:text-emerald-300 transition-colors" onMouseEnter={(e) => showBtnTip(e, t("group.rename"))}
title={t("group.saveAlias")} onMouseLeave={hideBtnTip}
> className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity shrink-0"
<Check size={14} /> >
</button> <Pencil size={11} />
<button </button>
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); cancel(); }} )}
className="text-slate-500 hover:text-slate-300 transition-colors" {hasAlias && canEdit && (
title={t("group.cancelAlias")} <button
> onClick={(e) => { e.stopPropagation(); reset(); }}
<X size={14} /> onMouseEnter={(e) => showBtnTip(e, t("group.resetAlias"))}
</button> onMouseLeave={hideBtnTip}
</> className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity shrink-0"
) : ( >
<> <RotateCcw size={11} />
<span </button>
className={`text-sm font-semibold tracking-wider uppercase ${canEdit ? "cursor-pointer hover:opacity-80" : ""}`} )}
style={{ color: config.color }} {canColor && (
onClick={canEdit ? startEdit : undefined} <button
> ref={colorBtnRef}
{displayName} data-color-btn
</span> onClick={(e) => { e.stopPropagation(); palettePos ? closePalette() : openPalette(); }}
{canEdit && ( onMouseEnter={(e) => showBtnTip(e, t("group.changeColor"))}
<button onMouseLeave={hideBtnTip}
onClick={(e) => { e.stopPropagation(); startEdit(); }} 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"
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity" style={{ backgroundColor: config.color }}
title={t("group.rename")} />
> )}
<Pencil size={11} /> </>
</button> )}
)} <div className="flex-1 h-px min-w-2" style={{ backgroundColor: config.borderColor }} />
{hasAlias && canEdit && ( {d.count != null && (
<button <div className="flex items-center gap-1.5 shrink-0">
onClick={(e) => { e.stopPropagation(); reset(); }} <Box size={12} style={{ color: config.borderColor }} />
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 transition-opacity" <span className="text-xs font-mono" style={{ color: config.borderColor }}>
title={t("group.resetAlias")} {d.count}
> </span>
<RotateCcw size={11} /> </div>
</button> )}
)} </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 shrink-0"> >
<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
)}
</>
); );
}); });
+2 -2
View File
@@ -176,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]);
@@ -186,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]);
+1 -1
View File
@@ -28,7 +28,7 @@ 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"; const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
const project = c.Labels["com.docker.compose.project"] || "standalone"; const project = c.Labels["com.docker.compose.project"] || "docker";
const info = inspections[i] as any; const info = inspections[i] as any;
// Extract network IPs // Extract network IPs
+42 -4
View File
@@ -9,6 +9,7 @@ import { pollStats, watchDockerEvents } from "./watcher";
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 { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
import { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors";
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, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
@@ -204,7 +205,8 @@ app.get("/api/init", async (c) => {
// 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.
const projectAliases = loadProjectAliases(); const projectAliases = loadProjectAliases();
return c.json({ services, connections, positions, stats: lastStats, projectAliases }); const projectColors = loadProjectColors();
return c.json({ services, connections, positions, stats: lastStats, projectAliases, projectColors });
}); });
// ── Server config (read by frontend to disable buttons for non-allowed paths) ── // ── Server config (read by frontend to disable buttons for non-allowed paths) ──
@@ -218,7 +220,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}`;
} }
@@ -290,7 +292,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);
} }
@@ -346,7 +348,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);
} }
@@ -654,6 +656,42 @@ app.delete("/api/project-aliases/:project", (c) => {
return c.json({ ok: true }); 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 });
});
// ── Stats history ── // ── Stats history ──
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]); const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
+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();
}
+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,