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