From 3962e6c319f5760cb0286d7a3a7ff1560f7ae220 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Sun, 10 May 2026 23:39:12 +0000 Subject: [PATCH] v0.0.30 --- src/client/App.tsx | 4 +- src/client/components/HeaderBar.tsx | 2 +- src/client/components/Sparkline.tsx | 35 +- src/client/components/StatsCard.tsx | 4 +- src/client/components/ThresholdBar.tsx | 14 +- src/client/components/Tooltip.tsx | 140 ++++- src/client/hooks/useDocker.ts | 17 +- src/client/hooks/useEventsLog.ts | 74 +++ src/client/hooks/useStatsHistory.ts | 9 +- src/client/i18n.tsx | 34 +- src/client/pages/MonitoringPage.tsx | 827 +++++++++++++++++++------ src/client/pages/SettingsPage.tsx | 2 +- src/client/panels/DetailPanel.tsx | 26 +- src/server/discord.ts | 76 ++- src/server/events-db.ts | 149 +++++ src/server/index.ts | 53 +- src/server/watcher.test.ts | 111 ++++ src/server/watcher.ts | 43 +- src/shared/types.ts | 37 +- 19 files changed, 1382 insertions(+), 275 deletions(-) create mode 100644 src/client/hooks/useEventsLog.ts create mode 100644 src/server/events-db.ts create mode 100644 src/server/watcher.test.ts diff --git a/src/client/App.tsx b/src/client/App.tsx index 680f8b8..9771051 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -84,7 +84,7 @@ function Dashboard({ token }: { token: string }) { const onPositions = useCallback((pos: Record) => { savedPositions.current = pos; }, []); - const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError } = useDocker(token, statsStore, onPositions); + const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError, eventLogStream, notificationStream } = useDocker(token, statsStore, onPositions); const { config: serverConfig, canInteract } = useServerConfig(token); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -522,7 +522,7 @@ function Dashboard({ token }: { token: string }) { - {activePage === "monitoring" && } + {activePage === "monitoring" && } {activePage === "settings" && } {/* Canvas — inset (only visible on dashboard) */} diff --git a/src/client/components/HeaderBar.tsx b/src/client/components/HeaderBar.tsx index 360a9c4..15f0800 100644 --- a/src/client/components/HeaderBar.tsx +++ b/src/client/components/HeaderBar.tsx @@ -4,7 +4,7 @@ import { LayoutDashboard, Activity, Settings, Bell, Play, Square, RotateCcw, } from "lucide-react"; -import type { Service, DockerEvent } from "../../shared/types"; +import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types"; import { useT } from "../i18n"; export type Page = "dashboard" | "monitoring" | "settings"; diff --git a/src/client/components/Sparkline.tsx b/src/client/components/Sparkline.tsx index 262811a..46027f6 100644 --- a/src/client/components/Sparkline.tsx +++ b/src/client/components/Sparkline.tsx @@ -68,6 +68,28 @@ export function Sparkline({ ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); + // Clip canvas drawing to a rounded rectangle matching the `rounded-lg` + // (8px radius) of the parent wrapper. This is more robust than relying + // on CSS overflow:hidden alone — guarantees no fill/stroke leaks past + // the rounded shape due to subpixel/anti-aliasing artifacts. + const RADIUS = 8; + ctx.beginPath(); + if (typeof (ctx as any).roundRect === "function") { + (ctx as any).roundRect(0, 0, w, h, RADIUS); + } else { + // Fallback for older browsers + ctx.moveTo(RADIUS, 0); + ctx.lineTo(w - RADIUS, 0); + ctx.quadraticCurveTo(w, 0, w, RADIUS); + ctx.lineTo(w, h - RADIUS); + ctx.quadraticCurveTo(w, h, w - RADIUS, h); + ctx.lineTo(RADIUS, h); + ctx.quadraticCurveTo(0, h, 0, h - RADIUS); + ctx.lineTo(0, RADIUS); + ctx.quadraticCurveTo(0, 0, RADIUS, 0); + } + ctx.clip(); + if (data.length === 0) { ctx.fillStyle = "#64748b"; ctx.font = "11px sans-serif"; @@ -79,7 +101,16 @@ export function Sparkline({ const plotW = w - PAD.left - PAD.right; const plotH = h - PAD.top - PAD.bottom; const avg = data.reduce((a, b) => a + b, 0) / data.length; - const max = Math.max(...data, threshold ?? 0, avg, 1); + + // Auto-scale Y to data range with 30% headroom for visual breathing room. + // Floor at 0.1 (not 1) so values like 0.1% don't get pancaked against the bottom. + // Threshold is included in the scale ONLY when data is reasonably close to it + // (≥30% of threshold); otherwise low values would get pancaked at the bottom. + const dataMax = Math.max(...data); + let max = Math.max(dataMax * 1.3, 0.1); + if (threshold !== undefined && threshold > 0 && dataMax >= threshold * 0.3) { + max = Math.max(max, threshold * 1.1); + } const range = max || 1; const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW; @@ -302,7 +333,7 @@ export function Sparkline({ ref={canvasRef} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} - className="cursor-crosshair" + className="cursor-crosshair block" /> {/* Tooltip — below the chart */} diff --git a/src/client/components/StatsCard.tsx b/src/client/components/StatsCard.tsx index 4a84617..083378f 100644 --- a/src/client/components/StatsCard.tsx +++ b/src/client/components/StatsCard.tsx @@ -42,7 +42,7 @@ export function StatsCard({
{/* Left: label + value + limit */}
- {label} + {label} {value} @@ -53,7 +53,7 @@ export function StatsCard({ )}
{/* Center: sparkline */} -
+
{ if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }} > - {/* Usage fill */} -
+ {/* Clip wrapper — ensures the fill always respects the track's rounded shape, + even at very low values (otherwise rounded-full on the inner fill creates + a tiny pill that looks detached from the track edge). */} +
+
+
{/* Threshold handle — only when notifications enabled */} {showThreshold && (
(null); + const btnRef = useRef(null); + const popRef = useRef(null); + + useLayoutEffect(() => { + if (!show || !btnRef.current) return; + + const compute = () => { + const btn = btnRef.current; + const pop = popRef.current; + if (!btn || !pop) return; + const btnRect = btn.getBoundingClientRect(); + const popRect = pop.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + const btnCenterX = btnRect.left + btnRect.width / 2; + const btnCenterY = btnRect.top + btnRect.height / 2; + + let left: number; + let top: number; + let arrowLeft = 0; + let arrowTop = 0; + let actual: "top" | "bottom" | "right" = placement; + + if (placement === "right") { + // Popover to the right of icon, vertically centered + left = btnRect.right + GAP; + top = btnCenterY - popRect.height / 2; + // Flip to left/bottom if no room to the right + if (left + popRect.width + 8 > vw) { + // fallback to bottom + actual = "bottom"; + left = Math.max(8, Math.min(btnCenterX - popRect.width / 2, vw - popRect.width - 8)); + top = btnRect.bottom + GAP; + arrowLeft = btnCenterX - left; + } else { + top = Math.max(8, Math.min(top, vh - popRect.height - 8)); + arrowTop = btnCenterY - top; + } + } else { + // Center horizontally on icon, clamp to viewport + left = btnCenterX - popRect.width / 2; + left = Math.max(8, Math.min(left, vw - popRect.width - 8)); + arrowLeft = btnCenterX - left; + + if (placement === "top") { + const candidateTop = btnRect.top - popRect.height - GAP; + if (candidateTop < 8 && btnRect.bottom + popRect.height + GAP < vh) { + actual = "bottom"; + top = btnRect.bottom + GAP; + } else { + top = candidateTop; + } + } else { + // bottom + const candidateTop = btnRect.bottom + GAP; + if (candidateTop + popRect.height + 8 > vh && btnRect.top - popRect.height - GAP > 0) { + actual = "top"; + top = btnRect.top - popRect.height - GAP; + } else { + top = candidateTop; + } + } + } + + setPos({ left, top, arrowLeft, arrowTop, flippedTo: actual }); + }; + + compute(); + window.addEventListener("scroll", compute, true); + window.addEventListener("resize", compute); + return () => { + window.removeEventListener("scroll", compute, true); + window.removeEventListener("resize", compute); + }; + }, [show, placement]); + return ( - + <> - {show && ( -
+ {show && createPortal( +
{text} -
-
+ {pos && pos.flippedTo === "right" && ( +
+ )} + {pos && pos.flippedTo === "top" && ( +
+ )} + {pos && pos.flippedTo === "bottom" && ( +
+ )} +
, + document.body )} - + ); } diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index dc85156..85a2d0e 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError } from "../../shared/types"; +import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError, EventLogEntry, NotificationLogEntry } from "../../shared/types"; import type { StatsStore } from "./useStatsStore"; import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing"; @@ -10,6 +10,8 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po const [events, setEvents] = useState([]); const [logLines, setLogLines] = useState([]); const [actionErrors, setActionErrors] = useState([]); + const [eventLogStream, setEventLogStream] = useState([]); + const [notificationStream, setNotificationStream] = useState([]); // Processing state: uid → { expected state, start time, min duration before clearing } const processingRef = useRef>(new Map()); const processingIntervalsRef = useRef>>(new Map()); @@ -35,6 +37,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po setServices((prev) => prev.length === 0 ? data.services : prev); setConnections((prev) => prev.length === 0 ? data.connections : prev); if (onPositions) onPositions(data.positions || {}); + // Hydrate stats immediately so charts/cards don't wait for next WS poll + if (Array.isArray(data.stats) && data.stats.length > 0) { + for (const s of data.stats as Stats[]) statsRef.current.set(s.service, s); + if (statsStore) statsStore.update(statsRef.current); + } }) .catch(() => {}); }, [token]); @@ -131,6 +138,12 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po ]); break; } + case "event_log": + setEventLogStream((prev) => [msg.data, ...prev].slice(0, 50)); + break; + case "notification_log": + setNotificationStream((prev) => [msg.data, ...prev].slice(0, 50)); + break; } } catch (err) { console.error("Failed to parse WS message:", err); @@ -241,5 +254,5 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po ]); }, []); - return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError }; + return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError, eventLogStream, notificationStream }; } diff --git a/src/client/hooks/useEventsLog.ts b/src/client/hooks/useEventsLog.ts new file mode 100644 index 0000000..c2e4079 --- /dev/null +++ b/src/client/hooks/useEventsLog.ts @@ -0,0 +1,74 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import type { EventLogEntry, NotificationLogEntry, WSMessage } from "../../shared/types"; + +export function useEventsLog(token: string, limit = 200) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`/api/events?limit=${limit}`, { headers }) + .then((r) => (r.ok ? r.json() : [])) + .then((d: EventLogEntry[]) => { setEvents(d); setLoading(false); }) + .catch(() => { setEvents([]); setLoading(false); }); + }, [token, limit]); + + const prepend = useCallback((entry: EventLogEntry) => { + setEvents((prev) => [entry, ...prev].slice(0, 1000)); + }, []); + + return { events, loading, prepend }; +} + +export function useNotificationsLog(token: string, limit = 100) { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`/api/notifications?limit=${limit}`, { headers }) + .then((r) => (r.ok ? r.json() : [])) + .then((d: NotificationLogEntry[]) => { setNotifications(d); setLoading(false); }) + .catch(() => { setNotifications([]); setLoading(false); }); + }, [token, limit]); + + const prepend = useCallback((entry: NotificationLogEntry) => { + setNotifications((prev) => [entry, ...prev].slice(0, 500)); + }, []); + + return { notifications, loading, prepend }; +} + +/** Hook into the existing WebSocket to receive event_log / notification_log push messages. + * Pass the ws ref from useDocker (or use a separate WS listener). + * Simplest: a tiny dedicated WebSocket that listens for these two message types. */ +export function useEventsNotificationsLive(token: string, onEvent: (e: EventLogEntry) => void, onNotification: (n: NotificationLogEntry) => void) { + const wsRef = useRef(null); + const eventCb = useRef(onEvent); + const notifCb = useRef(onNotification); + eventCb.current = onEvent; + notifCb.current = onNotification; + + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); + wsRef.current = ws; + ws.onopen = () => { + if (token) ws.send(JSON.stringify({ type: "auth", token })); + }; + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) as WSMessage; + if (msg.type === "event_log") eventCb.current(msg.data); + else if (msg.type === "notification_log") notifCb.current(msg.data); + } catch {} + }; + return () => { + try { ws.close(); } catch {} + }; + }, [token]); +} diff --git a/src/client/hooks/useStatsHistory.ts b/src/client/hooks/useStatsHistory.ts index 227986c..835c8de 100644 --- a/src/client/hooks/useStatsHistory.ts +++ b/src/client/hooks/useStatsHistory.ts @@ -1,11 +1,16 @@ import { useState, useEffect } from "react"; import type { StatsHistoryPoint, StatsRange } from "../../shared/types"; -export function useStatsHistory(uid: string, range: StatsRange, token: string) { +export function useStatsHistory(uid: string, range: StatsRange, token: string, enabled = true) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { + if (!enabled) { + setData([]); + setLoading(false); + return; + } setLoading(true); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -20,7 +25,7 @@ export function useStatsHistory(uid: string, range: StatsRange, token: string) { setData([]); setLoading(false); }); - }, [uid, range, token]); + }, [uid, range, token, enabled]); return { data, loading }; } diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index 5334aed..197d3bb 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -112,6 +112,12 @@ const en = { "detail.unlimited": "Unlimited", "detail.threshold": "Threshold", "detail.thresholdTooltip": "Alert threshold — sends a Discord notification when exceeded", + "detail.memBreakdown": "Memory breakdown", + "detail.memAnon": "Anon (process memory)", + "detail.memCache": "Page cache (reclaimable)", + "detail.memTotal": "Total reserved (incl. cache)", + "detail.memLimit": "Limit", + "detail.memTooltipHint": "ContainerFlow shows real usage (anon). Cache is reclaimable by the kernel under pressure — same logic as `docker stats` CLI.", "detail.limit": "Limit", "detail.limitTooltip": "Maximum resource allocated to this container in Docker", "detail.avg": "Avg", @@ -183,12 +189,21 @@ const en = { "logPanel.noLogs": "No logs available", // Monitoring page - "monitoring.title": "Event History", + "monitoring.title": "Monitoring", + "monitoring.titleTooltip": "CPU/RAM history, events and notifications of your containers.", "monitoring.subtitle": "Docker container events in real-time", "monitoring.noEvents": "No events yet. Events will appear here as containers start, stop, or restart.", "monitoring.alertRules": "Alert Rules", "monitoring.alertRulesDesc": "Configure alerting rules for container events \u2014 coming soon", "monitoring.statsHistory": "Resource Usage History", + "monitoring.totals": "Totals (all filtered containers)", + "monitoring.totalCpu": "CPU (T)", + "monitoring.totalMem": "MEM (T)", + "monitoring.clearFilters": "Clear filters", + "monitoring.tabHistory": "History", + "monitoring.tabEvents": "Events", + "monitoring.tabNotifications": "Notifications", + "monitoring.noNotifications": "No notifications yet. They will appear here when containers change state, hit resource thresholds, or you run UI actions (mirrors Discord webhooks).", "monitoring.loadingHistory": "Loading historical data...", "monitoring.noHistoryData": "No historical data available yet", "monitoring.selectFilter": "Select a service or load all to view history", @@ -353,6 +368,12 @@ const es: Record = { "detail.unlimited": "Sin l\u00edmite", "detail.threshold": "Umbral", "detail.thresholdTooltip": "Umbral de alerta \u2014 env\u00eda una notificaci\u00f3n a Discord cuando se supera", + "detail.memBreakdown": "Desglose de memoria", + "detail.memAnon": "Anon (memoria de procesos)", + "detail.memCache": "Page cache (liberable)", + "detail.memTotal": "Total reservado (incl. cache)", + "detail.memLimit": "L\u00edmite", + "detail.memTooltipHint": "ContainerFlow muestra uso real (anon). El cache es liberable por el kernel bajo presi\u00f3n \u2014 misma l\u00f3gica que `docker stats` CLI.", "detail.limit": "L\u00edmite", "detail.limitTooltip": "Recurso m\u00e1ximo asignado a este contenedor en Docker", "detail.avg": "Prom", @@ -424,12 +445,21 @@ const es: Record = { "logPanel.noLogs": "No hay logs disponibles", // Monitoring page - "monitoring.title": "Historial de Eventos", + "monitoring.title": "Monitoreo", + "monitoring.titleTooltip": "Historial de consumo (CPU/RAM), eventos y notificaciones de tus containers.", "monitoring.subtitle": "Eventos de contenedores Docker en tiempo real", "monitoring.noEvents": "Sin eventos a\u00fan. Los eventos aparecer\u00e1n aqu\u00ed cuando los contenedores inicien, se detengan o reinicien.", "monitoring.alertRules": "Reglas de Alerta", "monitoring.alertRulesDesc": "Configurar reglas de alerta para eventos de contenedores \u2014 pr\u00f3ximamente", "monitoring.statsHistory": "Historial de Uso de Recursos", + "monitoring.totals": "Totales (todos los containers filtrados)", + "monitoring.totalCpu": "CPU (T)", + "monitoring.totalMem": "MEM (T)", + "monitoring.clearFilters": "Limpiar filtros", + "monitoring.tabHistory": "Historial", + "monitoring.tabEvents": "Eventos", + "monitoring.tabNotifications": "Notificaciones", + "monitoring.noNotifications": "Sin notificaciones todavía. Aparecerán aquí cuando los containers cambien de estado, superen umbrales de recursos o ejecutes acciones (espejo de Discord).", "monitoring.loadingHistory": "Cargando datos hist\u00f3ricos...", "monitoring.noHistoryData": "No hay datos hist\u00f3ricos disponibles a\u00fan", "monitoring.selectFilter": "Selecciona un servicio o carga todos para ver el historial", diff --git a/src/client/pages/MonitoringPage.tsx b/src/client/pages/MonitoringPage.tsx index 85b5756..ca77588 100644 --- a/src/client/pages/MonitoringPage.tsx +++ b/src/client/pages/MonitoringPage.tsx @@ -1,10 +1,11 @@ import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { Activity, Play, Square, RotateCcw, AlertTriangle, BarChart3, ChevronDown, Check, Maximize2, Minimize2, Settings } from "lucide-react"; -import type { DockerEvent, StatsRange, Service, ContainerSettings, DiscordConfig } from "../../shared/types"; +import type { DockerEvent, StatsRange, Service, ContainerSettings, DiscordConfig, StatsHistoryPoint, EventLogEntry, NotificationLogEntry } from "../../shared/types"; import { useT } from "../i18n"; -import { useAllStatsHistory } from "../hooks/useStatsHistory"; +import { useAllStatsHistory, useStatsHistory } from "../hooks/useStatsHistory"; import { StatsCard } from "../components/StatsCard"; import { ThresholdBar } from "../components/ThresholdBar"; +import { Tooltip } from "../components/Tooltip"; import { guessIcon } from "../nodes/ServiceNode"; function timeAgo(ts: number): string { @@ -81,11 +82,14 @@ interface MonitoringPageProps { events: DockerEvent[]; token: string; services: Service[]; + eventLogStream: EventLogEntry[]; + notificationStream: NotificationLogEntry[]; } -export function MonitoringPage({ events, token, services }: MonitoringPageProps) { +export function MonitoringPage({ events, token, services, eventLogStream, notificationStream }: MonitoringPageProps) { const { t } = useT(); const [statsRange, setStatsRange] = useState("1h"); + const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history"); const [selectedProjects, setSelectedProjects] = useState>(new Set()); const [selectedServices, setSelectedServices] = useState>(new Set()); const [expandedService, setExpandedService] = useState(null); @@ -247,21 +251,34 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps) : `${selectedServices.size} ${t("footer.containers")}`; return ( -
+
{/* Header */}
-
+

{t("monitoring.title")}

-

{t("monitoring.subtitle")}

+
{/* Filters */} {allServiceNames.length > 0 && (
+ {/* Clear filters — only when something is active */} + {(selectedProjects.size > 0 || selectedServices.size > 0) && ( + + )} {/* Project filter */} {allProjects.length > 1 && ( + ); + })} +
+ {/* Resource Usage History */} + {activeTab === "history" && (
@@ -388,205 +441,575 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps) {t("monitoring.noHistoryData")}
) : ( -
- {historyServiceNames.map((svc) => { - const points = filteredHistory[svc] || []; - const shortName = svc.split("/").pop() || svc; - const cs = containerSettings[svc]; - const svcNotifs = discordEnabled && (cs?.notificationsEnabled !== false); - const cpuThreshold = svcNotifs ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined; - const memThreshold = svcNotifs ? (cs?.memThreshold ?? globalThresholds.mem) : undefined; - const latest = points.length > 0 ? points[points.length - 1] : null; - const isExpanded = expandedService === svc; - const chartHeight = isExpanded ? 120 : 56; - const svcData = services.find((s) => s.uid === svc); - const cpuLimit = svcData && svcData.cpu_quota > 0 ? `${(svcData.cpu_quota / 1000).toFixed(0)}%` : undefined; - const memLimit = svcData && svcData.memory_limit > 0 ? `${(svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined; - return ( -
-
- -
- {shortName} - {svc.includes("/") && ( - {svc.split("/")[0]} - )} -
-
- {discordEnabled && ( - - )} - -
- {/* Inline config panel */} - {configService === svc && (() => { - const settings = cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; - const cpuTh = settings.cpuThreshold ?? globalThresholds.cpu; - const memTh = settings.memThreshold ?? globalThresholds.mem; - const cpuVal = latest?.cpu ?? 0; - const memVal = latest?.mem_percent ?? 0; - const memMb = latest?.mem_mb ?? 0; - return ( -
- {/* Notifications toggle */} -
- {t("detail.notifications")} - -
- {settings.notificationsEnabled && ( - <> - { - setContainerSettings((prev) => { - const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; - const updated = { ...cur, cpuThreshold: v }; - debouncedSave(svc, updated); - return { ...prev, [svc]: updated }; - }); - }} - onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), cpuThreshold: null })} - formatValue={(v) => `${v.toFixed(1)}%`} - baseColor="cyan" - /> - { - setContainerSettings((prev) => { - const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; - const updated = { ...cur, memThreshold: v }; - debouncedSave(svc, updated); - return { ...prev, [svc]: updated }; - }); - }} - onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), memThreshold: null })} - formatValue={() => `${memMb.toFixed(0)} MB (${memVal.toFixed(1)}%)`} - formatThreshold={svcData && svcData.memory_limit > 0 ? (th) => `${((th / 100) * svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined} - baseColor="purple" - /> - - )} -
- ); - })()} -
- p.cpu)} - timestamps={points.map((p) => p.timestamp)} - hoverValues={points.map((p) => p.cpu)} - color="#06b6d4" - threshold={cpuThreshold} - sparklineHeight={chartHeight} - formatHoverValue={(v) => `${v.toFixed(2)}%`} - showAverage - formatAverage={(v) => `${v.toFixed(2)}%`} - avgLabel={t("detail.avg")} - /> - p.mem_percent)} - timestamps={points.map((p) => p.timestamp)} - hoverValues={points.map((p) => p.mem_mb)} - color="#a78bfa" - threshold={memThreshold} - sparklineHeight={chartHeight} - formatHoverValue={(v) => `${v.toFixed(0)} MB`} - showAverage - formatAverage={(v) => `${v.toFixed(0)} MB`} - avgLabel={t("detail.avg")} - /> -
-
- ); - })} -
- )} -
- - {/* Events list */} -
- {filteredEvents.length === 0 ? ( -
- -

{t("monitoring.noEvents")}

-
- ) : ( -
- {filteredEvents.map((ev, i) => ( -
-
- {eventIcon(ev.action)} -
- -
-
- - {ev.service.split("/").pop() || ev.service} - - {ev.service.includes("/") && ( - - {ev.service.split("/")[0]} - - )} -
- {ev.action} -
- {timeAgo(ev.time)} -
+ <> + 0 + ? (selectedServices.size === 1 + ? ([...selectedServices][0].split("/").pop() || [...selectedServices][0]) + : `${selectedServices.size} ${t("footer.containers")}`) + : selectedProjects.size === 1 + ? [...selectedProjects][0] + : selectedProjects.size === allProjects.length + ? t("monitoring.allProjects") + : `${selectedProjects.size} ${t("filter.projects").toLowerCase()}` + } + /> +
+ {historyServiceNames.map((svc) => ( + ))} -
+
+ )}
+ )} - {/* Alert Rules placeholder */} -
- -

{t("monitoring.alertRules")}

-

{t("monitoring.alertRulesDesc")}

-
+ {/* Events log (persistent, from SQLite) */} + {activeTab === "events" && ( + + )} + + {/* Notifications log (persistent, mirrors Discord) */} + {activeTab === "notifications" && ( + + )}
); } + +// ────────────────────────────────────────────────────────────────────────────── +// Aggregated totals card — sum of CPU% and memory across all filtered services +// ────────────────────────────────────────────────────────────────────────────── + +function MonitoringTotalsCard({ + historyByService, + services, + filteredUids, + title, +}: { + historyByService: Record; + services: Service[]; + filteredUids: Set; + title: string; +}) { + const { t } = useT(); + + // Aggregate per-timestamp totals across all filtered services. + // CPU: sum of per-container CPU% (can exceed 100% on multi-core hosts — informative). + // MEM: sum of per-container mem_mb (absolute memory usage). + const totals = useMemo(() => { + const buckets = new Map(); + for (const [svc, points] of Object.entries(historyByService)) { + if (!filteredUids.has(svc)) continue; + for (const p of points) { + const existing = buckets.get(p.timestamp); + if (existing) { + existing.cpu += p.cpu; + existing.mem_mb += p.mem_mb; + } else { + buckets.set(p.timestamp, { cpu: p.cpu, mem_mb: p.mem_mb }); + } + } + } + return [...buckets.entries()] + .sort(([a], [b]) => a - b) + .map(([ts, v]) => ({ timestamp: ts, cpu: v.cpu, mem_mb: v.mem_mb })); + }, [historyByService, filteredUids]); + + // Sum container memory limits for the "X / Y" display + const totalMemLimitMb = useMemo(() => { + let sum = 0; + for (const s of services) { + if (!filteredUids.has(s.uid)) continue; + if (s.memory_limit > 0) sum += s.memory_limit / 1024 / 1024; + } + return sum; + }, [services, filteredUids]); + + // Hide totals card if it would be redundant with the single service card below + if (totals.length === 0) return null; + if (filteredUids.size <= 1) return null; + + const latest = totals[totals.length - 1]; + const cpuValue = `${latest.cpu.toFixed(1)}%`; + const memValue = latest.mem_mb >= 1024 + ? `${(latest.mem_mb / 1024).toFixed(2)} GB` + : `${latest.mem_mb.toFixed(0)} MB`; + const memLimit = totalMemLimitMb > 0 + ? (totalMemLimitMb >= 1024 ? `${(totalMemLimitMb / 1024).toFixed(1)} GB` : `${totalMemLimitMb.toFixed(0)} MB`) + : undefined; + const formatMem = (v: number) => v >= 1024 ? `${(v / 1024).toFixed(2)} GB` : `${v.toFixed(0)} MB`; + const containerCount = filteredUids.size; + + return ( +
+
+ {title} + · {containerCount} {t("footer.containers")} +
+
+ p.cpu)} + timestamps={totals.map((p) => p.timestamp)} + hoverValues={totals.map((p) => p.cpu)} + color="#10b981" + sparklineHeight={56} + formatHoverValue={(v) => `${v.toFixed(1)}%`} + showAverage + formatAverage={(v) => `${v.toFixed(1)}%`} + avgLabel={t("detail.avg")} + /> + p.mem_mb)} + timestamps={totals.map((p) => p.timestamp)} + hoverValues={totals.map((p) => p.mem_mb)} + color="#10b981" + sparklineHeight={56} + formatHoverValue={formatMem} + showAverage + formatAverage={formatMem} + avgLabel={t("detail.avg")} + /> +
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Per-service card with its own range override +// ────────────────────────────────────────────────────────────────────────────── + +interface MonitoringServiceCardProps { + svc: string; + services: Service[]; + containerSettings: Record; + setContainerSettings: React.Dispatch>>; + globalThresholds: { cpu: number; mem: number }; + discordEnabled: boolean; + configService: string | null; + setConfigService: React.Dispatch>; + expandedService: string | null; + setExpandedService: React.Dispatch>; + saveContainerSetting: (uid: string, settings: ContainerSettings) => Promise; + debouncedSave: (uid: string, settings: ContainerSettings) => void; + globalRange: StatsRange; + fallbackData: StatsHistoryPoint[]; + token: string; +} + +function MonitoringServiceCard({ + svc, + services, + containerSettings, + setContainerSettings, + globalThresholds, + discordEnabled, + configService, + setConfigService, + expandedService, + setExpandedService, + saveContainerSetting, + debouncedSave, + globalRange, + fallbackData, + token, +}: MonitoringServiceCardProps) { + const { t } = useT(); + const [localRange, setLocalRange] = useState(null); + + // When the global range changes, reset this card's local override so it + // follows the new global. User clicking the global filter expresses intent + // "show all at this range". + useEffect(() => { + setLocalRange(null); + }, [globalRange]); + + const hasOverride = localRange !== null; + const effectiveRange = localRange ?? globalRange; + // Only fetch when overridden — otherwise the page's useAllStatsHistory covers it. + const { data: ownData, loading: ownLoading } = useStatsHistory(svc, effectiveRange, token, hasOverride); + + const points = hasOverride ? ownData : fallbackData; + const loading = hasOverride ? ownLoading : false; + + const shortName = svc.split("/").pop() || svc; + const cs = containerSettings[svc]; + const svcNotifs = discordEnabled && (cs?.notificationsEnabled !== false); + const cpuThreshold = svcNotifs ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined; + const memThreshold = svcNotifs ? (cs?.memThreshold ?? globalThresholds.mem) : undefined; + const latest = points.length > 0 ? points[points.length - 1] : null; + const isExpanded = expandedService === svc; + const chartHeight = isExpanded ? 120 : 56; + const svcData = services.find((s) => s.uid === svc); + const cpuLimit = svcData && svcData.cpu_quota > 0 ? `${(svcData.cpu_quota / 1000).toFixed(0)}%` : undefined; + const memLimit = svcData && svcData.memory_limit > 0 ? `${(svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined; + + return ( +
+
+ +
+ {shortName} + {svc.includes("/") && ( + {svc.split("/")[0]} + )} +
+
+ {/* Per-card range buttons */} +
+ {(["1h", "6h", "24h", "7d"] as StatsRange[]).map((r) => { + const active = effectiveRange === r; + const isOverrideHighlight = active && hasOverride; + return ( + + ); + })} +
+ {discordEnabled && ( + + )} + +
+ {/* Inline config panel */} + {configService === svc && (() => { + const settings = cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; + const cpuTh = settings.cpuThreshold ?? globalThresholds.cpu; + const memTh = settings.memThreshold ?? globalThresholds.mem; + const cpuVal = latest?.cpu ?? 0; + const memVal = latest?.mem_percent ?? 0; + const memMb = latest?.mem_mb ?? 0; + return ( +
+
+ {t("detail.notifications")} + +
+ {settings.notificationsEnabled && ( + <> + { + setContainerSettings((prev) => { + const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; + const updated = { ...cur, cpuThreshold: v }; + debouncedSave(svc, updated); + return { ...prev, [svc]: updated }; + }); + }} + onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), cpuThreshold: null })} + formatValue={(v) => `${v.toFixed(1)}%`} + baseColor="cyan" + /> + { + setContainerSettings((prev) => { + const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; + const updated = { ...cur, memThreshold: v }; + debouncedSave(svc, updated); + return { ...prev, [svc]: updated }; + }); + }} + onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), memThreshold: null })} + formatValue={() => `${memMb.toFixed(0)} MB (${memVal.toFixed(1)}%)`} + formatThreshold={svcData && svcData.memory_limit > 0 ? (th) => `${((th / 100) * svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined} + baseColor="purple" + /> + + )} +
+ ); + })()} + {loading ? ( +
{t("monitoring.loadingHistory")}
+ ) : ( +
+ p.cpu)} + timestamps={points.map((p) => p.timestamp)} + hoverValues={points.map((p) => p.cpu)} + color="#06b6d4" + threshold={cpuThreshold} + sparklineHeight={chartHeight} + formatHoverValue={(v) => `${v.toFixed(2)}%`} + showAverage + formatAverage={(v) => `${v.toFixed(2)}%`} + avgLabel={t("detail.avg")} + /> + p.mem_percent)} + timestamps={points.map((p) => p.timestamp)} + hoverValues={points.map((p) => p.mem_mb)} + color="#a78bfa" + threshold={memThreshold} + sparklineHeight={chartHeight} + formatHoverValue={(v) => `${v.toFixed(0)} MB`} + showAverage + formatAverage={(v) => `${v.toFixed(0)} MB`} + avgLabel={t("detail.avg")} + /> +
+ )} +
+ ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Events log tab — Docker events + UI actions, persistent in SQLite +// ────────────────────────────────────────────────────────────────────────────── + +function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter }: { token: string; services: Service[]; liveStream: EventLogEntry[]; filteredUids: Set; hasActiveFilter: boolean }) { + const { t } = useT(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/events?limit=200", { headers }) + .then((r) => r.ok ? r.json() : []) + .then((data: EventLogEntry[]) => { setEvents(data); setLoading(false); }) + .catch(() => { setEvents([]); setLoading(false); }); + }, [token]); + + // Merge live stream into events, dedupe by id, then apply monitoring filter + const allEvents = useMemo(() => { + const seen = new Set(); + const merged: EventLogEntry[] = []; + for (const e of [...liveStream, ...events]) { + if (seen.has(e.id)) continue; + seen.add(e.id); + if (hasActiveFilter && !filteredUids.has(e.service)) continue; + merged.push(e); + } + return merged; + }, [events, liveStream, filteredUids, hasActiveFilter]); + + if (loading) { + return
{t("monitoring.loadingHistory")}
; + } + + if (allEvents.length === 0) { + return ( +
+ +

{t("monitoring.noEvents")}

+
+ ); + } + + return ( +
+
+ {allEvents.map((ev) => ( +
+
+ {eventIcon(ev.action)} +
+ +
+
+ + {ev.service.split("/").pop() || ev.service} + + {ev.service.includes("/") && ( + + {ev.service.split("/")[0]} + + )} + + {ev.source} + +
+ {ev.action} + {ev.error_msg && ( +
{ev.error_msg}
+ )} +
+ {timeAgo(ev.timestamp)} +
+ ))} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Notifications log tab — mirrors Discord webhooks, persistent in SQLite +// ────────────────────────────────────────────────────────────────────────────── + +function levelStyles(level: NotificationLogEntry["level"]) { + switch (level) { + case "error": return { ring: "border-red-500/40", iconBg: "bg-red-500/15", iconColor: "text-red-400", titleColor: "text-red-300" }; + case "warning": return { ring: "border-amber-500/40", iconBg: "bg-amber-500/15", iconColor: "text-amber-400", titleColor: "text-amber-300" }; + case "info": return { ring: "border-slate-700/40", iconBg: "bg-slate-700/60", iconColor: "text-slate-400", titleColor: "text-slate-200" }; + } +} + +function NotificationsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter }: { token: string; services: Service[]; liveStream: NotificationLogEntry[]; filteredUids: Set; hasActiveFilter: boolean }) { + const { t } = useT(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/notifications?limit=100", { headers }) + .then((r) => r.ok ? r.json() : []) + .then((data: NotificationLogEntry[]) => { setNotifications(data); setLoading(false); }) + .catch(() => { setNotifications([]); setLoading(false); }); + }, [token]); + + const allNotifs = useMemo(() => { + const seen = new Set(); + const merged: NotificationLogEntry[] = []; + for (const n of [...liveStream, ...notifications]) { + if (seen.has(n.id)) continue; + seen.add(n.id); + if (hasActiveFilter && !filteredUids.has(n.service)) continue; + merged.push(n); + } + return merged; + }, [notifications, liveStream, filteredUids, hasActiveFilter]); + + if (loading) { + return
{t("monitoring.loadingHistory")}
; + } + + if (allNotifs.length === 0) { + return ( +
+ +

{t("monitoring.noNotifications")}

+
+ ); + } + + return ( +
+ {allNotifs.map((n) => { + const s = levelStyles(n.level); + return ( +
+
+
+ +
+
+
+ {n.title} + {n.type} + + {timeAgo(n.timestamp)} +
+
+ + {n.service.split("/").pop() || n.service} + {n.service.includes("/") && · {n.service.split("/")[0]}} +
+
{n.message}
+
+
+
+ ); + })} +
+ ); +} diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index ac16db7..dfbb3ce 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -102,7 +102,7 @@ export function SettingsPage({ projects, servicesCount, token }: SettingsPagePro }; return ( -
+
{/* Header */}
diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index e00758d..c9d3a2a 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -965,6 +965,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked, limitLabel={t("detail.limit")} thresholdTooltip={t("detail.thresholdTooltip")} limitTooltip={t("detail.limitTooltip")} + valueTooltip={stats.mem_breakdown ? formatMemTooltip(stats.mem_breakdown, t) : undefined} />
@@ -1308,14 +1309,17 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono? -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 }) { +function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel, limitLabel, thresholdTooltip, limitTooltip, valueTooltip }: { label: string; value: string; extra?: string; color: string; limit?: string; threshold?: string; thresholdLabel?: string; limitLabel?: string; thresholdTooltip?: string; limitTooltip?: string; valueTooltip?: string }) { return (
- {label} +
+ {label} + {valueTooltip && } +
-
+
{value} - {extra && {extra}} + {extra && {extra}}
{(threshold || limit) && (
@@ -1340,6 +1344,20 @@ function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel ); } +function formatMemTooltip(b: NonNullable, t: (k: any) => string): string { + const fmt = (mb: number) => mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(0)} MB`; + return [ + `${t("detail.memBreakdown")}:`, + "", + ` ${t("detail.memAnon")}: ${fmt(b.anon_mb)}`, + ` ${t("detail.memCache")}: ${fmt(b.cache_mb)}`, + ` ${t("detail.memTotal")}: ${fmt(b.total_mb)}`, + ` ${t("detail.memLimit")}: ${fmt(b.limit_mb)}`, + "", + t("detail.memTooltipHint"), + ].join("\n"); +} + function formatTimestamp(ts: string): string { try { const d = new Date(ts); diff --git a/src/server/discord.ts b/src/server/discord.ts index 0ce55d4..7e2102a 100644 --- a/src/server/discord.ts +++ b/src/server/discord.ts @@ -1,6 +1,13 @@ import fs from "fs"; import path from "path"; import type { DiscordConfig } from "../shared/types"; +import { insertNotification, type NotificationLogEntry } from "./events-db"; + +// External listener (set by index.ts) so we can broadcast new notifications via WS +let onNotificationLogged: ((entry: NotificationLogEntry) => void) | null = null; +export function setNotificationListener(fn: typeof onNotificationLogged) { + onNotificationLogged = fn; +} const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data"); const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json"); @@ -70,7 +77,7 @@ const downServices = new Map(); // service → timestamp when it * for services that are still down after the cooldown period. */ export function checkDownServices(config: DiscordConfig): void { - if (!config.enabled || !config.events.containerStateChanges) return; + if (!config.events.containerStateChanges) return; const now = Date.now(); for (const [service, downSince] of downServices) { // Skip services that are still in debounce window (might be restarting) @@ -79,19 +86,12 @@ export function checkDownServices(config: DiscordConfig): void { // Don't send "Still Down" for less than 1 minute if (downMinutes < 1) continue; const cooldownKey = `down:${service}`; - if (!isOnCooldown(cooldownKey, config.downReminderMinutes)) { - setCooldown(cooldownKey); - queueWebhook(config.webhookUrl, { - username: "ContainerFlow", - embeds: [{ - title: "Container Still Down", - color: 0xef4444, - description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``, - footer: { text: "ContainerFlow" }, - timestamp: new Date().toISOString(), - }], - }); - } + sendEmbed(config, { + title: "Container Still Down", + color: 0xef4444, + description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey, { type: "state_change", service }); } } @@ -138,10 +138,40 @@ function queueWebhook(url: string, body: any): Promise { }); } -function sendEmbed(config: DiscordConfig, embed: any, cooldownKey?: string): void { - if (!config.enabled || !config.webhookUrl) return; +/** Map embed color to notification level for in-app log */ +function colorToLevel(color: number): NotificationLogEntry["level"] { + if (color === 0xef4444) return "error"; // red + if (color === 0xf59e0b) return "warning"; // amber + if (color === 0x22c55e) return "info"; // green + if (color === 0x3b82f6) return "info"; // blue + return "info"; +} + +function sendEmbed( + config: DiscordConfig, + embed: any, + cooldownKey?: string, + meta?: { type: NotificationLogEntry["type"]; service: string }, +): void { if (cooldownKey && isOnCooldown(cooldownKey, config.cooldownMinutes)) return; if (cooldownKey) setCooldown(cooldownKey); + + // Always log to in-app notifications (regardless of Discord webhook config) + if (meta) { + try { + const entry = insertNotification( + meta.type, + meta.service, + colorToLevel(embed.color), + embed.title || "Notification", + embed.description || "", + ); + if (entry && onNotificationLogged) onNotificationLogged(entry); + } catch {} + } + + // Send to Discord only if enabled + configured + if (!config.enabled || !config.webhookUrl) return; queueWebhook(config.webhookUrl, { username: "ContainerFlow", embeds: [{ ...embed, timestamp: new Date().toISOString() }], @@ -175,7 +205,7 @@ function flushPendingDown(service: string): void { color: 0xef4444, description: `**${service}**\n\nAction: \`${action}\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "state_change", service }); } function cancelPendingDown(service: string): void { @@ -218,7 +248,7 @@ export function notifyStateChange(service: string, action: string, config: Disco color: 0xf59e0b, description: `**${service}**\n\nAction: \`redeployed\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "state_change", service }); } else { // Fresh start (no preceding stop/die) const cooldownKey = `state:start:${service}`; @@ -227,7 +257,7 @@ export function notifyStateChange(service: string, action: string, config: Disco color: 0x22c55e, description: `**${service}**\n\nAction: \`start\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "state_change", service }); } return; } @@ -247,7 +277,7 @@ export function notifyStateChange(service: string, action: string, config: Disco color: colors[action] || 0x94a3b8, description: `**${service}**\n\nAction: \`${action}\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "state_change", service }); } export function notifyResourceAlert(service: string, resource: "cpu" | "memory", value: number, threshold: number, config: DiscordConfig): void { @@ -259,7 +289,7 @@ export function notifyResourceAlert(service: string, resource: "cpu" | "memory", color, description: `**${service}**\n\nCurrent: \`${value.toFixed(1)}%\`\nThreshold: \`${threshold}%\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "resource_alert", service }); } export function notifyUIAction(service: string, action: string, config: DiscordConfig): void { @@ -270,7 +300,7 @@ export function notifyUIAction(service: string, action: string, config: DiscordC color: 0x3b82f6, description: `**${service}**\n\nAction: \`${action}\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "ui_action", service }); } export function notifyActionError(service: string, action: string, error: string, config: DiscordConfig): void { @@ -282,7 +312,7 @@ export function notifyActionError(service: string, action: string, error: string color: 0xef4444, description: `**${service}**\n\n\`\`\`\n${truncated}\n\`\`\``, footer: { text: "ContainerFlow" }, - }, cooldownKey); + }, cooldownKey, { type: "action_error", service }); } export async function testWebhook(webhookUrl: string): Promise<{ ok: boolean; error?: string }> { diff --git a/src/server/events-db.ts b/src/server/events-db.ts new file mode 100644 index 0000000..19b5a64 --- /dev/null +++ b/src/server/events-db.ts @@ -0,0 +1,149 @@ +import { Database } from "bun:sqlite"; +import path from "path"; + +const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data"); +const DB_PATH = path.join(DATA_DIR, ".dockerflow-events.db"); + +const MAX_EVENTS = 1000; +const MAX_NOTIFICATIONS = 500; + +let db: Database; + +export interface EventLogEntry { + id: number; + timestamp: number; + service: string; + action: string; + source: "docker" | "ui"; + error_msg: string | null; +} + +export interface NotificationLogEntry { + id: number; + timestamp: number; + type: "state_change" | "resource_alert" | "ui_action" | "action_error"; + service: string; + level: "info" | "warning" | "error"; + title: string; + message: string; +} + +export function initEventsDB() { + db = new Database(DB_PATH); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA synchronous = NORMAL"); + db.exec(` + CREATE TABLE IF NOT EXISTS events_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + service TEXT NOT NULL, + action TEXT NOT NULL, + source TEXT NOT NULL, + error_msg TEXT + ) + `); + db.exec("CREATE INDEX IF NOT EXISTS idx_events_time ON events_log (timestamp DESC)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_events_service ON events_log (service, timestamp DESC)"); + db.exec(` + CREATE TABLE IF NOT EXISTS notifications_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + type TEXT NOT NULL, + service TEXT NOT NULL, + level TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL + ) + `); + db.exec("CREATE INDEX IF NOT EXISTS idx_notif_time ON notifications_log (timestamp DESC)"); + + // Initial prune + schedule periodic + pruneOld(); + setInterval(pruneOld, 60 * 60_000); // hourly +} + +export function insertEvent(service: string, action: string, source: "docker" | "ui", errorMsg?: string): EventLogEntry | null { + if (!db) return null; + const ts = Math.floor(Date.now() / 1000); + const result = db.prepare( + "INSERT INTO events_log (timestamp, service, action, source, error_msg) VALUES (?, ?, ?, ?, ?)" + ).run(ts, service, action, source, errorMsg || null); + return { + id: Number(result.lastInsertRowid), + timestamp: ts, + service, + action, + source, + error_msg: errorMsg || null, + }; +} + +export function insertNotification( + type: NotificationLogEntry["type"], + service: string, + level: NotificationLogEntry["level"], + title: string, + message: string, +): NotificationLogEntry | null { + if (!db) return null; + const ts = Math.floor(Date.now() / 1000); + const result = db.prepare( + "INSERT INTO notifications_log (timestamp, type, service, level, title, message) VALUES (?, ?, ?, ?, ?, ?)" + ).run(ts, type, service, level, title, message); + return { + id: Number(result.lastInsertRowid), + timestamp: ts, + type, + service, + level, + title, + message, + }; +} + +export function getEvents(opts: { limit?: number; since?: number; service?: string; action?: string } = {}): EventLogEntry[] { + if (!db) return []; + const limit = Math.min(opts.limit ?? 200, 1000); + const where: string[] = []; + const args: any[] = []; + if (opts.since !== undefined) { where.push("timestamp >= ?"); args.push(opts.since); } + if (opts.service) { where.push("service = ?"); args.push(opts.service); } + if (opts.action) { where.push("action = ?"); args.push(opts.action); } + const whereSQL = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = db.prepare( + `SELECT * FROM events_log ${whereSQL} ORDER BY timestamp DESC, id DESC LIMIT ?` + ).all(...args, limit) as EventLogEntry[]; + return rows; +} + +export function getNotifications(opts: { limit?: number; since?: number; type?: string; level?: string } = {}): NotificationLogEntry[] { + if (!db) return []; + const limit = Math.min(opts.limit ?? 100, 500); + const where: string[] = []; + const args: any[] = []; + if (opts.since !== undefined) { where.push("timestamp >= ?"); args.push(opts.since); } + if (opts.type) { where.push("type = ?"); args.push(opts.type); } + if (opts.level) { where.push("level = ?"); args.push(opts.level); } + const whereSQL = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = db.prepare( + `SELECT * FROM notifications_log ${whereSQL} ORDER BY timestamp DESC, id DESC LIMIT ?` + ).all(...args, limit) as NotificationLogEntry[]; + return rows; +} + +/** Trim oldest entries beyond the retention cap. Called hourly + on startup. */ +export function pruneOld() { + if (!db) return; + try { + db.prepare( + `DELETE FROM events_log WHERE id IN ( + SELECT id FROM events_log ORDER BY timestamp DESC, id DESC LIMIT -1 OFFSET ? + )` + ).run(MAX_EVENTS); + db.prepare( + `DELETE FROM notifications_log WHERE id IN ( + SELECT id FROM notifications_log ORDER BY timestamp DESC, id DESC LIMIT -1 OFFSET ? + )` + ).run(MAX_NOTIFICATIONS); + } catch {} +} diff --git a/src/server/index.ts b/src/server/index.ts index 3821270..f25a13d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,10 +6,11 @@ import path from "path"; 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 { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord"; import { loadContainerSettings, saveContainerSettings } from "./container-settings"; import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db"; -import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; +import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db"; +import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; /** Directory for persistent data files (SQLite, JSON configs, positions). * Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */ @@ -197,7 +198,11 @@ app.get("/api/init", async (c) => { positions = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8")); } } catch {} - return c.json({ services, connections, positions }); + // Return cached stats (may be empty briefly during cold start). + // We deliberately do NOT trigger a fresh pollStats here — on cold start + // with many containers it can exceed Bun's 10s request timeout and hang + // the dashboard. The first regular poll (within ~3s) populates via WS. + return c.json({ services, connections, positions, stats: lastStats }); }); // ── Server config (read by frontend to disable buttons for non-allowed paths) ── @@ -626,6 +631,23 @@ app.get("/api/stats/history", (c) => { return c.json(getAllServicesStatsHistory(range)); }); +// ── Events + Notifications log ── +app.get("/api/events", (c) => { + const limit = parseInt(c.req.query("limit") || "200"); + const service = c.req.query("service") || undefined; + const action = c.req.query("action") || undefined; + const since = c.req.query("since") ? parseInt(c.req.query("since")!) : undefined; + return c.json(getEvents({ limit, service, action, since })); +}); + +app.get("/api/notifications", (c) => { + const limit = parseInt(c.req.query("limit") || "100"); + const type = c.req.query("type") || undefined; + const level = c.req.query("level") || undefined; + const since = c.req.query("since") ? parseInt(c.req.query("since")!) : undefined; + return c.json(getNotifications({ limit, type, level, since })); +}); + // ── Cache headers for static assets ── app.use("/*", async (c, next) => { await next(); @@ -714,6 +736,7 @@ async function refreshStats(services: Service[]) { statsLockTimer = setTimeout(() => { statsLock = false; }, 30000); try { const stats = await pollStats(services); + lastStats = stats; // cache for /api/init (avoid 3s wait on page load) broadcast({ type: "stats", data: stats }); try { insertStats(stats); } catch {} // Check resource thresholds and down services for Discord alerts @@ -768,9 +791,22 @@ function immediateRefresh() { refreshServices(); } +// Actions worth persisting in the events log. `stop` is intentionally excluded +// — Docker emits BOTH `stop` (command issued) and `die` (process terminated) +// when stopping a container, which results in duplicate entries. `die` always +// fires when a container exits (intentional stop or crash), so it covers both. +const PERSISTED_ACTIONS = new Set(["start", "die", "restart", "health_status"]); + watchDockerEvents((event) => { broadcast({ type: "docker_event", data: event }); scheduleRefresh(); + // Persist to events log only if action is meaningful + non-duplicate + if (PERSISTED_ACTIONS.has(event.action)) { + try { + const entry = insertEvent(event.service, event.action, "docker"); + if (entry) broadcast({ type: "event_log", data: entry } as any); + } catch {} + } try { const config = loadDiscordConfig(); notifyStateChange(event.service, event.action, config); @@ -780,11 +816,20 @@ watchDockerEvents((event) => { // ── Stats polling ── let lastServicesHash = ""; let lastConnectionsHash = ""; +/** Last stats snapshot — sent in /api/init so frontend has data immediately + * instead of waiting for the next polling cycle (~3s wait). */ +let lastStats: Stats[] = []; setInterval(refreshServices, POLL_INTERVAL_MS); -// ── Init stats DB ── +// ── Init persistent DBs ── initStatsDB(); +initEventsDB(); + +// Wire up notification listener so new notifications stream to UI via WebSocket +setNotificationListener((entry) => { + try { broadcast({ type: "notification_log", data: entry } as any); } catch {} +}); // ── Start ── const server = Bun.serve({ diff --git a/src/server/watcher.test.ts b/src/server/watcher.test.ts new file mode 100644 index 0000000..4de9a4c --- /dev/null +++ b/src/server/watcher.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { computeMemoryBreakdown } from "./watcher"; + +describe("computeMemoryBreakdown", () => { + it("subtracts inactive_file from usage (cgroup v2)", () => { + // Real example from a busy DB container on cgroup v2 + const memStats = { + usage: 2108977152, + limit: 2147483648, + stats: { + anon: 77737984, + file: 1996709888, + inactive_file: 1811337216, + active_file: 38223872, + }, + }; + const r = computeMemoryBreakdown(memStats); + expect(r.total).toBe(2108977152); + expect(r.cache).toBe(1811337216); + expect(r.anon).toBe(77737984); + expect(r.limit).toBe(2147483648); + // real = 2108977152 - 1811337216 = 297639936 (~283 MB, real usage) + expect(r.real).toBe(297639936); + // NOT the inflated value of 2108977152 (~2.01 GB) + expect(r.real).toBeLessThan(memStats.usage); + }); + + it("uses total_inactive_file for cgroup v1", () => { + const memStats = { + usage: 1000000000, + limit: 2000000000, + stats: { + total_rss: 200000000, + total_inactive_file: 700000000, + cache: 800000000, + }, + }; + const r = computeMemoryBreakdown(memStats); + // Prefers total_inactive_file over generic cache + expect(r.cache).toBe(700000000); + expect(r.anon).toBe(200000000); // total_rss + expect(r.real).toBe(300000000); + }); + + it("falls back to cache field for legacy cgroup v1", () => { + const memStats = { + usage: 500000000, + limit: 1000000000, + stats: { + rss: 100000000, + cache: 350000000, + }, + }; + const r = computeMemoryBreakdown(memStats); + expect(r.cache).toBe(350000000); + expect(r.anon).toBe(100000000); // rss + expect(r.real).toBe(150000000); + }); + + it("clamps negative real usage to 0", () => { + // Edge case: stats reports cache > usage (race condition) + const memStats = { + usage: 100000000, + limit: 1000000000, + stats: { + inactive_file: 150000000, + }, + }; + const r = computeMemoryBreakdown(memStats); + expect(r.real).toBe(0); + }); + + it("handles missing stats gracefully", () => { + const memStats = { usage: 100000000, limit: 1000000000 }; + const r = computeMemoryBreakdown(memStats); + expect(r.cache).toBe(0); + expect(r.anon).toBe(0); + expect(r.real).toBe(100000000); // no cache to subtract, real = total + expect(r.total).toBe(100000000); + }); + + it("handles completely empty input", () => { + const r = computeMemoryBreakdown(undefined); + expect(r.real).toBe(0); + expect(r.cache).toBe(0); + expect(r.anon).toBe(0); + expect(r.total).toBe(0); + expect(r.limit).toBe(0); + }); + + it("matches docker stats CLI for the user's reported case (~12% real vs 98% inflated)", () => { + // From the bug report: ninjasagacw-db-1 cgroup v2 + const memStats = { + usage: 2108977152, // 2.01 GB raw + limit: 2147483648, // 2.0 GB limit + stats: { + anon: 77737984, + inactive_file: 1811337216, + active_file: 38223872, + }, + }; + const r = computeMemoryBreakdown(memStats); + const realPercent = (r.real / r.limit) * 100; + const rawPercent = (r.total / r.limit) * 100; + // Before fix: would show ~98% + expect(rawPercent).toBeGreaterThan(95); + // After fix: shows ~14% (close to docker stats CLI's 12%) + expect(realPercent).toBeLessThan(20); + expect(realPercent).toBeGreaterThan(10); + }); +}); diff --git a/src/server/watcher.ts b/src/server/watcher.ts index 4d31e9d..c7a59eb 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -1,6 +1,34 @@ import { docker } from "./docker"; import type { Service, Stats, DockerEvent } from "../shared/types"; +/** Compute real memory usage by subtracting reclaimable page cache. + * Mirrors `docker stats` CLI logic. Works for cgroup v1 and v2. + * + * Why: memory_stats.usage includes the kernel page cache (file-backed pages + * the kernel keeps in RAM "just in case"). That cache is INSTANTLY reclaimable + * under memory pressure and is NOT real usage. Containers with heavy I/O + * (DBs, collectors) appear at 90-100% when actually using 10-15%. + * + * Returns: { real, cache, anon, total, limit } all in bytes. */ +export function computeMemoryBreakdown(memoryStats: any): { + real: number; + cache: number; + anon: number; + total: number; + limit: number; +} { + const total = memoryStats?.usage ?? 0; + const limit = memoryStats?.limit ?? 0; + const s = memoryStats?.stats ?? {}; + // cgroup v2: 'inactive_file' + // cgroup v1: 'total_inactive_file' (recursive) or 'cache' (legacy) + const cache = s.inactive_file ?? s.total_inactive_file ?? s.cache ?? 0; + // anon = process memory (heap, stack). cgroup v2: 'anon'. cgroup v1: 'rss' or 'total_rss'. + const anon = s.anon ?? s.total_rss ?? s.rss ?? 0; + const real = Math.max(0, total - cache); + return { real, cache, anon, total, limit }; +} + export async function pollStats(services: Service[]): Promise { const running = services.filter((s) => s.state === "running"); const results: Stats[] = []; @@ -30,14 +58,21 @@ export async function pollStats(services: Service[]): Promise { ? (cpuHost * 100000 / svc.cpu_quota) : cpuHost; - const memUsage = raw.memory_stats.usage || 0; - const memLimit = raw.memory_stats.limit || 1; + const mb = computeMemoryBreakdown(raw.memory_stats); + const memLimit = mb.limit || 1; + const TO_MB = 1024 * 1024; results.push({ service: svc.uid, cpu: parseFloat(cpu.toFixed(2)), - mem_mb: parseFloat((memUsage / 1024 / 1024).toFixed(1)), - mem_percent: parseFloat(((memUsage / memLimit) * 100).toFixed(1)), + mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)), + mem_percent: parseFloat(((mb.real / memLimit) * 100).toFixed(1)), + mem_breakdown: { + anon_mb: parseFloat((mb.anon / TO_MB).toFixed(1)), + cache_mb: parseFloat((mb.cache / TO_MB).toFixed(1)), + 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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 3867eee..094e59b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -32,8 +32,22 @@ export interface Connection { export interface Stats { service: string; cpu: number; + /** Real memory usage in MB (usage minus reclaimable page cache). + * Matches what `docker stats` CLI shows. */ mem_mb: number; + /** Real memory usage as percentage of limit. */ mem_percent: number; + /** Optional breakdown for tooltips (only present in live stats, not persisted). */ + mem_breakdown?: { + /** Anonymous memory (process heap, stack) in MB */ + anon_mb: number; + /** Reclaimable page cache in MB (inactive_file) */ + cache_mb: number; + /** Total reserved including cache (raw memory_stats.usage) in MB */ + total_mb: number; + /** Container memory limit in MB */ + limit_mb: number; + }; } export interface DockerEvent { @@ -99,6 +113,25 @@ export interface ServerConfig { restrictedMode: boolean; } +export interface EventLogEntry { + id: number; + timestamp: number; + service: string; + action: string; + source: "docker" | "ui"; + error_msg: string | null; +} + +export interface NotificationLogEntry { + id: number; + timestamp: number; + type: "state_change" | "resource_alert" | "ui_action" | "action_error"; + service: string; + level: "info" | "warning" | "error"; + title: string; + message: string; +} + export type WSMessage = | { type: "services"; data: Service[] } | { type: "connections"; data: Connection[] } @@ -107,4 +140,6 @@ export type WSMessage = | { type: "subscribe_logs"; container: string } | { type: "unsubscribe_logs" } | { type: "log_line"; data: LogLine } - | { type: "action_error"; data: { uid: string; action: string; error: string } }; + | { type: "action_error"; data: { uid: string; action: string; error: string } } + | { type: "event_log"; data: EventLogEntry } + | { type: "notification_log"; data: NotificationLogEntry };