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