diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index 8800409..3336978 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -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 = { "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", diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 1a06325..9b47c06 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -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(null); + // Container notification settings + const [containerSettings, setContainerSettings] = useState({ 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 = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/container-settings", { headers }) + .then((r) => r.ok ? r.json() : {}) + .then((all: Record) => { + 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 = { "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, )} + {/* Warning banners */} + {service.memory_limit === 0 && ( +
+ + {t("detail.noMemoryLimit")} +
+ )} + {service.cpu_quota === 0 && ( +
+ + {t("detail.noCpuLimit")} +
+ )} + {(service.restart_policy === "no" || service.restart_policy === "") && ( +
+ + {t("detail.weakRestartPolicy")} +
+ )} {service.status && ( )} @@ -804,6 +858,40 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, {/* Stats tab */} {activeTab === "stats" && (
+ {/* Warning banners */} + {service.memory_limit === 0 && ( +
+ + {t("detail.noMemoryLimit")} +
+ )} + {service.cpu_quota === 0 && ( +
+ + {t("detail.noCpuLimit")} +
+ )} + {(service.restart_policy === "no" || service.restart_policy === "") && ( +
+ + {t("detail.weakRestartPolicy")} +
+ )} + + {/* Notifications toggle */} + {csLoaded && ( +
+ {t("detail.notifications")} + +
+ )} + {stats ? ( <>
@@ -811,37 +899,50 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
- {/* CPU bar */} -
-
- {t("detail.cpuUsage")} - {stats.cpu.toFixed(1)}% -
-
-
80 ? "bg-red-500" : stats.cpu > 50 ? "bg-yellow-500" : "bg-emerald-500"}`} - style={{ width: `${Math.min(stats.cpu, 100)}%` }} - /> -
-
+ {/* CPU bar with draggable threshold */} + setContainerSettings((s) => ({ ...s, cpuThreshold: v }))} + onReset={() => setContainerSettings((s) => ({ ...s, cpuThreshold: null }))} + formatValue={(v) => `${v.toFixed(1)}%`} + /> - {/* Memory bar */} -
-
- {t("detail.memoryUsage")} - {stats.mem_mb.toFixed(0)} MB ({stats.mem_percent.toFixed(1)}%) -
-
-
80 ? "bg-red-500" : stats.mem_percent > 50 ? "bg-yellow-500" : "bg-emerald-500"}`} - style={{ width: `${Math.min(stats.mem_percent, 100)}%` }} - /> -
-
+ {/* Memory bar with draggable threshold */} + setContainerSettings((s) => ({ ...s, memThreshold: v }))} + onReset={() => setContainerSettings((s) => ({ ...s, memThreshold: null }))} + formatValue={() => `${stats.mem_mb.toFixed(0)} MB (${stats.mem_percent.toFixed(1)}%)`} + /> ) : (
{t("detail.noStats")}
)} + + {/* Save button */} + {csLoaded && ( + + )}
)} @@ -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(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 ( +
+
+ {label} + {formatValue(value)} +
+
{ if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }} + > + {/* Usage fill */} +
+ {/* Threshold handle — only when notifications enabled */} + {showThreshold && ( +
{ e.preventDefault(); setDragging(true); }} + onTouchStart={(e) => { e.preventDefault(); setDragging(true); }} + onMouseEnter={() => setHovering(true)} + onMouseLeave={() => setHovering(false)} + > + {/* Vertical line */} +
+ {/* Drag handle diamond */} +
+ {/* Tooltip */} + {showTooltip && ( +
+ {threshold}% +
+ )} +
+ )} +
+ {/* Label row — only when notifications enabled */} + {showThreshold && ( +
+ {hintLabel} +
+ {threshold}% + {isCustom && ( + + )} + {tagLabel} +
+
+ )} +
+ ); +} + function StatCard({ label, value, extra, color }: { label: string; value: string; extra?: string; color: string }) { return (
diff --git a/src/server/container-settings.ts b/src/server/container-settings.ts new file mode 100644 index 0000000..f071ead --- /dev/null +++ b/src/server/container-settings.ts @@ -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 { + try { + if (fs.existsSync(SETTINGS_FILE)) { + return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")); + } + } catch {} + return {}; +} + +export function saveContainerSettings(settings: Record): void { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); +} diff --git a/src/server/index.ts b/src/server/index.ts index 736abba..ade5ac5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); } } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 668779e..74a8f25 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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[] }