This commit is contained in:
RGJorge
2026-05-10 20:49:06 +00:00
parent 0ebe000a26
commit 713504dc65
34 changed files with 918 additions and 327 deletions
+34 -7
View File
@@ -15,6 +15,7 @@ import "@xyflow/react/dist/style.css";
import { ServiceNode } from "./nodes/ServiceNode";
import { GroupNode } from "./nodes/GroupNode";
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";
@@ -24,6 +25,7 @@ import { LoginScreen } from "./components/LoginScreen";
import { OffsetEdge } from "./components/OffsetEdge";
import { HeaderBar, type Page } from "./components/HeaderBar";
import { EdgeLegend } from "./components/EdgeLegend";
import { ActionErrorToast } from "./components/ActionErrorToast";
import { Wifi, WifiOff, ChevronDown, Check } from "lucide-react";
import { MonitoringPage } from "./pages/MonitoringPage";
import { SettingsPage } from "./pages/SettingsPage";
@@ -82,7 +84,8 @@ function Dashboard({ token }: { token: string }) {
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
savedPositions.current = pos;
}, []);
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince } = useDocker(token, statsStore, onPositions);
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError } = useDocker(token, statsStore, onPositions);
const { config: serverConfig, canInteract } = useServerConfig(token);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const initialLayoutDone = useRef(false);
@@ -325,6 +328,14 @@ function Dashboard({ token }: { token: string }) {
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
// Mark service nodes as locked when restricted mode is active
for (const n of newNodes) {
if (n.type === "service") {
const svc = filteredServices.find((s) => s.uid === n.id);
if (svc) (n.data as any).locked = !canInteract(svc);
}
}
if (!initialLayoutDone.current) {
let positioned = newNodes.map((n) => {
const saved = savedPositions.current[n.id];
@@ -405,7 +416,7 @@ function Dashboard({ token }: { token: string }) {
return result;
});
}
}, [filteredServices, filteredConnections]);
}, [filteredServices, filteredConnections, canInteract]);
// Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -509,6 +520,8 @@ function Dashboard({ token }: { token: string }) {
events={events}
/>
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
@@ -674,23 +687,35 @@ function Dashboard({ token }: { token: string }) {
<NodeContextMenu
position={{ x: contextMenu.x, y: contextMenu.y }}
service={contextMenu.service}
locked={!canInteract(contextMenu.service)}
onClose={() => setContextMenu(null)}
onAction={(action) => {
const svc = contextMenu.service;
// Optimistic processing — set BEFORE fetch
const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" :
action === "start" || action === "restart" || action === "rebuild" || action === "recreate" ? "running" :
svc.state;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0;
const minDuration = action === "restart" ? 2000 : (action === "rebuild" || action === "recreate") ? 3000 : 0;
setProcessing(svc.uid, expectedState, minDuration);
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
.then((r) => {
if (!r.ok) clearProcessing(svc.uid);
.then(async (r) => {
if (!r.ok) {
clearProcessing(svc.uid);
try {
const data = await r.json();
if (data?.error) pushActionError(svc.uid, action, data.error);
} catch {
pushActionError(svc.uid, action, `HTTP ${r.status}`);
}
}
})
.catch(() => clearProcessing(svc.uid));
.catch((err) => {
clearProcessing(svc.uid);
pushActionError(svc.uid, action, err?.message || "Network error");
});
}}
onOpenLogs={() => {
const svc = contextMenu.service;
@@ -731,9 +756,11 @@ function Dashboard({ token }: { token: string }) {
logLines={panelLogLines}
token={token}
closing={panelClosing}
locked={!canInteract(detailService)}
onClose={closeDetail}
onAction={setProcessing}
clearProcessing={clearProcessing}
pushActionError={pushActionError}
sendMessage={sendMessage}
clearLogLines={clearLogLines}
connections={filteredConnections}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from "react";
import { AlertCircle, X, Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ActionError } from "../../shared/types";
import { useT } from "../i18n";
const PREVIEW_CHAR_LIMIT = 180;
function ToastItem({ err, onDismiss }: { err: ActionError; onDismiss: (id: string) => void }) {
const { t } = useT();
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const shortName = err.uid.split("/").pop() || err.uid;
const project = err.uid.includes("/") ? err.uid.split("/")[0] : null;
const isLong = err.error.length > PREVIEW_CHAR_LIMIT;
const visibleError = expanded || !isLong ? err.error : err.error.slice(0, PREVIEW_CHAR_LIMIT) + "…";
const copy = async () => {
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(err.error);
ok = true;
} else {
const ta = document.createElement("textarea");
ta.value = err.error;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
ok = document.execCommand("copy");
document.body.removeChild(ta);
}
} catch {}
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<div className="bg-slate-800/95 backdrop-blur-sm border border-red-500/40 rounded-lg shadow-xl shadow-black/50 w-[380px] overflow-hidden">
<div className="flex items-start gap-2.5 px-3.5 py-2.5 border-b border-red-500/20 bg-red-500/10">
<AlertCircle size={16} className="text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-red-300 capitalize">
{t("toast.actionFailed").replace("{action}", err.action)}
</div>
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-xs text-slate-200 font-medium truncate">{shortName}</span>
{project && <span className="text-[10px] text-slate-500 truncate">{project}</span>}
</div>
</div>
<button
onClick={() => onDismiss(err.id)}
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
title={t("toast.dismiss")}
>
<X size={14} />
</button>
</div>
<div className="px-3.5 py-2.5">
<pre className="text-[11px] text-slate-300 font-mono whitespace-pre-wrap break-words leading-snug max-h-48 overflow-auto">
{visibleError}
</pre>
<div className="flex items-center justify-end gap-1 mt-2">
{isLong && (
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{expanded ? t("toast.collapse") : t("toast.expand")}
</button>
)}
<button
onClick={copy}
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
>
{copied ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
{copied ? t("toast.copied") : t("toast.copy")}
</button>
</div>
</div>
</div>
);
}
interface ActionErrorToastProps {
errors: ActionError[];
onDismiss: (id: string) => void;
onClearAll: () => void;
}
export function ActionErrorToast({ errors, onDismiss, onClearAll }: ActionErrorToastProps) {
const { t } = useT();
if (errors.length === 0) return null;
return (
<div className="fixed top-16 right-4 z-50 flex flex-col gap-2">
{errors.length > 1 && (
<button
onClick={onClearAll}
className="self-end text-[10px] text-slate-500 hover:text-slate-300 underline transition-colors"
>
{t("toast.dismissAll")} ({errors.length})
</button>
)}
{errors.map((err) => (
<ToastItem key={err.id} err={err} onDismiss={onDismiss} />
))}
</div>
);
}
+25 -12
View File
@@ -1,17 +1,18 @@
import { useEffect, useRef } from "react";
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer } from "lucide-react";
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer, Lock, RefreshCw } from "lucide-react";
import type { Service } from "../../shared/types";
import { useT } from "../i18n";
interface NodeContextMenuProps {
position: { x: number; y: number };
service: Service;
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild") => void;
locked?: boolean;
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild" | "recreate") => void;
onOpenLogs: () => void;
onClose: () => void;
}
export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
export function NodeContextMenu({ position, service, locked, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
const { t } = useT();
const ref = useRef<HTMLDivElement>(null);
@@ -48,21 +49,31 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
className="fixed z-[10000] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/50 py-1.5 min-w-[180px]"
style={{ left: x, top: y }}
>
{locked && (
<>
<div className="flex items-center gap-2 px-3.5 py-1.5 text-[11px] text-slate-500 bg-slate-900/40">
<Lock size={11} className="text-slate-500" />
<span>{t("access.viewOnly")}</span>
</div>
<div className="border-t border-slate-700/50" />
</>
)}
{isRunning ? (
<>
<MenuItem icon={RotateCw} label={t("actions.restart")} color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label={t("actions.stop")} color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} />
<MenuItem icon={RotateCw} label={t("actions.restart")} tooltip={t("actions.restart.tooltip")} color="text-yellow-400" disabled={locked} onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label={t("actions.stop")} tooltip={t("actions.stop.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("stop"); onClose(); }} />
</>
) : (
<>
<MenuItem icon={Play} label={t("actions.start")} color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label={t("actions.remove")} color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} />
<MenuItem icon={Play} label={t("actions.start")} tooltip={t("actions.start.tooltip")} color="text-emerald-400" disabled={locked} onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label={t("actions.remove")} tooltip={t("actions.remove.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("remove"); onClose(); }} />
</>
)}
{service.compose_file && (
<>
<div className="border-t border-slate-700/50 my-1" />
<MenuItem icon={Hammer} label={t("actions.rebuild")} color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} />
<MenuItem icon={RefreshCw} label={t("actions.recreate")} tooltip={t("actions.recreate.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("recreate"); onClose(); }} />
<MenuItem icon={Hammer} label={t("actions.rebuild")} tooltip={t("actions.rebuild.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("rebuild"); onClose(); }} />
</>
)}
<div className="border-t border-slate-700/50 my-1" />
@@ -83,13 +94,15 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
);
}
function MenuItem({ icon: Icon, label, color, onClick }: { icon: typeof Play; label: string; color: string; onClick: () => void }) {
function MenuItem({ icon: Icon, label, color, onClick, disabled, tooltip }: { icon: typeof Play; label: string; color: string; onClick: () => void; disabled?: boolean; tooltip?: string }) {
return (
<button
onClick={onClick}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm text-slate-300 hover:bg-slate-700/60 transition-colors"
onClick={disabled ? undefined : onClick}
disabled={disabled}
title={tooltip}
className={`flex items-center gap-2.5 w-full px-3.5 py-2 text-sm transition-colors ${disabled ? "text-slate-600 cursor-not-allowed" : "text-slate-300 hover:bg-slate-700/60"}`}
>
<Icon size={14} className={color} />
<Icon size={14} className={disabled ? "text-slate-600" : color} />
<span>{label}</span>
</button>
);
+43
View File
@@ -0,0 +1,43 @@
import { useState } from "react";
import { HelpCircle } from "lucide-react";
interface TooltipProps {
text: string;
/** Width of the tooltip popover. Default: w-56 */
width?: string;
/** Icon size. Default: 13 */
size?: number;
/** Where the popover opens relative to the icon. Default: "top" */
placement?: "top" | "bottom";
}
export function Tooltip({ text, width = "w-56", size = 13, placement = "top" }: TooltipProps) {
const [show, setShow] = useState(false);
const popoverPos =
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2";
const arrowPos =
placement === "top"
? "top-full -mt-px border-t-slate-700"
: "bottom-full -mb-px border-b-slate-700";
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={(e) => { e.stopPropagation(); setShow((v) => !v); }}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={size} />
</button>
{show && (
<div className={`absolute ${popoverPos} left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${width} text-left shadow-xl z-50 leading-relaxed whitespace-normal`}>
{text}
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
</div>
)}
</span>
);
}
+22 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError } from "../../shared/types";
import type { StatsStore } from "./useStatsStore";
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
@@ -9,6 +9,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
const statsRef = useRef<Map<string, Stats>>(new Map());
const [events, setEvents] = useState<DockerEvent[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
const [actionErrors, setActionErrors] = useState<ActionError[]>([]);
// Processing state: uid → { expected state, start time, min duration before clearing }
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
@@ -123,6 +124,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
} else {
setServices((prev) => prev.map((s) => s.uid === msg.data.uid ? { ...s, state: "exited" as any } : s));
}
const errorId = `${msg.data.uid}:${msg.data.action}:${Date.now()}`;
setActionErrors((prev) => [
...prev.slice(-4), // keep at most 5 errors
{ id: errorId, uid: msg.data.uid, action: msg.data.action, error: msg.data.error, timestamp: Date.now() },
]);
break;
}
}
@@ -221,5 +227,19 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
return actionTimestamps.current.get(uid);
}, []);
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince };
const dismissActionError = useCallback((id: string) => {
setActionErrors((prev) => prev.filter((e) => e.id !== id));
}, []);
const clearActionErrors = useCallback(() => setActionErrors([]), []);
const pushActionError = useCallback((uid: string, action: string, error: string) => {
const errorId = `${uid}:${action}:${Date.now()}`;
setActionErrors((prev) => [
...prev.slice(-4),
{ id: errorId, uid, action, error, timestamp: Date.now() },
]);
}, []);
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError };
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState, useCallback } from "react";
import type { Service, ServerConfig } from "../../shared/types";
const DEFAULT_CONFIG: ServerConfig = {
allowedPaths: [],
allowNonCompose: true,
restrictedMode: false,
};
export function useServerConfig(token: string) {
const [config, setConfig] = useState<ServerConfig>(DEFAULT_CONFIG);
useEffect(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch("/api/config", { headers })
.then((r) => (r.ok ? r.json() : DEFAULT_CONFIG))
.then((data: ServerConfig) => setConfig({ ...DEFAULT_CONFIG, ...data }))
.catch(() => {});
}, [token]);
/** Returns true if the service can be acted upon (start/stop/restart/rebuild/remove/exec).
* When restrictedMode is off, always returns true. */
const canInteract = useCallback(
(service: Pick<Service, "compose_file">): boolean => {
if (!config.restrictedMode) return true;
const cf = service.compose_file;
if (!cf) return config.allowNonCompose;
return config.allowedPaths.some(
(prefix) => cf === prefix || cf.startsWith(prefix.replace(/\/+$/, "") + "/")
);
},
[config]
);
return { config, canInteract };
}
+56 -8
View File
@@ -32,16 +32,39 @@ const en = {
"login.errorConnectionRefused": "Connection refused",
"login.errorConnectionFailed": "ERROR: Connection failed",
// Context menu
// Context menu — labels stay in English (match docker commands)
"actions.restart": "Restart",
"actions.stop": "Stop",
"actions.start": "Start",
"actions.remove": "Remove",
"actions.rebuild": "Rebuild",
"actions.recreate": "Recreate",
"actions.openLogs": "Open Logs",
"actions.open": "Open",
"actions.retry": "Retry",
// Action tooltips (hover descriptions)
"actions.start.tooltip": "Starts the container (docker start)",
"actions.stop.tooltip": "Stops the container with SIGTERM, then SIGKILL after timeout (docker stop)",
"actions.restart.tooltip": "Stops and starts the same container (docker restart). Keeps image and config.",
"actions.rebuild.tooltip": "Rebuilds the image from Dockerfile and creates a new container (docker compose up --build). Apply code changes.",
"actions.recreate.tooltip": "Recreates the container with current compose config, reusing existing image (docker compose up --force-recreate). Apply compose changes without rebuilding.",
"actions.remove.tooltip": "Stops and permanently removes the container (docker rm). For compose services, also cleans associated networks.",
"actions.retry.tooltip": "Retries starting a crashed container",
// Action error toast
"toast.actionFailed": "{action} failed",
"toast.dismiss": "Dismiss",
"toast.dismissAll": "Dismiss all",
"toast.copy": "Copy",
"toast.copied": "Copied",
"toast.expand": "Show more",
"toast.collapse": "Show less",
// Access control (ALLOWED_PATHS)
"access.viewOnly": "View-only",
"access.restricted": "Outside ALLOWED_PATHS — actions disabled",
// Edge legend
"legend.connections": "Connections",
@@ -138,6 +161,7 @@ const en = {
"detail.confirmRestart": "Restart this container? This will briefly interrupt the service.",
"detail.confirmRemove": "Remove this container? This will stop and delete it.",
"detail.confirmRebuild": "Rebuild this container? This will rebuild the image and recreate the container.",
"detail.confirmRecreate": "Recreate this container? Reuses the existing image and applies current compose config.",
// Detail panel - Crash
"detail.containerCrashed": "Container crashed",
@@ -249,15 +273,38 @@ const es: Record<TranslationKey, string> = {
"login.errorConnectionRefused": "Conexi\u00f3n rechazada",
"login.errorConnectionFailed": "ERROR: Conexi\u00f3n fallida",
// Context menu
"actions.restart": "Reiniciar",
"actions.stop": "Detener",
"actions.start": "Iniciar",
"actions.remove": "Eliminar",
"actions.rebuild": "Reconstruir",
// Context menu — labels stay in English (match docker commands, evita confusion)
"actions.restart": "Restart",
"actions.stop": "Stop",
"actions.start": "Start",
"actions.remove": "Remove",
"actions.rebuild": "Rebuild",
"actions.recreate": "Recreate",
"actions.openLogs": "Ver Logs",
"actions.open": "Abrir",
"actions.retry": "Reintentar",
"actions.retry": "Retry",
// Action tooltips (hover descriptions)
"actions.start.tooltip": "Inicia el contenedor (docker start)",
"actions.stop.tooltip": "Detiene el contenedor con SIGTERM, luego SIGKILL tras el timeout (docker stop)",
"actions.restart.tooltip": "Detiene y vuelve a iniciar el mismo contenedor (docker restart). Mantiene imagen y configuración.",
"actions.rebuild.tooltip": "Reconstruye la imagen desde el Dockerfile y crea un contenedor nuevo (docker compose up --build). Para aplicar cambios de código.",
"actions.recreate.tooltip": "Recrea el contenedor con la config actual del compose, reusando la imagen existente (docker compose up --force-recreate). Para aplicar cambios de compose sin rebuild.",
"actions.remove.tooltip": "Detiene y elimina el contenedor permanentemente (docker rm). Para servicios compose, también limpia networks asociadas.",
"actions.retry.tooltip": "Reintenta arrancar un contenedor que crasheó",
// Action error toast
"toast.actionFailed": "Error en {action}",
"toast.dismiss": "Descartar",
"toast.dismissAll": "Descartar todos",
"toast.copy": "Copiar",
"toast.copied": "Copiado",
"toast.expand": "Ver más",
"toast.collapse": "Ver menos",
// Access control (ALLOWED_PATHS)
"access.viewOnly": "Solo lectura",
"access.restricted": "Fuera de ALLOWED_PATHS — acciones deshabilitadas",
// Edge legend
"legend.connections": "Conexiones",
@@ -355,6 +402,7 @@ const es: Record<TranslationKey, string> = {
"detail.confirmRestart": "\u00bfReiniciar este contenedor? Esto interrumpir\u00e1 brevemente el servicio.",
"detail.confirmRemove": "\u00bfEliminar este contenedor? Esto lo detendr\u00e1 y eliminar\u00e1.",
"detail.confirmRebuild": "\u00bfReconstruir este contenedor? Esto reconstruir\u00e1 la imagen y recrear\u00e1 el contenedor.",
"detail.confirmRecreate": "\u00bfRecrear este contenedor? Reusa la imagen existente y aplica la config actual del compose.",
// Detail panel - Crash
"detail.containerCrashed": "Contenedor crash\u00f3",
+9 -2
View File
@@ -23,6 +23,7 @@ import {
Mail,
BarChart3,
AlertTriangle,
Lock,
type LucideIcon,
} from "lucide-react";
@@ -36,6 +37,7 @@ interface ServiceNodeData {
id?: string;
activeHandles?: string[];
highlighted?: boolean;
locked?: boolean;
[key: string]: unknown;
}
@@ -136,11 +138,16 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
return (
<div
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`}
title={`${d.label} (${d.state})${d.locked ? " — view-only (outside ALLOWED_PATHS)" : ""}\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`}
className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring}
transition-[opacity,box-shadow] duration-300 ${flashClass}`}
transition-[opacity,box-shadow] duration-300 ${flashClass} ${d.locked ? "opacity-70" : ""}`}
>
{d.locked && (
<div className="absolute top-1.5 right-1.5 flex items-center justify-center w-5 h-5 rounded bg-slate-700/80 border border-slate-600/60 z-10" title={t("access.viewOnly")}>
<Lock size={11} className="text-slate-400" />
</div>
)}
{/* Top handles — left offset, transform centered horizontally */}
{offsets.map((o, i) => (
<Handle key={`t${i}`} type="source" position={Position.Top} id={`top-${i}`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
+2 -24
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { Settings, Server, Bell, Info, Send, Save, Check, X, HelpCircle } from "lucide-react";
import { Settings, Server, Bell, Info, Send, Save, Check, X } from "lucide-react";
import type { DiscordConfig } from "../../shared/types";
import { useT } from "../i18n";
import { Tooltip } from "../components/Tooltip";
interface SettingsPageProps {
projects: string[];
@@ -26,29 +27,6 @@ const DEFAULT_CONFIG: DiscordConfig = {
downReminderMinutes: 5,
};
function Tooltip({ text }: { text: string }) {
const [show, setShow] = useState(false);
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={() => setShow((v) => !v)}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={13} />
</button>
{show && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 w-56 text-left shadow-xl z-50 leading-relaxed">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-slate-700" />
</div>
)}
</span>
);
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
+58 -56
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react";
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle, Save } from "lucide-react";
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle, Save, Lock, RefreshCw } from "lucide-react";
import { Tooltip } from "../components/Tooltip";
import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings, DiscordConfig, StatsRange } from "../../shared/types";
import { useT } from "../i18n";
import { useStatsHistory } from "../hooks/useStatsHistory";
@@ -45,9 +46,11 @@ interface DetailPanelProps {
logLines: LogLine[];
token: string;
closing?: boolean;
locked?: boolean;
onClose: () => void;
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
clearProcessing: (uid: string) => void;
pushActionError: (uid: string, action: string, error: string) => void;
sendMessage: (msg: WSMessage) => void;
clearLogLines: () => void;
connections: Connection[];
@@ -59,7 +62,7 @@ interface DetailPanelProps {
events: DockerEvent[];
}
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, clearProcessing, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) {
export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) {
const { t } = useT();
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
@@ -163,9 +166,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
}, [isProcessing, processingStartedAt]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{ type: "success" | "error"; message: string } | null>(null);
const [confirmAction, setConfirmAction] = useState<"stop" | "restart" | "rebuild" | "remove" | null>(null);
const [confirmAction, setConfirmAction] = useState<"stop" | "restart" | "rebuild" | "recreate" | "remove" | null>(null);
const executeAction = useCallback(async (action: "stop" | "start" | "restart" | "rebuild" | "remove") => {
const executeAction = useCallback(async (action: "stop" | "start" | "restart" | "rebuild" | "recreate" | "remove") => {
setActionLoading(action);
setActionResult(null);
setConfirmAction(null);
@@ -174,9 +177,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
// Optimistic processing — set BEFORE fetch
const expectedState: Service["state"] =
action === "stop" || action === "remove" ? "exited" :
action === "start" || action === "restart" || action === "rebuild" ? "running" :
action === "start" || action === "restart" || action === "rebuild" || action === "recreate" ? "running" :
service.state;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0;
const minDuration = action === "restart" ? 2000 : (action === "rebuild" || action === "recreate") ? 3000 : 0;
onAction(service.uid, expectedState, minDuration);
setInitialLogs([]);
clearLogLines();
@@ -196,16 +199,20 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
}
} else {
clearProcessing(service.uid);
setActionResult({ type: "error", message: data.error || `${t("detail.actionFailed")} ${action}` });
const errMsg = data.error || `${t("detail.actionFailed")} ${action}`;
setActionResult({ type: "error", message: errMsg });
pushActionError(service.uid, action, errMsg);
}
} catch {
} catch (err: any) {
clearProcessing(service.uid);
setActionResult({ type: "error", message: `${t("detail.actionFailed")} ${action}` });
const errMsg = err?.message || `${t("detail.actionFailed")} ${action}`;
setActionResult({ type: "error", message: errMsg });
pushActionError(service.uid, action, errMsg);
} finally {
setActionLoading(null);
setTimeout(() => setActionResult(null), 3000);
}
}, [service.id, service.uid, token, onAction, clearProcessing, sendMessage, clearLogLines]);
}, [service.id, service.uid, token, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, t]);
const runExec = useCallback(async () => {
if (!execCmd.trim()) return;
@@ -378,6 +385,12 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${stateDot}`} />
<span className="text-sm font-semibold text-white truncate">{service.name}</span>
{locked && (
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-slate-400 bg-slate-700/60 border border-slate-600/50 rounded" title={t("access.viewOnly")}>
<Lock size={10} />
{t("access.viewOnly")}
</span>
)}
{service.ports.length > 0 && service.state === "running" && (
<a
href={`http://${window.location.hostname}:${service.ports[0].host}`}
@@ -406,32 +419,39 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
) : (
<>
{service.compose_file && (
<button
onClick={() => setConfirmAction("rebuild")}
disabled={!!actionLoading}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
title={t("actions.rebuild")}
>
{actionLoading === "rebuild" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />}
{t("actions.rebuild")}
</button>
<>
<button
onClick={() => setConfirmAction("recreate")}
disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
>
{actionLoading === "recreate" ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
{t("actions.recreate")}
</button>
<button
onClick={() => setConfirmAction("rebuild")}
disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
>
{actionLoading === "rebuild" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />}
{t("actions.rebuild")}
</button>
</>
)}
{service.state === "running" ? (
<>
<button
onClick={() => setConfirmAction("restart")}
disabled={!!actionLoading}
disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-yellow-400 hover:bg-yellow-400/10 transition-colors disabled:opacity-40"
title={t("actions.restart")}
>
{actionLoading === "restart" ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
{t("actions.restart")}
</button>
<button
onClick={() => setConfirmAction("stop")}
disabled={!!actionLoading}
disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
title={t("actions.stop")}
>
{actionLoading === "stop" ? <Loader2 size={12} className="animate-spin" /> : <Square size={12} />}
{t("actions.stop")}
@@ -441,20 +461,18 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<>
<button
onClick={() => setConfirmAction("remove")}
disabled={!!actionLoading}
disabled={!!actionLoading || locked}
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
title={t("actions.remove")}
>
{actionLoading === "remove" ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
{t("actions.remove")}
</button>
<button
onClick={() => executeAction("start")}
disabled={!!actionLoading}
disabled={!!actionLoading || locked}
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors disabled:opacity-40 ${
isCrashed ? "text-orange-400 hover:bg-orange-400/10" : "text-emerald-400 hover:bg-emerald-400/10"
}`}
title={isCrashed ? t("actions.retry") : t("actions.start")}
>
{actionLoading === "start" ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{isCrashed ? t("actions.retry") : t("actions.start")}
@@ -480,24 +498,30 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="px-4 py-2.5 bg-slate-800/90 border-b border-slate-700/60 flex items-center gap-3 shrink-0">
<AlertTriangle size={14} className={`shrink-0 ${
confirmAction === "stop" || confirmAction === "remove" ? "text-red-400" :
confirmAction === "rebuild" ? "text-cyan-400" :
confirmAction === "rebuild" || confirmAction === "recreate" ? "text-cyan-400" :
"text-yellow-400"
}`} />
<span className="text-xs text-slate-300 flex-1">
<span className="text-xs text-slate-300 flex-1 flex items-center gap-1.5">
{confirmAction === "stop" ? t("detail.confirmStop") :
confirmAction === "restart" ? t("detail.confirmRestart") :
confirmAction === "remove" ? t("detail.confirmRemove") :
confirmAction === "recreate" ? t("detail.confirmRecreate") :
t("detail.confirmRebuild")}
<Tooltip text={t(`actions.${confirmAction}.tooltip` as any)} width="w-72" placement="bottom" />
</span>
<button
onClick={() => executeAction(confirmAction)}
className={`px-3 py-1 rounded text-[11px] font-medium text-white transition-colors ${
confirmAction === "stop" || confirmAction === "remove" ? "bg-red-700 hover:bg-red-600" :
confirmAction === "rebuild" ? "bg-cyan-700 hover:bg-cyan-600" :
confirmAction === "rebuild" || confirmAction === "recreate" ? "bg-cyan-700 hover:bg-cyan-600" :
"bg-yellow-700 hover:bg-yellow-600"
}`}
>
{confirmAction === "stop" ? t("actions.stop") : confirmAction === "restart" ? t("actions.restart") : confirmAction === "remove" ? t("actions.remove") : t("actions.rebuild")}
{confirmAction === "stop" ? t("actions.stop") :
confirmAction === "restart" ? t("actions.restart") :
confirmAction === "remove" ? t("actions.remove") :
confirmAction === "recreate" ? t("actions.recreate") :
t("actions.rebuild")}
</button>
<button
onClick={() => setConfirmAction(null)}
@@ -1084,7 +1108,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
)}
</div>
<div className="flex items-center gap-1">
{service.state === "running" && (
{service.state === "running" && !locked && (
<button
onClick={() => { setExecOpen((v) => { if (!v) setLogsExpanded(true); return !v; }); setExecResult(null); setExecError(null); }}
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${execOpen ? "text-purple-300 bg-purple-400/10" : "text-purple-400 hover:bg-purple-400/10"}`}
@@ -1283,28 +1307,6 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
}
function Tooltip({ text }: { text: string }) {
const [show, setShow] = useState(false);
return (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={() => setShow((v) => !v)}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={10} />
</button>
{show && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 w-48 text-left shadow-xl z-50 leading-relaxed">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-slate-700" />
</div>
)}
</span>
);
}
function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel, limitLabel, thresholdTooltip, limitTooltip }: { label: string; value: string; extra?: string; color: string; limit?: string; threshold?: string; thresholdLabel?: string; limitLabel?: string; thresholdTooltip?: string; limitTooltip?: string }) {
return (
@@ -1321,14 +1323,14 @@ function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel
<div className="flex items-center gap-1">
<span className="text-[10px] text-slate-500">{thresholdLabel}:</span>
<span className="text-[10px] text-slate-400 font-mono">{threshold}</span>
{thresholdTooltip && <Tooltip text={thresholdTooltip} />}
{thresholdTooltip && <Tooltip text={thresholdTooltip} size={10} width="w-48" />}
</div>
)}
{limit && (
<div className="flex items-center gap-1">
<span className="text-[10px] text-slate-500">{limitLabel}:</span>
<span className="text-[10px] text-slate-400 font-mono">{limit}</span>
{limitTooltip && <Tooltip text={limitTooltip} />}
{limitTooltip && <Tooltip text={limitTooltip} size={10} width="w-48" />}
</div>
)}
</div>
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import type { ContainerSettings } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd();
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const SETTINGS_FILE = path.join(DATA_DIR, ".dockerflow-container-settings.json");
export function loadContainerSettings(): Record<string, ContainerSettings> {
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import type { DiscordConfig } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd();
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json");
const DEFAULT_CONFIG: DiscordConfig = {
+141 -3
View File
@@ -11,8 +11,46 @@ import { loadContainerSettings, saveContainerSettings } from "./container-settin
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
/** Directory for persistent data files (positions, env overrides) */
const DATA_DIR = process.env.DATA_DIR || process.cwd();
/** Directory for persistent data files (SQLite, JSON configs, positions).
* Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
/** Paths under which actions (start/stop/restart/rebuild/remove/exec) are allowed.
* Empty = permissive mode (all actions allowed on all containers).
* Set = strict mode (actions only allowed for compose files under these paths). */
const ALLOWED_PATHS = (process.env.ALLOWED_PATHS || "")
.split(":")
.map((p) => p.trim())
.filter(Boolean)
.map((p) => p.replace(/\/+$/, "")); // strip trailing slashes
/** When ALLOWED_PATHS is set, allow actions on non-compose containers (no compose label). */
const ALLOW_NON_COMPOSE = process.env.ALLOW_NON_COMPOSE === "true";
/** Strict mode is active when ALLOWED_PATHS has at least one entry. */
const RESTRICTED_MODE = ALLOWED_PATHS.length > 0;
/** Returns true if filePath is under one of the allowed prefixes. */
function isPathAllowed(filePath: string): boolean {
if (!RESTRICTED_MODE) return true;
const normalized = path.resolve(filePath);
return ALLOWED_PATHS.some((prefix) => normalized === prefix || normalized.startsWith(prefix + "/"));
}
/** Returns null if container can be acted upon, or an error message string if not. */
function checkContainerAccess(info: { Config?: { Labels?: Record<string, string> } }): string | null {
if (!RESTRICTED_MODE) return null;
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
if (!composeFile) {
if (ALLOW_NON_COMPOSE) return null;
return "This container has no compose file. Actions are restricted in this mode (ALLOWED_PATHS is set, ALLOW_NON_COMPOSE is false).";
}
if (!isPathAllowed(composeFile)) {
return `Container's compose file is outside ALLOWED_PATHS:\n ${composeFile}\n\nAllowed paths:\n${ALLOWED_PATHS.map((p) => ` ${p}`).join("\n")}`;
}
return null;
}
/** Env-file overrides per compose file (persisted to file) */
const ENV_FILES_FILE = path.join(DATA_DIR, ".dockerflow-env-files.json");
@@ -162,6 +200,15 @@ app.get("/api/init", async (c) => {
return c.json({ services, connections, positions });
});
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
app.get("/api/config", (c) => {
return c.json({
allowedPaths: ALLOWED_PATHS,
allowNonCompose: ALLOW_NON_COMPOSE,
restrictedMode: RESTRICTED_MODE,
});
});
// ── Helper: get service uid from container inspect info ──
function getContainerUid(info: any): string {
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
@@ -176,6 +223,8 @@ app.post("/api/containers/:id/stop", async (c) => {
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
await container.stop();
immediateRefresh();
const uid = getContainerUid(info);
@@ -193,6 +242,8 @@ app.post("/api/containers/:id/start", async (c) => {
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
await container.start();
immediateRefresh();
const uid = getContainerUid(info);
@@ -210,6 +261,8 @@ app.post("/api/containers/:id/restart", async (c) => {
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
await container.restart();
immediateRefresh();
const uid = getContainerUid(info);
@@ -226,12 +279,28 @@ app.post("/api/containers/:id/rebuild", async (c) => {
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
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";
if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
}
if (!fs.existsSync(composeFile)) {
const dir = path.dirname(composeFile);
return c.json({
error:
`Compose file not accessible from ContainerFlow:\n` +
` ${composeFile}\n\n` +
`Este path existe en el host pero no está montado dentro del container de ContainerFlow.\n\n` +
`Fix: agrega este volumen a docker-compose.yml de ContainerFlow:\n` +
` - ${dir}:${dir}:ro\n\n` +
`O usa la variable HOST_PROJECTS_DIR en .env:\n` +
` HOST_PROJECTS_DIR=${dir}\n\n` +
`Luego: docker compose up -d --force-recreate containerflow`,
}, 400);
}
const uid = `${project}/${serviceName}`;
const envArgs = findEnvFileArgs(composeFile);
// Run rebuild in background — respond immediately
@@ -260,12 +329,67 @@ app.post("/api/containers/:id/rebuild", async (c) => {
}
});
app.post("/api/containers/:id/recreate", async (c) => {
const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
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";
if (!composeFile || !serviceName) {
return c.json({ error: "Not a Compose service — recreate requires docker-compose" }, 400);
}
if (!fs.existsSync(composeFile)) {
const dir = path.dirname(composeFile);
return c.json({
error:
`Compose file not accessible from ContainerFlow:\n` +
` ${composeFile}\n\n` +
`Fix: agrega a docker-compose.yml de ContainerFlow:\n` +
` - ${dir}:${dir}:ro\n\n` +
`Luego: docker compose up -d --force-recreate containerflow`,
}, 400);
}
const uid = `${project}/${serviceName}`;
const envArgs = findEnvFileArgs(composeFile);
// Recreate uses existing image (no --build), only re-applies compose config
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--force-recreate", "-d", serviceName], {
stdout: "pipe",
stderr: "pipe",
});
proc.exited.then(async (exitCode) => {
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
const errorMsg = stderr || `Recreate failed with exit code ${exitCode}`;
broadcast({ type: "action_error", data: { uid, action: "recreate", error: errorMsg } });
try { notifyActionError(uid, "recreate", errorMsg, loadDiscordConfig()); } catch {}
} else {
try { notifyUIAction(uid, "recreate", loadDiscordConfig()); } catch {}
}
scheduleRefresh();
}).catch((err) => {
const errorMsg = err?.message || "Recreate failed";
broadcast({ type: "action_error", data: { uid, action: "recreate", error: errorMsg } });
try { notifyActionError(uid, "recreate", errorMsg, loadDiscordConfig()); } catch {}
});
return c.json({ ok: true });
} catch (err: any) {
return c.json({ error: err?.message || "Failed to recreate container" }, 500);
}
});
app.post("/api/containers/:id/remove", async (c) => {
const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
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"];
if (!composeFile || !serviceName) {
@@ -274,6 +398,17 @@ app.post("/api/containers/:id/remove", async (c) => {
await container.remove({ force: true });
return c.json({ ok: true });
}
if (!fs.existsSync(composeFile)) {
const dir = path.dirname(composeFile);
return c.json({
error:
`Compose file not accessible from ContainerFlow:\n` +
` ${composeFile}\n\n` +
`Fix: agrega a docker-compose.yml de ContainerFlow:\n` +
` - ${dir}:${dir}:ro\n\n` +
`Luego: docker compose up -d --force-recreate containerflow`,
}, 400);
}
const envArgs = findEnvFileArgs(composeFile);
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "rm", "-sf", serviceName], {
stdout: "pipe",
@@ -294,6 +429,10 @@ app.post("/api/containers/:id/exec", async (c) => {
const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
try {
const container = docker.getContainer(id);
const info = await container.inspect();
const denied = checkContainerAccess(info);
if (denied) return c.json({ error: denied }, 403);
const body = await c.req.json();
const cmd = body?.cmd;
if (!cmd || typeof cmd !== "string") return c.json({ error: "Missing cmd" }, 400);
@@ -317,7 +456,6 @@ app.post("/api/containers/:id/exec", async (c) => {
if (current) parts.push(current);
if (parts.length === 0) return c.json({ error: "Empty command" }, 400);
const container = docker.getContainer(id);
const exec = await container.exec({ Cmd: parts, AttachStdout: true, AttachStderr: true });
const stream = await exec.start({});
+1 -1
View File
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import path from "path";
import type { Stats, StatsHistoryPoint, StatsRange } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd();
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, ".dockerflow-stats.db");
let db: Database;
+14
View File
@@ -85,6 +85,20 @@ export interface StatsHistoryPoint {
export type StatsRange = "1h" | "6h" | "24h" | "7d";
export interface ActionError {
id: string;
uid: string;
action: string;
error: string;
timestamp: number;
}
export interface ServerConfig {
allowedPaths: string[];
allowNonCompose: boolean;
restrictedMode: boolean;
}
export type WSMessage =
| { type: "services"; data: Service[] }
| { type: "connections"; data: Connection[] }