feat: account for VM memory ballooning in host memory reporting (#1750)

* feat: account for VM memory ballooning in host memory reporting

Extend hostMemory.ts with a readBalloonedMemory() function that parses the
Balloon: field from /proc/meminfo, following the same fail-open pattern as
the ZFS ARC integration. When a nonzero balloon is detected, effective
memory fields (effectiveUsed, effectiveFree, effectiveUsagePercent) are
computed and exposed through /api/system/stats and /api/fleet/overview.

All consumers that derive meaning from host memory now prefer effective
values when present: the dashboard gauge, Fleet card RAM bar, mobile
views, health verdict, health status bar stat tile, and host RAM alerts.

Backward compatible: missing /proc/meminfo or absent Balloon: line
preserves exact current behavior. Old remote nodes without the new fields
continue rendering normally.

* refactor: extract shared helpers for balloon memory wiring

Extract readCandidateFile() and logSelectedPath() in hostMemory.ts to
deduplicate ARC and balloon file-read logic. Add memoryToWire() to
centralize the optional-field spread used by /api/system/stats and
/api/fleet/overview. Add getNodeMemUsed()/getNodeMemTotal() helpers
in nodeUtils.ts for frontend byte-text consumers.

* fix: make desktop fleet masthead aggregate balloon-aware

The desktop fleet overview's memory aggregate in useFleetOverview.ts still
summed raw memory.used, while the mobile fleet aggregate and per-node cards
already used effective values. Update to use getNodeMemUsed/getNodeMemTotal
helpers.

* fix: revert balloon adjustment from alerting and health decisions

Ballooned memory is host-reclaimed (unlike ZFS ARC, which the guest can
reclaim on demand). The guest cannot get ballooned pages back until the
hypervisor deflates them, so treating ballooned memory as available for
alerting or health can mask real memory pressure.

Keep balloon parsing, wire fields, and the dashboard context line as
informational-only. The memory gauge, health verdict, and host RAM alerts
now use the standard ARC-adjusted working-set percentage regardless of
balloon. Updated configuration.mdx and dashboard.mdx to document that
balloon data is informational and does not influence alerting.
This commit is contained in:
Anso
2026-08-02 20:48:38 -04:00
committed by GitHub
parent a74905ff1e
commit c613010199
20 changed files with 540 additions and 99 deletions
@@ -29,7 +29,7 @@ import { PinnedUpdateBadge } from './PinnedUpdateBadge';
import { StackSection } from './NodeCardStackList';
import type { Label as StackLabel } from '../label-types';
import type { FleetNode, NodeUpdateStatus } from './types';
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from './nodeUtils';
// --- Types ---
@@ -97,6 +97,8 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
const formattedLatest = formatVersion(updateStatus?.latestVersion);
const cpuPercent = getNodeCpu(node);
const memPercent = getNodeMem(node);
const memUsed = getNodeMemUsed(node);
const memTotal = getNodeMemTotal(node);
const diskPercent = getNodeDisk(node);
const openCordonModal = () => {
@@ -308,7 +310,7 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
<span className="flex items-center gap-1 text-muted-foreground">
<MemoryStick className="w-3 h-3" /> RAM
</span>
<span className="font-medium">{formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)}</span>
<span className="font-medium">{formatBytes(memUsed, 1)} / {formatBytes(memTotal, 1)}</span>
</div>
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
</div>
@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import { useFleetLabels, labelPaletteKey } from './useFleetLabels';
import { useNodeLabels } from './useNodeLabels';
import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from '../nodeUtils';
import { isCritical, getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk } from '../nodeUtils';
import type { FleetNode, ViewMode, FleetPreferences, NodeUpdateStatus } from '../types';
interface MastheadStats {
@@ -96,8 +96,8 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
const worstCpu = worstCpuNode
? { name: worstCpuNode.name, percent: getNodeCpu(worstCpuNode) }
: null;
const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0);
const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0);
const totalMemUsed = onlineNodes.reduce((sum, n) => sum + getNodeMemUsed(n), 0);
const totalMemTotal = onlineNodes.reduce((sum, n) => sum + getNodeMemTotal(n), 0);
return {
nodeCount: nodes.length,
onlineCount,
+13 -1
View File
@@ -5,7 +5,19 @@ export function getNodeCpu(node: FleetNode): number {
}
export function getNodeMem(node: FleetNode): number {
return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
if (!node.systemStats) return 0;
const eff = node.systemStats.memory.effectiveUsagePercent;
return parseFloat(eff ?? node.systemStats.memory.usagePercent);
}
export function getNodeMemUsed(node: FleetNode): number {
const mem = node.systemStats?.memory;
return mem?.effectiveUsed ?? mem?.used ?? 0;
}
export function getNodeMemTotal(node: FleetNode): number {
const mem = node.systemStats?.memory;
return mem?.effectiveTotal ?? mem?.total ?? 0;
}
export function getNodeDisk(node: FleetNode): number {
+12 -1
View File
@@ -10,7 +10,18 @@ export interface FleetNodeStats {
export interface FleetNodeSystemStats {
cpu: { usage: string; cores: number };
memory: { total: number; used: number; free: number; usagePercent: string };
memory: {
total: number;
used: number;
free: number;
usagePercent: string;
ballooned?: number;
effectiveTotal?: number;
effectiveUsed?: number;
effectiveFree?: number;
effectiveUsagePercent?: string;
balloonSource?: string;
};
disk: { total: number; used: number; free: number; usagePercent: string } | null;
}
@@ -80,7 +80,7 @@ export function HealthStatusBar({
const unreadAlerts = countVisibleUnread(notifications);
const running = `${stats.active}/${stats.total}`;
const cpuLabel = systemStats ? `${parseFloat(systemStats.cpu.usage).toFixed(0)}%` : '--';
const memLabel = systemStats ? formatGib(systemStats.memory.used) : '--';
const memLabel = systemStats ? formatGib(systemStats.memory.effectiveUsed ?? systemStats.memory.used) : '--';
const lastSyncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…';
const metaLine = `${activeNodeName} · ${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${lastSyncLabel}`;
const reasonsLine = reasons.join(' · ');
@@ -49,7 +49,13 @@ function GaugeBar({ value, warn = 80, crit = 90 }: { value: number; warn?: numbe
export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEndAt }: ResourceGaugesProps) {
const cpuVal = parseFloat(systemStats?.cpu.usage || '0');
// Primary gauge driven by raw ARC-adjusted percent only. Ballooned memory
// is host-reclaimed (not guest-reclaimable like ARC), so it must not mask
// real memory pressure in the gauge color or percent hero.
const ramVal = parseFloat(systemStats?.memory.usagePercent || '0');
const ramEffectivePercent = systemStats?.memory.effectiveUsagePercent ?? null;
const ramUsed = systemStats?.memory.effectiveUsed ?? systemStats?.memory.used ?? 0;
const ramTotal = systemStats?.memory.effectiveTotal ?? systemStats?.memory.total ?? 0;
const diskVal = parseFloat(systemStats?.disk?.usagePercent || '0');
const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0;
@@ -102,8 +108,14 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
{systemStats ? `${ramVal.toFixed(0)}%` : '--'}
</div>
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
{systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'}
{systemStats ? `${formatBytes(ramUsed)} / ${formatBytes(ramTotal)}` : '\u00A0'}
</div>
{systemStats?.memory.ballooned && systemStats.memory.ballooned > 0 ? (
<div className="mt-1 font-mono text-[10px] text-stat-subtitle/70">
Ballooned to host: {formatBytes(systemStats.memory.ballooned)}
{ramEffectivePercent !== null ? ` (effective ${parseFloat(ramEffectivePercent).toFixed(0)}%)` : ''}
</div>
) : null}
{systemStats ? <GaugeBar value={ramVal} /> : null}
</div>
@@ -10,7 +10,10 @@ export interface HealthResult {
// drift apart.
export function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult {
const cpu = parseFloat(systemStats?.cpu.usage || '0');
const ram = parseFloat(systemStats?.memory.usagePercent || '0');
// Ballooned memory is NOT subtracted for health: unlike ARC, ballooned
// pages are host-reclaimed and the guest cannot get them back on demand.
// A ballooned VM with real memory pressure must still show degraded/critical.
const ram = parseFloat(systemStats?.memory.usagePercent ?? '0');
const disk = parseFloat(systemStats?.disk?.usagePercent || '0');
const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length;
@@ -16,6 +16,12 @@ export interface SystemStats {
used: number;
free: number;
usagePercent: string;
ballooned?: number;
effectiveTotal?: number;
effectiveUsed?: number;
effectiveFree?: number;
effectiveUsagePercent?: string;
balloonSource?: string;
};
disk: {
fs: string;
@@ -95,7 +95,7 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac
);
const cpuVal = parseFloat(data.systemStats?.cpu.usage || '0');
const ramVal = parseFloat(data.systemStats?.memory.usagePercent || '0');
const ramVal = parseFloat(data.systemStats?.memory.effectiveUsagePercent ?? data.systemStats?.memory.usagePercent ?? '0');
const diskVal = parseFloat(data.systemStats?.disk?.usagePercent || '0');
const netPerSec = (data.systemStats?.network?.rxSec ?? 0) + (data.systemStats?.network?.txSec ?? 0);
@@ -7,7 +7,7 @@ import { cordonNode, uncordonNode } from '@/lib/nodesApi';
import { toast } from '@/components/ui/toast-store';
import { ConfirmModal } from '@/components/ui/modal';
import { formatBytes } from '@/lib/utils';
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils';
import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils';
import type { FleetNode } from '@/components/FleetView/types';
import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui';
import type { Tone as UiTone } from './mobile-ui';
@@ -221,7 +221,7 @@ function NodeDetail({
<ResourceRow
label="mem"
pct={getNodeMem(node)}
detail={`${formatBytes(node.systemStats.memory.used, 1)} / ${formatBytes(node.systemStats.memory.total, 1)}`}
detail={`${formatBytes(getNodeMemUsed(node), 1)} / ${formatBytes(getNodeMemTotal(node), 1)}`}
/>
{node.systemStats.disk ? (
<ResourceRow
@@ -309,8 +309,8 @@ export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: Mo
const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0);
const running = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0);
const avgCpu = onlineNodes.length > 0 ? onlineNodes.reduce((s, n) => s + getNodeCpu(n), 0) / onlineNodes.length : 0;
const memUsed = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.used ?? 0), 0);
const memTotal = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.total ?? 0), 0);
const memUsed = onlineNodes.reduce((s, n) => s + getNodeMemUsed(n), 0);
const memTotal = onlineNodes.reduce((s, n) => s + getNodeMemTotal(n), 0);
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : 0;
const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…';