mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.31
This commit is contained in:
+17
-1
@@ -119,6 +119,19 @@ function Dashboard({ token }: { token: string }) {
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [detailService, setDetailService] = useState<Service | null>(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<any>(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}
|
||||
/>
|
||||
|
||||
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
|
||||
|
||||
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} />}
|
||||
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} onOpenServiceDetail={openServiceDetail} />}
|
||||
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
|
||||
|
||||
{/* 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}
|
||||
|
||||
@@ -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<NotificationLogEntry[]>([]);
|
||||
const [lastReadId, setLastReadId] = useState<number>(() => {
|
||||
try { return parseInt(localStorage.getItem(LAST_READ_KEY) || "0") || 0; } catch { return 0; }
|
||||
});
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const unread = events.length - lastSeen;
|
||||
// Preload from server so the bell has history immediately after a page reload
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
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<number>();
|
||||
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 (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="relative flex items-center justify-center text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Notifications"
|
||||
title={t("header.recentNotifications")}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{unread > 0 && (
|
||||
@@ -93,22 +131,35 @@ function NotificationBell({ events }: NotificationBellProps) {
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 w-72 max-h-80 overflow-auto z-[9999]">
|
||||
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 w-80 max-h-96 overflow-auto z-[9999]">
|
||||
<div className="px-3 py-2 border-b border-slate-700/60 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
{t("header.recentEvents")}
|
||||
{t("header.recentNotifications")}
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-slate-500">{t("header.noEvents")}</div>
|
||||
<div className="px-3 py-6 text-center text-sm text-slate-500">{t("header.noNotifications")}</div>
|
||||
) : (
|
||||
recent.map((ev, i) => (
|
||||
<div key={`${ev.service}-${ev.time}-${i}`} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-700/40 transition-colors">
|
||||
{eventIcon(ev.action)}
|
||||
recent.map((n) => {
|
||||
const isKnownService = services.some((s) => s.uid === n.service);
|
||||
const isUnread = n.id > lastReadId;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={isKnownService ? () => { 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"}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${levelDot(n.level)} mt-1.5 shrink-0 ${isUnread ? "" : "ring-1 ring-slate-600 ring-offset-2 ring-offset-slate-800 opacity-70"}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-slate-300 truncate block">{ev.service}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-xs ${isUnread ? "text-slate-200 font-semibold" : "text-slate-300 font-normal"} truncate`}>{n.title}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</span>
|
||||
<span className="text-[10px] text-slate-400 truncate block">{n.service}</span>
|
||||
</div>
|
||||
))
|
||||
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0 mt-0.5">{timeAgo(n.timestamp)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -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({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NotificationBell events={events} />
|
||||
<NotificationBell notifications={notifications} services={services} token={token} onOpenServiceDetail={onOpenServiceDetail} />
|
||||
|
||||
{/* Logout (only if auth is active) */}
|
||||
{token && (
|
||||
|
||||
@@ -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<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -138,7 +138,6 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
title={`${d.label} (${d.state})${d.locked ? " — view-only (outside ALLOWED_PATHS)" : ""}\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${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" : ""}`}
|
||||
|
||||
@@ -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<StatsRange>("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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<string>; hasActiveFilter: boolean }) {
|
||||
function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter, onOpenServiceDetail }: { token: string; services: Service[]; liveStream: EventLogEntry[]; filteredUids: Set<string>; hasActiveFilter: boolean; onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void }) {
|
||||
const { t } = useT();
|
||||
const [events, setEvents] = useState<EventLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -897,8 +900,14 @@ function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilt
|
||||
return (
|
||||
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-slate-700/40">
|
||||
{allEvents.map((ev) => (
|
||||
<div key={ev.id} className="flex items-center gap-4 px-5 py-3 hover:bg-slate-700/30 transition-colors">
|
||||
{allEvents.map((ev) => {
|
||||
const isKnownService = services.some((svc) => svc.uid === ev.service);
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
onClick={isKnownService ? () => 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`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-700/60 flex items-center justify-center flex-shrink-0">
|
||||
{eventIcon(ev.action)}
|
||||
</div>
|
||||
@@ -924,7 +933,8 @@ function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilt
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.timestamp)}</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<string>; hasActiveFilter: boolean }) {
|
||||
function NotificationsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter, onOpenServiceDetail }: { token: string; services: Service[]; liveStream: NotificationLogEntry[]; filteredUids: Set<string>; hasActiveFilter: boolean; onOpenServiceDetail: (uid: string, tab?: "info" | "config" | "env" | "stats") => void }) {
|
||||
const { t } = useT();
|
||||
const [notifications, setNotifications] = useState<NotificationLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -986,8 +996,13 @@ function NotificationsLogTab({ token, services, liveStream, filteredUids, hasAct
|
||||
<div className="space-y-2">
|
||||
{allNotifs.map((n) => {
|
||||
const s = levelStyles(n.level);
|
||||
const isKnownService = services.some((svc) => svc.uid === n.service);
|
||||
return (
|
||||
<div key={n.id} className={`bg-slate-800/50 border ${s.ring} rounded-lg px-4 py-3`}>
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={isKnownService ? () => 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" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-7 h-7 rounded-lg ${s.iconBg} flex items-center justify-center shrink-0`}>
|
||||
<AlertTriangle size={14} className={s.iconColor} />
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<LogLine[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
@@ -70,7 +72,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const subscribedRef = useRef<string | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("info");
|
||||
const [activeTab, setActiveTab] = useState<Tab>(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<Set<number>>(new Set());
|
||||
|
||||
+27
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+33
-9
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user