Add split memory bar showing Used | Cache | Free segments (#1302)

Show reclaimable buff/cache as a distinct amber segment between used
(green) and free (gray) in the memory bar. This explains why Pulse's
memory percentage differs from Proxmox: Pulse reports cache-aware
usage (MemAvailable) while Proxmox includes cache as used (Total-Free).

Backend: add Cache field to Memory model, derived from MemInfo
(Available - Free). Only uses MemInfo.Free (not FreeMem fallback) to
avoid inflating cache by the balloon gap on ballooned VMs.

Frontend: StackedMemoryBar renders three segments with tooltip
breakdown. Tooltip Free accounts for balloon limit when active.
Percentage label and alerts remain cache-aware (unchanged).
This commit is contained in:
rcourtman 2026-03-10 10:16:06 +00:00
parent 5498575b8f
commit 7dab977d91
8 changed files with 82 additions and 21 deletions

View file

@ -843,6 +843,7 @@ export function GuestRow(props: GuestRowProps) {
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
cache={props.guest.memory?.cache || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}

View file

@ -9,6 +9,7 @@ import type { AnomalyReport } from '@/types/aiIntelligence';
interface StackedMemoryBarProps {
used: number;
total: number;
cache?: number; // Reclaimable buff/cache; used + cache + free ≈ total
swapUsed?: number;
swapTotal?: number;
balloon?: number;
@ -27,7 +28,8 @@ const anomalySeverityClass: Record<string, string> = {
// Colors for memory segments
const MEMORY_COLORS = {
active: 'rgba(34, 197, 94, 0.6)', // green (base, overridden by threshold)
balloon: 'rgba(59, 130, 246, 0.6)', // yellow to blue
cache: 'rgba(251, 191, 36, 0.45)', // amber, muted
balloon: 'rgba(59, 130, 246, 0.6)', // blue
swap: 'rgba(168, 85, 247, 0.6)', // purple
};
@ -83,6 +85,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
if (props.total <= 0) return [];
const balloon = props.balloon || 0;
const cache = props.cache || 0;
// Proxmox balloon semantics:
// - balloon = 0: ballooning not enabled/configured
@ -92,37 +95,36 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
// Only show balloon segment when actual ballooning is in effect
const hasActiveBallooning = balloon > 0 && balloon < props.total;
// Used memory is what the guest is actually consuming
// Used memory is what the guest is actually consuming (excludes reclaimable cache)
const usedPercent = (props.used / props.total) * 100;
const cachePercent = (cache / props.total) * 100;
const segs: Array<{ type: string; bytes: number; percent: number; color: string }> = [];
// Always show the used segment
segs.push({ type: 'Used', bytes: props.used, percent: usedPercent, color: getMemoryColor(usedPercent) });
// Cache segment (reclaimable buff/cache) — shown as muted amber
if (cache > 0) {
segs.push({ type: 'Cache', bytes: cache, percent: cachePercent, color: MEMORY_COLORS.cache });
}
if (hasActiveBallooning) {
// With active ballooning:
// - Green: actual used memory
// - Yellow: balloon limit marker (shows where the guest is capped)
// The balloon limit shows as a segment from used to balloon
const balloonLimitPercent = Math.max(0, (balloon / props.total) * 100 - usedPercent);
// With active ballooning, show balloon limit marker after used+cache
const usedPlusCache = props.used + cache;
const balloonLimitPercent = Math.max(0, (balloon / props.total) * 100 - (usedPlusCache / props.total) * 100);
const segs = [
{ type: 'Active', bytes: props.used, percent: usedPercent, color: getMemoryColor(usedPercent) },
];
// Only show balloon segment if there's room between used and balloon limit
if (balloonLimitPercent > 0 && balloon > props.used) {
if (balloonLimitPercent > 0 && balloon > usedPlusCache) {
segs.push({
type: 'Balloon',
bytes: balloon - props.used,
bytes: balloon - usedPlusCache,
percent: balloonLimitPercent,
color: MEMORY_COLORS.balloon,
});
}
return segs.filter(s => s.bytes > 0);
}
// No active ballooning - show used memory with threshold-based coloring
return [
{ type: 'Active', bytes: props.used, percent: usedPercent, color: getMemoryColor(usedPercent) },
].filter(s => s.bytes > 0);
return segs.filter(s => s.bytes > 0);
});
const swapPercent = createMemo(() => {
@ -161,6 +163,17 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
setShowTooltip(false);
};
// Truly free memory (excludes cache; capped at balloon limit when active)
const trulyFree = createMemo(() => {
const cache = props.cache || 0;
const balloon = props.balloon || 0;
const hasActiveBallooning = balloon > 0 && balloon < props.total;
// When ballooning is active, the guest can only use up to 'balloon' bytes,
// so free = balloon - used - cache (not total - used - cache).
const ceiling = hasActiveBallooning ? balloon : props.total;
return Math.max(0, ceiling - props.used - cache);
});
return (
<Show
when={viewMode() === 'sparklines' && props.resourceId}
@ -251,6 +264,15 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
</span>
</div>
<Show when={(props.cache || 0) > 0}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-amber-400">Cache</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.cache || 0)}
</span>
</div>
</Show>
<Show when={(props.balloon || 0) > 0 && (props.balloon || 0) < props.total}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-blue-400">Balloon Limit</span>
@ -263,7 +285,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-gray-400">Free</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.total - props.used)}
{formatBytes(trulyFree())}
</span>
</div>

View file

@ -1468,6 +1468,7 @@ const HostRow: Component<HostRowProps> = (props) => {
<StackedMemoryBar
used={props.host.memory?.used || 0}
total={props.host.memory?.total || 0}
cache={props.host.memory?.cache || 0}
balloon={props.host.memory?.balloon || 0}
swapUsed={props.host.memory?.swapUsed || 0}
swapTotal={props.host.memory?.swapTotal || 0}

View file

@ -655,6 +655,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
cache={node!.memory?.cache || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}

View file

@ -881,6 +881,7 @@ export interface Memory {
used: number;
free: number;
usage: number;
cache?: number; // Reclaimable buff/cache; used + cache + free ≈ total
balloon?: number;
swapUsed?: number;
swapTotal?: number;

View file

@ -1052,6 +1052,7 @@ type Memory struct {
Used int64 `json:"used"`
Free int64 `json:"free"`
Usage float64 `json:"usage"`
Cache int64 `json:"cache,omitempty"` // Reclaimable buff/cache (Available - Free); used + cache + free ≈ total
Balloon int64 `json:"balloon,omitempty"`
SwapUsed int64 `json:"swapUsed,omitempty"`
SwapTotal int64 `json:"swapTotal,omitempty"`

View file

@ -7269,6 +7269,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
}
var detailedStatus *proxmox.VMStatus
memAvailable := uint64(0)
memRawFree := uint64(0) // Truly free memory (MemFree), for cache segment calculation
memInfoTotalMinusUsed := uint64(0)
rrdUsed := uint64(0)
agentEnabled := false
@ -7318,6 +7319,10 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
guestRaw.MemInfoCached = detailedStatus.MemInfo.Cached
guestRaw.MemInfoShared = detailedStatus.MemInfo.Shared
if detailedStatus.MemInfo.Free > 0 {
memRawFree = detailedStatus.MemInfo.Free
}
selection := selectVMAvailableFromMemInfo(detailedStatus.MemInfo)
memInfoTotalMinusUsed = selection.TotalMinusUsed
guestRaw.MemInfoTotalMinusUsed = memInfoTotalMinusUsed
@ -7342,6 +7347,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
if detailedStatus.MaxMem > 0 {
memTotal = detailedStatus.MaxMem
}
// Note: do NOT fall back to detailedStatus.FreeMem for memRawFree.
// FreeMem is relative to the balloon allocation (guest-visible total),
// while memFree is derived from MaxMem. Mixing reference frames would
// inflate the cache segment by the balloon gap. Only MemInfo.Free is
// safe because it shares the same reference frame as MemInfo.Available.
} else {
// No vmStatus available - keep cluster/resources data
log.Debug().
@ -7841,6 +7851,14 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
if memory.Used > memory.Total {
memory.Used = memory.Total
}
// Derive reclaimable cache: the difference between "available" memory
// (what the OS can reclaim) and "truly free" memory (unused pages).
// This lets the frontend show a split bar: used | cache | free.
if memRawFree > 0 && memFree > memRawFree {
memory.Cache = int64(memFree - memRawFree)
// Adjust Free to reflect truly free pages, not available
memory.Free = int64(memRawFree)
}
if detailedStatus != nil && detailedStatus.Balloon > 0 {
memory.Balloon = int64(detailedStatus.Balloon)
}

View file

@ -297,6 +297,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
memTotal := vm.MaxMem
var vmStatus *proxmox.VMStatus
memAvailable := uint64(0)
memRawFree := uint64(0) // Truly free memory (MemFree), for cache segment calculation
memInfoTotalMinusUsed := uint64(0)
rrdUsed := uint64(0)
agentEnabled := vm.Agent > 0
@ -325,6 +326,10 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
guestRaw.MemInfoCached = status.MemInfo.Cached
guestRaw.MemInfoShared = status.MemInfo.Shared
if status.MemInfo.Free > 0 {
memRawFree = status.MemInfo.Free
}
selection := selectVMAvailableFromMemInfo(status.MemInfo)
memInfoTotalMinusUsed = selection.TotalMinusUsed
guestRaw.MemInfoTotalMinusUsed = memInfoTotalMinusUsed
@ -333,6 +338,11 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
memorySource = selection.Source
}
}
// Note: do NOT fall back to vmStatus.FreeMem for memRawFree.
// FreeMem is relative to the balloon allocation (guest-visible total),
// while memFree is derived from MaxMem. Mixing reference frames would
// inflate the cache segment by the balloon gap. Only MemInfo.Free is
// safe because it shares the same reference frame as MemInfo.Available.
// Note: We intentionally do NOT override memTotal with balloon.
// The balloon value is tracked separately in memory.balloon for
// visualization purposes. Using balloon as total causes user
@ -816,6 +826,12 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
Free: memFreeBytes,
Usage: safePercentage(float64(memUsed), float64(memTotal)),
}
// Derive reclaimable cache: the difference between "available" memory
// (what the OS can reclaim) and "truly free" memory (unused pages).
if memRawFree > 0 && memFreeBytes > clampToInt64(memRawFree) {
memory.Cache = memFreeBytes - clampToInt64(memRawFree)
memory.Free = clampToInt64(memRawFree)
}
if guestRaw.Balloon > 0 {
memory.Balloon = clampToInt64(guestRaw.Balloon)
}