From 17e805d0fcc23b0236e2caee9fc7550e3c84c7b2 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Mon, 11 May 2026 00:30:55 +0000 Subject: [PATCH] v0.0.32 --- src/client/App.tsx | 36 ++++++++++++++++++++++++++++--- src/client/i18n.tsx | 4 ++-- src/client/nodes/ServiceNode.tsx | 14 ++++++++++-- src/client/panels/DetailPanel.tsx | 9 ++++++-- src/server/watcher.ts | 19 ++++++++-------- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index e9e8c6e..f6d5402 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -138,6 +138,29 @@ function Dashboard({ token }: { token: string }) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null); const [envFiles, setEnvFiles] = useState>({}); + // Thresholds (per-container settings + global Discord config). Used in + // dashboard ServiceNode to color progress bars amber when exceeded. + const [containerSettings, setContainerSettings] = useState>({}); + const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 }); + const [discordEnabled, setDiscordEnabled] = useState(false); + useEffect(() => { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/container-settings", { headers }) + .then((r) => r.ok ? r.json() : {}) + .then(setContainerSettings) + .catch(() => {}); + fetch("/api/discord-config", { headers }) + .then((r) => r.ok ? r.json() : null) + .then((c: any) => { + if (c) { + setGlobalThresholds({ cpu: c.thresholds?.cpuPercent ?? 50, mem: c.thresholds?.memPercent ?? 60 }); + setDiscordEnabled(!!(c.enabled && c.webhookUrl)); + } + }) + .catch(() => {}); + }, [token]); + // Close filter dropdown on outside click useEffect(() => { const handler = (e: MouseEvent) => { @@ -341,11 +364,17 @@ function Dashboard({ token }: { token: string }) { const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections); - // Mark service nodes as locked when restricted mode is active + // Mark service nodes as locked + inject effective thresholds for progress bar coloring 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 (svc) { + (n.data as any).locked = !canInteract(svc); + const cs = containerSettings[svc.uid]; + const notifsOn = discordEnabled && (cs?.notificationsEnabled !== false); + (n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined; + (n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined; + } } } @@ -429,7 +458,7 @@ function Dashboard({ token }: { token: string }) { return result; }); } - }, [filteredServices, filteredConnections, canInteract]); + }, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]); // Recompute edges + handles on drag end (not every pixel) const recomputeEdges = useCallback((currentNodes: Node[]) => { @@ -786,6 +815,7 @@ function Dashboard({ token }: { token: string }) { envFiles={envFiles} onEnvFileChange={handleEnvFileChange} events={events} + onContainerSettingsChange={(uid, settings) => setContainerSettings((prev) => ({ ...prev, [uid]: settings }))} /> )} diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index eaf8bc9..ca52104 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -140,7 +140,7 @@ const en = { "detail.memoryUsage": "Memory Usage", "detail.memory": "Memory", "detail.noStats": "No stats available", - "detail.cpuHistory": "CPU History", + "detail.cpuHistory": "Usage History", "detail.memoryHistory": "Memory History", "detail.noHistory": "No historical data available", "detail.loadingHistory": "Loading history...", @@ -398,7 +398,7 @@ const es: Record = { "detail.memoryUsage": "Uso de Memoria", "detail.memory": "Memoria", "detail.noStats": "No hay estad\u00edsticas disponibles", - "detail.cpuHistory": "Historial de CPU", + "detail.cpuHistory": "Historial de Consumo", "detail.memoryHistory": "Historial de Memoria", "detail.noHistory": "No hay datos hist\u00f3ricos disponibles", "detail.loadingHistory": "Cargando historial...", diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 72ca7db..fe2c721 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -38,6 +38,8 @@ interface ServiceNodeData { activeHandles?: string[]; highlighted?: boolean; locked?: boolean; + cpuThreshold?: number; + memThreshold?: number; [key: string]: unknown; } @@ -221,13 +223,21 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
d.cpuThreshold + ? "bg-amber-500/80" + : "bg-cyan-500/60" + }`} style={{ width: `${Math.min(nodeStats.cpu, 100)}%` }} />
d.memThreshold + ? "bg-amber-500/80" + : "bg-violet-500/60" + }`} style={{ width: `${Math.min(nodeStats.mem_percent, 100)}%` }} />
diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 73fd79f..a89c451 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -62,9 +62,12 @@ interface DetailPanelProps { envFiles: Record; onEnvFileChange: (composeFile: string, envFile: string | null) => void; events: DockerEvent[]; + /** Called when container settings change (thresholds, notifications toggle). + * Lets the parent (App.tsx) update dashboard ServiceNode threshold coloring live. */ + onContainerSettingsChange?: (uid: string, settings: ContainerSettings) => void; } -export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, initialTab, envFiles, onEnvFileChange, events }: DetailPanelProps) { +export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, initialTab, envFiles, onEnvFileChange, events, onContainerSettingsChange }: DetailPanelProps) { const { t } = useT(); const [initialLogs, setInitialLogs] = useState([]); const [autoScroll, setAutoScroll] = useState(true); @@ -145,11 +148,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked, }); setCsSaved(true); setTimeout(() => setCsSaved(false), 1500); + // Notify parent so dashboard ServiceNode thresholds update live + onContainerSettingsChange?.(service.uid, containerSettings); } catch {} setCsSaving(false); }, 500); return () => clearTimeout(timer); - }, [containerSettings, csLoaded, service.uid, token]); + }, [containerSettings, csLoaded, service.uid, token, onContainerSettingsChange]); // Scroll modal to bottom when opened or when logs arrive useEffect(() => { diff --git a/src/server/watcher.ts b/src/server/watcher.ts index 396af64..e0db45b 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -55,9 +55,11 @@ export function computeMemoryBreakdown(memoryStats: any): { export async function pollStats(services: Service[]): Promise { const running = services.filter((s) => s.state === "running"); - const results: Stats[] = []; - for (const svc of running) { + // Poll all containers in parallel — sequential polling makes the first cycle + // take ~3s × N containers, blocking the dashboard on page load. The Docker + // daemon handles concurrent stats requests fine. + const results = await Promise.all(running.map(async (svc): Promise => { try { const container = docker.getContainer(svc.id); const raw = await Promise.race([ @@ -76,8 +78,6 @@ export async function pollStats(services: Service[]): Promise { ? (cpuDelta / sysDelta) * onlineCpus * 100 : 0; - // If container has a CPU limit, show % relative to its allocation - // cpu_quota: 100000 = 1 core; cpuHost: % of one host core const cpu = svc.cpu_quota > 0 ? (cpuHost * 100000 / svc.cpu_quota) : cpuHost; @@ -86,7 +86,7 @@ export async function pollStats(services: Service[]): Promise { const memLimit = mb.limit || 1; const TO_MB = 1024 * 1024; - results.push({ + return { service: svc.uid, cpu: parseFloat(cpu.toFixed(2)), mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)), @@ -97,13 +97,14 @@ export async function pollStats(services: Service[]): Promise { total_mb: parseFloat((mb.total / TO_MB).toFixed(1)), limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)), }, - }); + }; } catch { - // Container may have stopped between discovery and stats + // Container may have stopped between discovery and stats, or stats timed out + return null; } - } + })); - return results; + return results.filter((r): r is Stats => r !== null); } export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {