From 6cb5bc8e3492aa3255845b836c8f9fa9d28e24c7 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Mon, 11 May 2026 00:15:38 +0000 Subject: [PATCH] v0.0.31 --- src/client/App.tsx | 18 +++++- src/client/components/HeaderBar.tsx | 97 ++++++++++++++++++++++------- src/client/i18n.tsx | 4 ++ src/client/nodes/ServiceNode.tsx | 1 - src/client/pages/MonitoringPage.tsx | 35 ++++++++--- src/client/panels/DetailPanel.tsx | 12 +++- src/server/watcher.test.ts | 40 ++++++++---- src/server/watcher.ts | 42 ++++++++++--- 8 files changed, 192 insertions(+), 57 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 9771051..e9e8c6e 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -119,6 +119,19 @@ function Dashboard({ token }: { token: string }) { const [selectedNode, setSelectedNode] = useState(null); const [detailService, setDetailService] = useState(null); const [openLogsFullscreen, setOpenLogsFullscreen] = useState(false); + + const [detailInitialTab, setDetailInitialTab] = useState<"info" | "config" | "env" | "stats" | undefined>(undefined); + + // Open the DetailPanel for a service by uid. Navigates to dashboard if needed. + // Used from MonitoringPage notifications/events tabs (click → see container detail). + const openServiceDetail = useCallback((uid: string, tab?: "info" | "config" | "env" | "stats") => { + const svc = services.find((s) => s.uid === uid); + if (!svc) return; + setActivePage("dashboard"); + setSelectedNode(svc.uid); + setDetailInitialTab(tab); + setDetailService(svc); + }, [services]); const reactFlowRef = useRef(null); const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null); const isDragging = useRef(false); @@ -518,11 +531,13 @@ function Dashboard({ token }: { token: string }) { activePage={activePage} onPageChange={(page) => { setContextMenu(null); navigateTo(page); }} events={events} + notifications={notificationStream} + onOpenServiceDetail={openServiceDetail} /> - {activePage === "monitoring" && } + {activePage === "monitoring" && } {activePage === "settings" && } {/* Canvas — inset (only visible on dashboard) */} @@ -767,6 +782,7 @@ function Dashboard({ token }: { token: string }) { services={filteredServices} getLogsSince={getLogsSince} initialLogsFullscreen={openLogsFullscreen} + initialTab={detailInitialTab} envFiles={envFiles} onEnvFileChange={handleEnvFileChange} events={events} diff --git a/src/client/components/HeaderBar.tsx b/src/client/components/HeaderBar.tsx index 15f0800..e4ee08a 100644 --- a/src/client/components/HeaderBar.tsx +++ b/src/client/components/HeaderBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { LogOut, Cpu, MemoryStick, LayoutDashboard, Activity, Settings, Bell, @@ -50,16 +50,51 @@ function eventIcon(action: string) { } interface NotificationBellProps { - events: DockerEvent[]; + notifications: NotificationLogEntry[]; + services: Service[]; + token: string; + onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void; } -function NotificationBell({ events }: NotificationBellProps) { +function levelDot(level: NotificationLogEntry["level"]): string { + if (level === "error") return "bg-red-400"; + if (level === "warning") return "bg-amber-400"; + return "bg-cyan-400"; +} + +const LAST_READ_KEY = "df:lastReadNotifId"; + +function NotificationBell({ notifications, services, token, onOpenServiceDetail }: NotificationBellProps) { const { t } = useT(); const [open, setOpen] = useState(false); - const [lastSeen, setLastSeen] = useState(events.length); + const [persisted, setPersisted] = useState([]); + const [lastReadId, setLastReadId] = useState(() => { + try { return parseInt(localStorage.getItem(LAST_READ_KEY) || "0") || 0; } catch { return 0; } + }); const ref = useRef(null); - const unread = events.length - lastSeen; + // Preload from server so the bell has history immediately after a page reload + useEffect(() => { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/notifications?limit=20", { headers }) + .then((r) => r.ok ? r.json() : []) + .then((d: NotificationLogEntry[]) => setPersisted(d)) + .catch(() => {}); + }, [token]); + + // Merge persisted + live, dedupe by id, keep newest 20 + const recent = useMemo(() => { + const seen = new Set(); + const merged: NotificationLogEntry[] = []; + for (const n of [...notifications, ...persisted]) { + if (seen.has(n.id)) continue; + seen.add(n.id); + merged.push(n); + } + return merged.slice(0, 20); + }, [notifications, persisted]); + const unread = recent.filter((n) => n.id > lastReadId).length; useEffect(() => { const handler = (e: MouseEvent) => { @@ -72,18 +107,21 @@ function NotificationBell({ events }: NotificationBellProps) { }, []); const handleToggle = () => { - if (!open) setLastSeen(events.length); + if (!open && recent.length > 0) { + // Mark current set as read (persist) + const newest = recent[0].id; + setLastReadId(newest); + try { localStorage.setItem(LAST_READ_KEY, String(newest)); } catch {} + } setOpen((v) => !v); }; - const recent = events.slice(-20).reverse(); - return (
{open && ( -
+
- {t("header.recentEvents")} + {t("header.recentNotifications")}
{recent.length === 0 ? ( -
{t("header.noEvents")}
+
{t("header.noNotifications")}
) : ( - recent.map((ev, i) => ( -
- {eventIcon(ev.action)} -
- {ev.service} + recent.map((n) => { + const isKnownService = services.some((s) => s.uid === n.service); + const isUnread = n.id > lastReadId; + return ( +
{ onOpenServiceDetail(n.service, "stats"); setOpen(false); } : undefined} + className={`flex items-start gap-2.5 px-3 py-2 ${isKnownService ? "cursor-pointer hover:bg-slate-700/40" : ""} transition-colors ${isUnread ? "" : "opacity-55"}`} + > + +
+
+ {n.title} +
+ {n.service} +
+ {timeAgo(n.timestamp)}
- {timeAgo(ev.time)} -
- )) + ); + }) )}
)} @@ -124,6 +175,8 @@ interface HeaderBarProps { activePage: Page; onPageChange: (page: Page) => void; events: DockerEvent[]; + notifications: NotificationLogEntry[]; + onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void; } export function HeaderBar({ @@ -134,6 +187,8 @@ export function HeaderBar({ activePage, onPageChange, events, + notifications, + onOpenServiceDetail, }: HeaderBarProps) { const { t, lang, setLang } = useT(); @@ -199,7 +254,7 @@ export function HeaderBar({
- + {/* Logout (only if auth is active) */} {token && ( diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index 197d3bb..eaf8bc9 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -7,6 +7,8 @@ const en = { "header.settings": "Settings", "header.recentEvents": "Recent Events", "header.noEvents": "No events yet", + "header.recentNotifications": "Recent Notifications", + "header.noNotifications": "No notifications yet", // Footer "footer.live": "Live", @@ -263,6 +265,8 @@ const es: Record = { "header.settings": "Configuraci\u00f3n", "header.recentEvents": "Eventos Recientes", "header.noEvents": "Sin eventos a\u00fan", + "header.recentNotifications": "Notificaciones Recientes", + "header.noNotifications": "Sin notificaciones a\u00fan", // Footer "footer.live": "En vivo", diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 4db984f..72ca7db 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -138,7 +138,6 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) { return (
`${p.host}:${p.container}`).join(", ") || "none"}`} className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring} transition-[opacity,box-shadow] duration-300 ${flashClass} ${d.locked ? "opacity-70" : ""}`} diff --git a/src/client/pages/MonitoringPage.tsx b/src/client/pages/MonitoringPage.tsx index ca77588..5b3c5fd 100644 --- a/src/client/pages/MonitoringPage.tsx +++ b/src/client/pages/MonitoringPage.tsx @@ -84,9 +84,10 @@ interface MonitoringPageProps { services: Service[]; eventLogStream: EventLogEntry[]; notificationStream: NotificationLogEntry[]; + onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void; } -export function MonitoringPage({ events, token, services, eventLogStream, notificationStream }: MonitoringPageProps) { +export function MonitoringPage({ events, token, services, eventLogStream, notificationStream, onOpenServiceDetail }: MonitoringPageProps) { const { t } = useT(); const [statsRange, setStatsRange] = useState("1h"); const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history"); @@ -493,6 +494,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi liveStream={eventLogStream} filteredUids={finalFilteredServices} hasActiveFilter={hasActiveFilter} + onOpenServiceDetail={onOpenServiceDetail} /> )} @@ -504,6 +506,7 @@ export function MonitoringPage({ events, token, services, eventLogStream, notifi liveStream={notificationStream} filteredUids={finalFilteredServices} hasActiveFilter={hasActiveFilter} + onOpenServiceDetail={onOpenServiceDetail} /> )}
@@ -853,7 +856,7 @@ function MonitoringServiceCard({ // 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 }) { +function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter, onOpenServiceDetail }: { token: string; services: Service[]; liveStream: EventLogEntry[]; filteredUids: Set; hasActiveFilter: boolean; onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void }) { const { t } = useT(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -897,8 +900,14 @@ function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilt return (
- {allEvents.map((ev) => ( -
+ {allEvents.map((ev) => { + const isKnownService = services.some((svc) => svc.uid === ev.service); + return ( +
onOpenServiceDetail(ev.service, "stats") : undefined} + className={`flex items-center gap-4 px-5 py-3 ${isKnownService ? "cursor-pointer hover:bg-slate-700/30" : ""} transition-colors`} + >
{eventIcon(ev.action)}
@@ -924,7 +933,8 @@ function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilt
{timeAgo(ev.timestamp)}
- ))} + ); + })}
); @@ -936,13 +946,13 @@ function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilt 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" }; + case "error": return { ring: "border-red-500/40", iconBg: "bg-red-500/20", iconColor: "text-red-400", titleColor: "text-red-300", dot: "bg-red-500", bar: "bg-red-500" }; + case "warning": return { ring: "border-amber-500/40", iconBg: "bg-amber-500/20", iconColor: "text-amber-400", titleColor: "text-amber-300", dot: "bg-amber-500", bar: "bg-amber-500" }; + case "info": return { ring: "border-cyan-500/40", iconBg: "bg-cyan-500/20", iconColor: "text-cyan-400", titleColor: "text-cyan-300", dot: "bg-cyan-500", bar: "bg-cyan-500" }; } } -function NotificationsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter }: { token: string; services: Service[]; liveStream: NotificationLogEntry[]; filteredUids: Set; hasActiveFilter: boolean }) { +function NotificationsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter, onOpenServiceDetail }: { token: string; services: Service[]; liveStream: NotificationLogEntry[]; filteredUids: Set; hasActiveFilter: boolean; onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void }) { const { t } = useT(); const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); @@ -986,8 +996,13 @@ function NotificationsLogTab({ token, services, liveStream, filteredUids, hasAct
{allNotifs.map((n) => { const s = levelStyles(n.level); + const isKnownService = services.some((svc) => svc.uid === n.service); return ( -
+
onOpenServiceDetail(n.service, "stats") : undefined} + className={`bg-slate-800/50 border ${s.ring} rounded-lg px-4 py-3 ${isKnownService ? "cursor-pointer hover:bg-slate-800/80 transition-colors" : ""}`} + >
diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index c9d3a2a..73fd79f 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -57,12 +57,14 @@ interface DetailPanelProps { services: Service[]; getLogsSince: (uid: string) => number | undefined; initialLogsFullscreen?: boolean; + /** Initial tab to open. Used when entering panel via notification/event click. */ + initialTab?: "info" | "config" | "env" | "stats"; envFiles: Record; onEnvFileChange: (composeFile: string, envFile: string | null) => void; events: DockerEvent[]; } -export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, 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 }: DetailPanelProps) { const { t } = useT(); const [initialLogs, setInitialLogs] = useState([]); const [autoScroll, setAutoScroll] = useState(true); @@ -70,7 +72,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked, const scrollRef = useRef(null); const subscribedRef = useRef(null); const [visible, setVisible] = useState(false); - const [activeTab, setActiveTab] = useState("info"); + const [activeTab, setActiveTab] = useState(initialTab ?? "info"); + + // When the panel opens for a different service (via notification/event click), + // honor the requested initialTab. + useEffect(() => { + if (initialTab) setActiveTab(initialTab); + }, [service.uid, initialTab]); const [logsExpanded, setLogsExpanded] = useState(false); const [envVisibleAll, setEnvVisibleAll] = useState(false); const [envVisibleSet, setEnvVisibleSet] = useState>(new Set()); diff --git a/src/server/watcher.test.ts b/src/server/watcher.test.ts index 4de9a4c..5e0a750 100644 --- a/src/server/watcher.test.ts +++ b/src/server/watcher.test.ts @@ -2,30 +2,45 @@ import { describe, expect, it } from "vitest"; import { computeMemoryBreakdown } from "./watcher"; describe("computeMemoryBreakdown", () => { - it("subtracts inactive_file from usage (cgroup v2)", () => { + it("subtracts active_file + 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, + file: 1996709888, // some kernels include shmem here — we avoid this inactive_file: 1811337216, active_file: 38223872, }, }; const r = computeMemoryBreakdown(memStats); expect(r.total).toBe(2108977152); - expect(r.cache).toBe(1811337216); + // We use active+inactive (= 1849561088), NOT `file` which can include shmem + expect(r.cache).toBe(1849561088); 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) + // real = 2108977152 - 1849561088 = 259416064 (~247 MB) + expect(r.real).toBe(259416064); expect(r.real).toBeLessThan(memStats.usage); }); - it("uses total_inactive_file for cgroup v1", () => { + it("falls back to active_file + inactive_file when `file` not present", () => { + const memStats = { + usage: 1000000000, + limit: 2000000000, + stats: { + anon: 200000000, + active_file: 600000000, + inactive_file: 200000000, + }, + }; + const r = computeMemoryBreakdown(memStats); + expect(r.cache).toBe(800000000); // active + inactive + expect(r.real).toBe(200000000); + }); + + it("uses total cache for cgroup v1 (full page cache, not just inactive)", () => { const memStats = { usage: 1000000000, limit: 2000000000, @@ -36,10 +51,10 @@ describe("computeMemoryBreakdown", () => { }, }; const r = computeMemoryBreakdown(memStats); - // Prefers total_inactive_file over generic cache - expect(r.cache).toBe(700000000); + // Prefers `cache` (total page cache) over partial total_inactive_file + expect(r.cache).toBe(800000000); expect(r.anon).toBe(200000000); // total_rss - expect(r.real).toBe(300000000); + expect(r.real).toBe(200000000); }); it("falls back to cache field for legacy cgroup v1", () => { @@ -88,7 +103,7 @@ describe("computeMemoryBreakdown", () => { expect(r.limit).toBe(0); }); - it("matches docker stats CLI for the user's reported case (~12% real vs 98% inflated)", () => { + it("excludes all file cache for accurate DB container reading", () => { // From the bug report: ninjasagacw-db-1 cgroup v2 const memStats = { usage: 2108977152, // 2.01 GB raw @@ -104,8 +119,7 @@ describe("computeMemoryBreakdown", () => { 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%) + // After fix: subtracting all file cache → much lower (~13% in this case) expect(realPercent).toBeLessThan(20); - expect(realPercent).toBeGreaterThan(10); }); }); diff --git a/src/server/watcher.ts b/src/server/watcher.ts index c7a59eb..396af64 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -2,12 +2,19 @@ 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. + * 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%. + * Why: memory_stats.usage includes the kernel page cache — file-backed pages + * the kernel keeps in RAM after reading from disk. Both `active_file` and + * `inactive_file` are reclaimable under memory pressure (the kernel drops + * inactive first, then active when needed). They are NOT real container usage. + * + * Note: `docker stats` CLI only subtracts `inactive_file`, which leaves the + * `active_file` portion looking like real usage. For DB containers (Postgres, + * MySQL, Mongo) most of their cached working set lives in `active_file`, so + * `docker stats` still over-reports. We subtract the full file cache for a + * more honest reading. The breakdown is exposed in mem_breakdown for users + * who want to see what's cache vs anon vs total. * * Returns: { real, cache, anon, total, limit } all in bytes. */ export function computeMemoryBreakdown(memoryStats: any): { @@ -20,10 +27,27 @@ export function computeMemoryBreakdown(memoryStats: any): { 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'. + // Total reclaimable file-backed page cache (does NOT include shmem, which is + // shared memory like Postgres shared_buffers — that IS real usage). + // Prefer `active_file + inactive_file` (cgroup v2) over `file` because some + // kernels include shmem in `file`, which would over-subtract. + let cache = 0; + if (typeof s.active_file === "number" || typeof s.inactive_file === "number") { + // cgroup v2 — sum the two file-cache buckets + cache = (s.active_file ?? 0) + (s.inactive_file ?? 0); + } else if (typeof s.total_cache === "number") { + // cgroup v1 + cache = s.total_cache; + } else if (typeof s.cache === "number") { + // cgroup v1 (older) + cache = s.cache; + } else if (typeof s.total_inactive_file === "number") { + cache = s.total_inactive_file; + } else if (typeof s.file === "number") { + // Last-resort fallback (some kernels) + cache = s.file; + } + // 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 };