This commit is contained in:
RGJorge
2026-05-08 02:50:17 +00:00
parent 1ee37368bd
commit 10c7d6e530
5 changed files with 333 additions and 32 deletions
+34
View File
@@ -105,6 +105,23 @@ const en = {
"detail.memory": "Memory",
"detail.noStats": "No stats available",
// Detail panel - Warning banners
"detail.noMemoryLimit": "No memory limit configured in Docker",
"detail.noCpuLimit": "No CPU limit configured in Docker",
"detail.weakRestartPolicy": "Restart policy: none — container won't restart automatically if it stops",
// Detail panel - Notification settings
"detail.notifications": "Notifications",
"detail.notificationsEnabled": "Enabled",
"detail.cpuThreshold": "CPU threshold",
"detail.memThreshold": "Memory threshold",
"detail.global": "(global)",
"detail.custom": "(custom)",
"detail.saveSettings": "Save",
"detail.settingsSaved": "Saved",
"detail.savingSettings": "Saving...",
"detail.thresholdHint": "Drag line to set alert threshold",
// Detail panel - Actions / Confirmations
"detail.actionSuccess": "successful",
"detail.actionFailed": "Failed to",
@@ -287,6 +304,23 @@ const es: Record<TranslationKey, string> = {
"detail.memory": "Memoria",
"detail.noStats": "No hay estad\u00edsticas disponibles",
// Detail panel - Warning banners
"detail.noMemoryLimit": "Sin l\u00edmite de memoria configurado en Docker",
"detail.noCpuLimit": "Sin l\u00edmite de CPU configurado en Docker",
"detail.weakRestartPolicy": "Restart policy: none \u2014 el contenedor no se reiniciar\u00e1 autom\u00e1ticamente si se detiene",
// Detail panel - Notification settings
"detail.notifications": "Notificaciones",
"detail.notificationsEnabled": "Habilitadas",
"detail.cpuThreshold": "Umbral de CPU",
"detail.memThreshold": "Umbral de memoria",
"detail.global": "(global)",
"detail.custom": "(personalizado)",
"detail.saveSettings": "Guardar",
"detail.settingsSaved": "Guardado",
"detail.savingSettings": "Guardando...",
"detail.thresholdHint": "Arrastra la l\u00ednea para configurar el umbral de alerta",
// Detail panel - Actions / Confirmations
"detail.actionSuccess": "exitoso",
"detail.actionFailed": "Error al",
+240 -27
View File
@@ -1,6 +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 } from "lucide-react";
import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent } from "../../shared/types";
import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings } from "../../shared/types";
import { useT } from "../i18n";
type Tab = "info" | "config" | "env" | "stats";
@@ -82,6 +82,41 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
const [execResult, setExecResult] = useState<{ output: string; exitCode: number } | null>(null);
const [execError, setExecError] = useState<string | null>(null);
// Container notification settings
const [containerSettings, setContainerSettings] = useState<ContainerSettings>({ notificationsEnabled: true, cpuThreshold: null, memThreshold: null });
const [csLoaded, setCsLoaded] = useState(false);
const [csSaving, setCsSaving] = useState(false);
const [csSaved, setCsSaved] = useState(false);
useEffect(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch("/api/container-settings", { headers })
.then((r) => r.ok ? r.json() : {})
.then((all: Record<string, ContainerSettings>) => {
if (all[service.uid]) setContainerSettings(all[service.uid]);
setCsLoaded(true);
})
.catch(() => setCsLoaded(true));
}, [service.uid, token]);
const saveContainerSettings = useCallback(async () => {
setCsSaving(true);
setCsSaved(false);
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
await fetch("/api/container-settings", {
method: "PUT",
headers,
body: JSON.stringify({ uid: service.uid, settings: containerSettings }),
});
setCsSaved(true);
setTimeout(() => setCsSaved(false), 2000);
} catch {}
setCsSaving(false);
}, [service.uid, token, containerSettings]);
// Scroll modal to bottom when opened or when logs arrive
useEffect(() => {
if (logsModal && modalScrollRef.current) {
@@ -493,6 +528,25 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
</div>
</div>
)}
{/* Warning banners */}
{service.memory_limit === 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs rounded-lg px-3 py-2 mb-3 flex items-center gap-2">
<AlertTriangle size={14} className="shrink-0" />
{t("detail.noMemoryLimit")}
</div>
)}
{service.cpu_quota === 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs rounded-lg px-3 py-2 mb-3 flex items-center gap-2">
<AlertTriangle size={14} className="shrink-0" />
{t("detail.noCpuLimit")}
</div>
)}
{(service.restart_policy === "no" || service.restart_policy === "") && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs rounded-lg px-3 py-2 mb-3 flex items-center gap-2">
<AlertTriangle size={14} className="shrink-0" />
{t("detail.weakRestartPolicy")}
</div>
)}
{service.status && (
<DetailRow label={t("detail.status")} value={service.status} />
)}
@@ -804,6 +858,40 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{/* Stats tab */}
{activeTab === "stats" && (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{/* Warning banners */}
{service.memory_limit === 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs rounded-lg px-3 py-2 flex items-center gap-2">
<AlertTriangle size={14} className="shrink-0" />
{t("detail.noMemoryLimit")}
</div>
)}
{service.cpu_quota === 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs rounded-lg px-3 py-2 flex items-center gap-2">
<AlertTriangle size={14} className="shrink-0" />
{t("detail.noCpuLimit")}
</div>
)}
{(service.restart_policy === "no" || service.restart_policy === "") && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs rounded-lg px-3 py-2 flex items-center gap-2">
<AlertTriangle size={14} className="shrink-0" />
{t("detail.weakRestartPolicy")}
</div>
)}
{/* Notifications toggle */}
{csLoaded && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
<button
type="button"
onClick={() => setContainerSettings((s) => ({ ...s, notificationsEnabled: !s.notificationsEnabled }))}
className={`relative w-9 h-5 rounded-full transition-colors ${containerSettings.notificationsEnabled ? "bg-cyan-600" : "bg-slate-600"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${containerSettings.notificationsEnabled ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
)}
{stats ? (
<>
<div className="grid grid-cols-2 gap-3">
@@ -811,37 +899,50 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<StatCard label={t("detail.memory")} value={`${stats.mem_mb.toFixed(0)} MB`} extra={`${stats.mem_percent.toFixed(1)}%`} color={stats.mem_percent > 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
</div>
{/* CPU bar */}
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>{t("detail.cpuUsage")}</span>
<span>{stats.cpu.toFixed(1)}%</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${stats.cpu > 80 ? "bg-red-500" : stats.cpu > 50 ? "bg-yellow-500" : "bg-emerald-500"}`}
style={{ width: `${Math.min(stats.cpu, 100)}%` }}
/>
</div>
</div>
{/* CPU bar with draggable threshold */}
<ThresholdBar
label={t("detail.cpuUsage")}
value={stats.cpu}
threshold={containerSettings.cpuThreshold ?? 80}
isCustom={containerSettings.cpuThreshold !== null}
showThreshold={containerSettings.notificationsEnabled}
thresholdLabel={t("detail.cpuThreshold")}
tagLabel={containerSettings.cpuThreshold !== null ? t("detail.custom") : t("detail.global")}
hintLabel={t("detail.thresholdHint")}
onThresholdChange={(v) => setContainerSettings((s) => ({ ...s, cpuThreshold: v }))}
onReset={() => setContainerSettings((s) => ({ ...s, cpuThreshold: null }))}
formatValue={(v) => `${v.toFixed(1)}%`}
/>
{/* Memory bar */}
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>{t("detail.memoryUsage")}</span>
<span>{stats.mem_mb.toFixed(0)} MB ({stats.mem_percent.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${stats.mem_percent > 80 ? "bg-red-500" : stats.mem_percent > 50 ? "bg-yellow-500" : "bg-emerald-500"}`}
style={{ width: `${Math.min(stats.mem_percent, 100)}%` }}
/>
</div>
</div>
{/* Memory bar with draggable threshold */}
<ThresholdBar
label={t("detail.memoryUsage")}
value={stats.mem_percent}
threshold={containerSettings.memThreshold ?? 90}
isCustom={containerSettings.memThreshold !== null}
showThreshold={containerSettings.notificationsEnabled}
thresholdLabel={t("detail.memThreshold")}
tagLabel={containerSettings.memThreshold !== null ? t("detail.custom") : t("detail.global")}
hintLabel={t("detail.thresholdHint")}
onThresholdChange={(v) => setContainerSettings((s) => ({ ...s, memThreshold: v }))}
onReset={() => setContainerSettings((s) => ({ ...s, memThreshold: null }))}
formatValue={() => `${stats.mem_mb.toFixed(0)} MB (${stats.mem_percent.toFixed(1)}%)`}
/>
</>
) : (
<div className="text-slate-500 text-sm text-center py-8">{t("detail.noStats")}</div>
)}
{/* Save button */}
{csLoaded && (
<button
onClick={saveContainerSettings}
disabled={csSaving}
className="w-full px-3 py-1.5 rounded text-xs font-medium text-white bg-cyan-700 hover:bg-cyan-600 transition-colors disabled:opacity-50"
>
{csSaving ? t("detail.savingSettings") : csSaved ? t("detail.settingsSaved") : t("detail.saveSettings")}
</button>
)}
</div>
)}
@@ -1084,6 +1185,118 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
);
}
function ThresholdBar({ label, value, threshold, isCustom, showThreshold, thresholdLabel, tagLabel, hintLabel, onThresholdChange, onReset, formatValue }: {
label: string;
value: number;
threshold: number;
isCustom: boolean;
showThreshold: boolean;
thresholdLabel: string;
tagLabel: string;
hintLabel: string;
onThresholdChange: (v: number) => void;
onReset: () => void;
formatValue: (v: number) => string;
}) {
const barRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const [hovering, setHovering] = useState(false);
const calcPercent = useCallback((clientX: number) => {
if (!barRef.current) return threshold;
const rect = barRef.current.getBoundingClientRect();
const pct = Math.round(((clientX - rect.left) / rect.width) * 100);
return Math.max(5, Math.min(100, pct));
}, [threshold]);
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => { onThresholdChange(calcPercent(e.clientX)); };
const onUp = () => { setDragging(false); };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
}, [dragging, calcPercent, onThresholdChange]);
// Touch support
useEffect(() => {
if (!dragging) return;
const onMove = (e: TouchEvent) => { if (e.touches[0]) onThresholdChange(calcPercent(e.touches[0].clientX)); };
const onEnd = () => { setDragging(false); };
window.addEventListener("touchmove", onMove);
window.addEventListener("touchend", onEnd);
return () => { window.removeEventListener("touchmove", onMove); window.removeEventListener("touchend", onEnd); };
}, [dragging, calcPercent, onThresholdChange]);
const barColor = showThreshold
? (value > threshold ? "bg-red-500" : value > 50 ? "bg-yellow-500" : "bg-emerald-500")
: (value > 80 ? "bg-red-500" : value > 50 ? "bg-yellow-500" : "bg-emerald-500");
const showTooltip = dragging || hovering;
return (
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>{label}</span>
<span>{formatValue(value)}</span>
</div>
<div
ref={barRef}
className={`relative ${showThreshold ? "h-3" : "h-2"} bg-slate-800 rounded-full group ${showThreshold ? "cursor-pointer" : ""}`}
onClick={(e) => { if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }}
>
{/* Usage fill */}
<div
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.min(value, 100)}%` }}
/>
{/* Threshold handle — only when notifications enabled */}
{showThreshold && (
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 z-10 select-none touch-none"
style={{ left: `${threshold}%` }}
onMouseDown={(e) => { e.preventDefault(); setDragging(true); }}
onTouchStart={(e) => { e.preventDefault(); setDragging(true); }}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{/* Vertical line */}
<div className={`w-0.5 h-5 rounded-full transition-colors ${dragging ? "bg-amber-300" : "bg-amber-400/80 group-hover:bg-amber-400"}`} />
{/* Drag handle diamond */}
<div className={`absolute -top-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rotate-45 rounded-[1px] border transition-colors cursor-grab active:cursor-grabbing ${
dragging ? "bg-amber-300 border-amber-200" : "bg-amber-400/90 border-amber-500/50 group-hover:bg-amber-400"
}`} />
{/* Tooltip */}
{showTooltip && (
<div className="absolute -top-7 left-1/2 -translate-x-1/2 px-1.5 py-0.5 bg-slate-700 rounded text-[10px] font-mono text-amber-300 whitespace-nowrap shadow-lg">
{threshold}%
</div>
)}
</div>
)}
</div>
{/* Label row — only when notifications enabled */}
{showThreshold && (
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-slate-600">{hintLabel}</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-amber-400/70 font-mono">{threshold}%</span>
{isCustom && (
<button
onClick={onReset}
className="text-[9px] text-slate-500 hover:text-slate-300 transition-colors"
title="Reset to global"
>
reset
</button>
)}
<span className="text-[9px] text-slate-600">{tagLabel}</span>
</div>
</div>
)}
</div>
);
}
function StatCard({ label, value, extra, color }: { label: string; value: string; extra?: string; color: string }) {
return (
<div className="bg-slate-800/80 rounded-lg px-4 py-3">
+19
View File
@@ -0,0 +1,19 @@
import fs from "fs";
import path from "path";
import type { ContainerSettings } from "../shared/types";
const DATA_DIR = process.env.DATA_DIR || process.cwd();
const SETTINGS_FILE = path.join(DATA_DIR, ".dockerflow-container-settings.json");
export function loadContainerSettings(): Record<string, ContainerSettings> {
try {
if (fs.existsSync(SETTINGS_FILE)) {
return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
}
} catch {}
return {};
}
export function saveContainerSettings(settings: Record<string, ContainerSettings>): void {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
}
+31 -5
View File
@@ -7,7 +7,8 @@ import fs from "fs";
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
import { pollStats, watchDockerEvents } from "./watcher";
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices } from "./discord";
import type { Service, WSMessage, DiscordConfig } from "../shared/types";
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
import type { Service, WSMessage, DiscordConfig, ContainerSettings } from "../shared/types";
/** Directory for persistent data files (positions, env overrides) */
const DATA_DIR = process.env.DATA_DIR || process.cwd();
@@ -450,6 +451,26 @@ app.post("/api/discord-config/test", async (c) => {
}
});
// ── Container settings ──
app.get("/api/container-settings", (c) => {
return c.json(loadContainerSettings());
});
app.put("/api/container-settings", async (c) => {
try {
const body = await c.req.json() as { uid: string; settings: ContainerSettings };
if (!body.uid || !body.settings) {
return c.json({ error: "Missing uid or settings" }, 400);
}
const all = loadContainerSettings();
all[body.uid] = body.settings;
saveContainerSettings(all);
return c.json({ ok: true });
} catch {
return c.json({ error: "Failed to save" }, 500);
}
});
// ── Cache headers for static assets ──
app.use("/*", async (c, next) => {
await next();
@@ -544,12 +565,17 @@ async function refreshStats(services: Service[]) {
const discordConfig = loadDiscordConfig();
if (discordConfig.enabled) {
if (discordConfig.events.resourceAlerts) {
const containerSettings = loadContainerSettings();
for (const stat of stats) {
if (stat.cpu >= discordConfig.thresholds.cpuPercent) {
notifyResourceAlert(stat.service, "cpu", stat.cpu, discordConfig.thresholds.cpuPercent, discordConfig);
const cs = containerSettings[stat.service];
if (cs && cs.notificationsEnabled === false) continue;
const cpuThreshold = cs?.cpuThreshold ?? discordConfig.thresholds.cpuPercent;
const memThreshold = cs?.memThreshold ?? discordConfig.thresholds.memPercent;
if (stat.cpu >= cpuThreshold) {
notifyResourceAlert(stat.service, "cpu", stat.cpu, cpuThreshold, discordConfig);
}
if (stat.mem_percent >= discordConfig.thresholds.memPercent) {
notifyResourceAlert(stat.service, "memory", stat.mem_percent, discordConfig.thresholds.memPercent, discordConfig);
if (stat.mem_percent >= memThreshold) {
notifyResourceAlert(stat.service, "memory", stat.mem_percent, memThreshold, discordConfig);
}
}
}
+9
View File
@@ -67,6 +67,15 @@ export interface DiscordConfig {
downReminderMinutes: number;
}
export interface ContainerSettings {
/** false = no enviar notificaciones para este contenedor */
notificationsEnabled: boolean;
/** Override del umbral global de CPU (null = usar global) */
cpuThreshold: number | null;
/** Override del umbral global de memoria (null = usar global) */
memThreshold: number | null;
}
export type WSMessage =
| { type: "services"; data: Service[] }
| { type: "connections"; data: Connection[] }