mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.29
This commit is contained in:
+34
-7
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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%)" }} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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({});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
Reference in New Issue
Block a user