This commit is contained in:
RGJorge
2026-05-11 00:15:38 +00:00
parent 3962e6c319
commit 6cb5bc8e34
8 changed files with 192 additions and 57 deletions
+17 -1
View File
@@ -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}
+76 -21
View File
@@ -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)}
<div className="flex-1 min-w-0">
<span className="text-xs text-slate-300 truncate block">{ev.service}</span>
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">
<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-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>
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</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 && (
+4
View File
@@ -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",
-1
View File
@@ -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" : ""}`}
+25 -10
View File
@@ -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} />
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 };