From 61472e4e04c68f46052ee6d2c3d410c17cbd1d42 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Sat, 16 May 2026 04:43:17 +0000 Subject: [PATCH] =?UTF-8?q?v0.1.7=20=E2=80=94=20group=20color=20picker=20+?= =?UTF-8?q?=20centered=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package.json | 2 +- src/client/App.tsx | 211 ++++++++++++++---- src/client/engine/layout.ts | 32 ++- src/client/i18n.tsx | 4 + src/client/nodes/GroupNode.tsx | 328 +++++++++++++++++++++------- src/client/pages/MonitoringPage.tsx | 4 +- src/server/docker.ts | 2 +- src/server/index.ts | 46 +++- src/server/project-colors.ts | 27 +++ src/server/watcher.ts | 2 +- 10 files changed, 514 insertions(+), 144 deletions(-) create mode 100644 src/server/project-colors.ts diff --git a/package.json b/package.json index 1b1775f..beded86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "containerflow", - "version": "0.1.6", + "version": "0.1.7", "license": "AGPL-3.0-or-later", "author": "Jorge Gonzalez D. (RGJorge)", "type": "module", diff --git a/src/client/App.tsx b/src/client/App.tsx index 522690c..473b3a9 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -175,6 +175,33 @@ function Dashboard({ token }: { token: string }) { }, [token]); const handleAliasChangeRef = useRef(handleAliasChange); handleAliasChangeRef.current = handleAliasChange; + + // Project colors — per-project hex color overrides for the group background. + const [projectColors, setProjectColors] = useState>({}); + 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 = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + try { + await fetch("/api/project-colors", { + method: "PUT", + headers, + body: JSON.stringify({ project, color }), + }); + } catch { + fetch("/api/project-colors", { headers }) + .then((r) => r.ok ? r.json() : {}) + .then(setProjectColors) + .catch(() => {}); + } + }, [token]); + const handleColorChangeRef = useRef(handleColorChange); + handleColorChangeRef.current = handleColorChange; useEffect(() => { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -186,6 +213,10 @@ function Dashboard({ token }: { token: string }) { .then((r) => r.ok ? r.json() : {}) .then(setProjectAliases) .catch(() => {}); + fetch("/api/project-colors", { headers }) + .then((r) => r.ok ? r.json() : {}) + .then(setProjectColors) + .catch(() => {}); fetch("/api/discord-config", { headers }) .then((r) => r.ok ? r.json() : null) .then((c: any) => { @@ -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(); for (let i = 0; i < nodes.length; i++) { const pid = nodes[i].parentId; @@ -317,45 +348,61 @@ function Dashboard({ token }: { token: string }) { arr.push(i); } - for (const [gid, kidIdxs] of childrenByParent) { - let minChildX = Infinity; - for (const ki of kidIdxs) minChildX = Math.min(minChildX, nodes[ki].position.x); - if (minChildX !== MIN_X) { - const shift = minChildX - MIN_X; - changed = true; - for (let i = 0; i < nodes.length; i++) { - const n = nodes[i]; - if (n.id === gid) nodes[i] = { ...n, position: { x: n.position.x + shift, y: n.position.y } }; - else if (n.parentId === gid) nodes[i] = { ...n, position: { x: n.position.x - shift, y: n.position.y } }; - } - } + // After every drag-end: re-center kids horizontally + vertically within + // their group, resize the group to fit, and shift the group on the + // canvas by the opposite of the kid shift so visible positions don't jump. + const FOOTER_RESERVE = 22; + const minW = NODE_W + G_PAD * 3; + const groupIdxById = new Map(); + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].id.startsWith("group-")) groupIdxById.set(nodes[i].id, i); } - // Resize groups to fit children - for (let i = 0; i < nodes.length; i++) { - const n = nodes[i]; - if (!n.id.startsWith("group-")) continue; - const kidIdxs = childrenByParent.get(n.id); - if (!kidIdxs || kidIdxs.length === 0) continue; - - let maxRight = 0; - let maxBottom = 0; + for (const [gid, kidIdxs] of childrenByParent) { + if (kidIdxs.length === 0) continue; + let minLeft = Infinity, minTop = Infinity; + let maxRight = 0, maxBottom = 0; for (const ki of kidIdxs) { const k = nodes[ki]; - maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); - maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); + minLeft = Math.min(minLeft, k.position.x); + maxRight = Math.max(maxRight, k.position.x + NODE_W); + minTop = Math.min(minTop, k.position.y); + maxBottom = Math.max(maxBottom, k.position.y + NODE_H); + } + const contentW = maxRight - minLeft; + const contentH = maxBottom - minTop; + const newW = Math.max(contentW + G_PAD * 2, minW); + const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE; + const targetLeft = (newW - contentW) / 2; + const targetTop = GROUP_HEADER + G_PAD; + const shiftX = targetLeft - minLeft; + const shiftY = targetTop - minTop; + + if (shiftX !== 0 || shiftY !== 0) { + changed = true; + // Shift kids inside the group... + for (const ki of kidIdxs) { + const k = nodes[ki]; + nodes[ki] = { ...k, position: { x: k.position.x + shiftX, y: k.position.y + shiftY } }; + } + // ...and shift the group itself by the opposite so canvas-relative + // positions stay where the user just dropped them. + const gIdx = groupIdxById.get(gid); + if (gIdx !== undefined) { + const g = nodes[gIdx]; + nodes[gIdx] = { ...g, position: { x: g.position.x - shiftX, y: g.position.y - shiftY } }; + } } - const minW = NODE_W + G_PAD * 3; - const newW = Math.max(maxRight, minW); - const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); - - const curW = (n.style?.width as number) || 0; - const curH = (n.style?.height as number) || 0; - - if (newW !== curW || newH !== curH) { - changed = true; - nodes[i] = { ...n, style: { ...n.style, width: newW, height: newH } }; + const gIdx = groupIdxById.get(gid); + if (gIdx !== undefined) { + const g = nodes[gIdx]; + const curW = (g.style?.width as number) || 0; + const curH = (g.style?.height as number) || 0; + if (newW !== curW || newH !== curH) { + changed = true; + nodes[gIdx] = { ...g, style: { ...g.style, width: newW, height: newH } }; + } } } @@ -419,31 +466,90 @@ function Dashboard({ token }: { token: string }) { const project = (n.data as any).project as string | undefined; if (project) { (n.data as any).alias = projectAliases[project]; + (n.data as any).color = projectColors[project]; (n.data as any).onAliasChange = handleAliasChangeRef.current; + (n.data as any).onColorChange = handleColorChangeRef.current; + // Apply custom color to group background/border. Falls back to the + // auto-assigned palette in buildLayout when not set. + const hex = projectColors[project]; + if (hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + n.style = { + ...n.style, + background: `rgba(${r}, ${g}, ${b}, 0.08)`, + border: `1px dashed rgba(${r}, ${g}, ${b}, 0.3)`, + color: `rgba(${r}, ${g}, ${b}, 0.8)`, + }; + } } } } if (!initialLayoutDone.current) { + // Single-service groups can't be "arranged" — always honor the computed + // (centered) position from buildLayout, ignoring any stale saved value. + const servicesPerGroup = new Map(); + for (const n of newNodes) { + if (n.type === "service" && n.parentId) { + servicesPerGroup.set(n.parentId, (servicesPerGroup.get(n.parentId) || 0) + 1); + } + } let positioned = newNodes.map((n) => { + if (n.type === "service" && n.parentId && servicesPerGroup.get(n.parentId) === 1) { + return n; + } const saved = savedPositions.current[n.id]; if (saved) return { ...n, position: saved }; return n; }); - positioned = positioned.map((n) => { - if (n.type !== "group") return n; - const kids = positioned.filter((c) => c.parentId === n.id); - if (kids.length === 0) return n; - let maxRight = 0; - let maxBottom = 0; - for (const k of kids) { - maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); - maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); + // Resize each group to fit its kids AND recenter content horizontally + // + vertically. We measure the bounding box of children, then shift them + // as a block so margins are symmetric on all four sides. Preserves the + // relative spacing between kids (a vertical stack stays a vertical stack, + // just centered). FOOTER_RESERVE accounts for the compose subtitle at + // the bottom of every group. + const FOOTER_RESERVE = 22; + const groupKids = new Map(); + 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(); + for (const [groupId, kids] of groupKids) { + let minLeft = Infinity, minTop = Infinity; + let maxRight = 0, maxBottom = 0; + for (const k of kids) { + minLeft = Math.min(minLeft, k.position.x); + maxRight = Math.max(maxRight, k.position.x + NODE_W); + minTop = Math.min(minTop, k.position.y); + maxBottom = Math.max(maxBottom, k.position.y + NODE_H); + } + const contentW = maxRight - minLeft; + const contentH = maxBottom - minTop; const minW = NODE_W + G_PAD * 3; - const newW = Math.max(maxRight, minW); - const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD); - return { ...n, style: { ...n.style, width: newW, height: newH } }; + const newW = Math.max(contentW + G_PAD * 2, minW); + const newH = GROUP_HEADER + G_PAD + contentH + G_PAD + FOOTER_RESERVE; + const shiftX = (newW - contentW) / 2 - minLeft; + const shiftY = (GROUP_HEADER + G_PAD) - minTop; + groupDims.set(groupId, { width: newW, height: newH, shiftX, shiftY }); + } + positioned = positioned.map((n) => { + if (n.type === "service" && n.parentId) { + const dim = groupDims.get(n.parentId); + if (dim && (dim.shiftX !== 0 || dim.shiftY !== 0)) { + return { ...n, position: { x: n.position.x + dim.shiftX, y: n.position.y + dim.shiftY } }; + } + return n; + } + if (n.type === "group") { + const dim = groupDims.get(n.id); + if (dim) return { ...n, style: { ...n.style, width: dim.width, height: dim.height } }; + } + return n; }); const { edges, activeHandles } = computeEdges(positioned, filteredConnections); for (const n of positioned) { @@ -464,6 +570,17 @@ function Dashboard({ token }: { token: string }) { for (const nn of newNodes) { const existing = prevNodeMap.get(nn.id); if (existing) { + // For groups, accept the new style (color overrides live there) + // but preserve current width/height which may reflect a user drag. + if (nn.type === "group") { + const mergedStyle = { + ...nn.style, + width: (existing.style as any)?.width, + height: (existing.style as any)?.height, + }; + result.push({ ...existing, data: nn.data, style: mergedStyle }); + continue; + } // Keep position and style, update data result.push({ ...existing, data: nn.data }); } else { @@ -504,7 +621,7 @@ function Dashboard({ token }: { token: string }) { 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) const recomputeEdges = useCallback((currentNodes: Node[]) => { diff --git a/src/client/engine/layout.ts b/src/client/engine/layout.ts index 7a1225e..d901a5d 100644 --- a/src/client/engine/layout.ts +++ b/src/client/engine/layout.ts @@ -11,7 +11,13 @@ const GROUP_GAP = 50; export function getComposeKey(file: string): string { if (!file) return "default"; - const match = file.match(/docker-compose\.?(.*)\.yml/); + // When multiple compose files are merged (COMPOSE_FILE env var with + // multiple paths), the docker `config_files` label is a comma-joined list. + // Use the LAST path — in docker-compose, the override file wins, and its + // name (e.g. "local", "dev") is the meaningful environment key. + const files = file.split(","); + const primary = files[files.length - 1] || file; + const match = primary.match(/docker-compose\.?(.*)\.yml/); const key = match?.[1] || ""; if (key === "") return "prod"; return key.replace(/^\./, ""); @@ -107,7 +113,10 @@ export function buildLayout( const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X; const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y; const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3); - const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_HEADER + GROUP_PADDING; + // Vertical: header + top padding + content + bottom padding + footer reserve. + // Keeps top/bottom margins symmetric and leaves room for the subtitle footer. + const FOOTER_RESERVE = 22; + const groupHeight = GROUP_HEADER + GROUP_PADDING + contentHeight + GROUP_PADDING + FOOTER_RESERVE; groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight }); @@ -121,11 +130,16 @@ export function buildLayout( const bgColor = knownBg || dynamic!.bg; const borderColor = knownBorder || dynamic!.border; - // Compose file subtitle — show unique compose files in this group - const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))] + // Subtitle: the compose filename(s). For COMPOSE_FILE merges, show the + // override (last file) since that's what defines the runtime config. + // Containers without compose labels (plain `docker run`) fall back to "docker". + const composeFiles = [...new Set(svcs.map((s) => { + const parts = (s.compose_file || "").split(","); + return parts[parts.length - 1] || s.compose_file; + }).filter(Boolean))] .map((f) => f.split("/").pop() || "") .filter(Boolean); - const subtitle = composeFiles.join(", "); + const subtitle = composeFiles.length > 0 ? composeFiles.join(", ") : "docker"; // Group node. `project` is the raw project key (without the compose part); // 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) => { const col = i % cols; const row = Math.floor(i / cols); - const x = GROUP_PADDING + col * (NODE_WIDTH + NODE_GAP_X); + const x = horizontalCenter + col * (NODE_WIDTH + NODE_GAP_X); const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y); nodes.push({ diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index c489a3e..2048bdc 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -31,6 +31,8 @@ const en = { "group.resetAlias": "Reset to original name", "group.saveAlias": "Save", "group.cancelAlias": "Cancel", + "group.changeColor": "Change color", + "group.resetColor": "Reset color", // Login "login.connecting": "Connecting...", @@ -301,6 +303,8 @@ const es: Record = { "group.rename": "Renombrar proyecto", "group.resetAlias": "Restaurar nombre original", "group.saveAlias": "Guardar", + "group.changeColor": "Cambiar color", + "group.resetColor": "Restaurar color", "group.cancelAlias": "Cancelar", // Login diff --git a/src/client/nodes/GroupNode.tsx b/src/client/nodes/GroupNode.tsx index 3f87920..5a2e687 100644 --- a/src/client/nodes/GroupNode.tsx +++ b/src/client/nodes/GroupNode.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; 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"; interface GroupNodeData { @@ -11,11 +12,25 @@ interface GroupNodeData { project?: string; /** Current alias if set, else undefined / empty string. */ alias?: string; + /** Current custom hex color (e.g. "#3b82f6"), if any. */ + color?: string; /** Save handler — called with (project, newAlias). Empty newAlias = reset. */ onAliasChange?: (project: string, newAlias: string) => void; + /** Color change handler — empty color = reset to default palette. */ + onColorChange?: (project: string, color: string) => void; [key: string]: unknown; } +// Palette shown when user clicks the color dot. First entry resets to default. +const COLOR_PALETTE: { hex: string; name: string }[] = [ + { hex: "#3b82f6", name: "blue" }, + { hex: "#8b5cf6", name: "purple" }, + { hex: "#06b6d4", name: "cyan" }, + { hex: "#22c55e", name: "green" }, + { hex: "#f59e0b", name: "yellow" }, + { hex: "#ef4444", name: "red" }, +]; + const groupConfig: Record = { INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 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)!; } +function hexToRgba(hex: string, alpha: number) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + export const GroupNode = memo(function GroupNode({ data }: NodeProps) { const { t } = useT(); const d = data as unknown as GroupNodeData; @@ -51,21 +73,96 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) { const labelParts = d.label.split(" / "); const projectPart = labelParts[0] || d.label; const composePart = labelParts.length > 1 ? labelParts.slice(1).join(" / ") : ""; + // Standalone containers (no compose) are grouped under project="docker" with + // compose key "default". For those, drop the suffix from the title and use a + // distinct icon so the group reads as "containers running directly on docker". + const isStandalone = d.project === "docker"; const iconKey = composePart || d.label; const known = groupConfig[iconKey]; const proj = known ? null : getProjectColor(d.label); - const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor }; + const baseConfig = isStandalone + ? { icon: Container, color: "#94a3b8", borderColor: "rgba(148, 163, 184, 0.3)" } + : known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor }; + // Override the color when the user has picked a custom one for this project. + const config = d.color + ? { icon: baseConfig.icon, color: d.color, borderColor: hexToRgba(d.color, 0.3) } + : baseConfig; const Icon = config.icon; const hasAlias = Boolean(d.alias && d.alias.trim().length > 0); const projectDisplay = hasAlias ? d.alias! : projectPart; - const displayName = composePart ? `${projectDisplay} / ${composePart}` : projectDisplay; + 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(null); + // Color palette popover + const colorBtnRef = useRef(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(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, 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(() => { @@ -107,91 +204,160 @@ export const GroupNode = memo(function GroupNode({ data }: NodeProps) { }; return ( -
- - {editing ? ( - <> - 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 && ( + <> +
+ + {editing ? ( + <> + 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 && ( + + / {composePart} + + )} + + + + ) : ( + <> - / {composePart} + {displayName} - )} - - - - ) : ( - <> - - {displayName} - - {canEdit && ( - - )} - {hasAlias && canEdit && ( - - )} - - )} + {canEdit && ( + + )} + {hasAlias && canEdit && ( + + )} + {canColor && ( + +
, + document.body + )} + ); }); diff --git a/src/client/pages/MonitoringPage.tsx b/src/client/pages/MonitoringPage.tsx index c2cfb63..1ecf15b 100644 --- a/src/client/pages/MonitoringPage.tsx +++ b/src/client/pages/MonitoringPage.tsx @@ -176,7 +176,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi const projects = new Set(); for (const svc of allServiceNames) { const slash = svc.indexOf("/"); - projects.add(slash >= 0 ? svc.slice(0, slash) : "standalone"); + projects.add(slash >= 0 ? svc.slice(0, slash) : "docker"); } return [...projects].sort(); }, [allServiceNames]); @@ -186,7 +186,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi if (selectedProjects.size === 0) return allServiceNames; return allServiceNames.filter((svc) => { const slash = svc.indexOf("/"); - const project = slash >= 0 ? svc.slice(0, slash) : "standalone"; + const project = slash >= 0 ? svc.slice(0, slash) : "docker"; return selectedProjects.has(project); }); }, [allServiceNames, selectedProjects]); diff --git a/src/server/docker.ts b/src/server/docker.ts index bdd0769..3c5a96d 100644 --- a/src/server/docker.ts +++ b/src/server/docker.ts @@ -28,7 +28,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis let services: Service[] = containers.map((c, i) => { const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown"; - const project = c.Labels["com.docker.compose.project"] || "standalone"; + const project = c.Labels["com.docker.compose.project"] || "docker"; const info = inspections[i] as any; // Extract network IPs diff --git a/src/server/index.ts b/src/server/index.ts index a871380..2923db4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,6 +9,7 @@ import { pollStats, watchDockerEvents } from "./watcher"; import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord"; import { loadContainerSettings, saveContainerSettings } from "./container-settings"; import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases"; +import { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors"; import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db"; import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db"; import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; @@ -204,7 +205,8 @@ app.get("/api/init", async (c) => { // with many containers it can exceed Bun's 10s request timeout and hang // the dashboard. The first regular poll (within ~3s) populates via WS. 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) ── @@ -218,7 +220,7 @@ app.get("/api/config", (c) => { // ── Helper: get service uid from container inspect info ── function getContainerUid(info: any): string { - const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; + const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker"; const service = info.Config?.Labels?.["com.docker.compose.service"] || info.Name?.replace(/^\//, "") || "unknown"; return `${project}/${service}`; } @@ -290,7 +292,7 @@ app.post("/api/containers/:id/rebuild", async (c) => { if (denied) return c.json({ error: denied }, 403); const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; - const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; + const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker"; if (!composeFile || !serviceName) { return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400); } @@ -346,7 +348,7 @@ app.post("/api/containers/:id/recreate", async (c) => { if (denied) return c.json({ error: denied }, 403); const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; - const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; + const project = info.Config?.Labels?.["com.docker.compose.project"] || "docker"; if (!composeFile || !serviceName) { return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400); } @@ -654,6 +656,42 @@ app.delete("/api/project-aliases/:project", (c) => { 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 ── const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]); diff --git a/src/server/project-colors.ts b/src/server/project-colors.ts new file mode 100644 index 0000000..8c24fa6 --- /dev/null +++ b/src/server/project-colors.ts @@ -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; + +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(); +} diff --git a/src/server/watcher.ts b/src/server/watcher.ts index e0db45b..9cf6343 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -138,7 +138,7 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) { "unknown"; const svcProject = event.Actor?.Attributes?.["com.docker.compose.project"] || - "standalone"; + "docker"; onEvent({ type: "docker", action,