memory: restore the v5 reclaimable-cache split end-to-end

v5 modeled memory as used | cache | free (Memory.Cache, 'reclaimable
buff/cache') and the memory bar's tooltip carried a 'Shown in Proxmox'
row explaining why Pulse's percentage reads lower than the Proxmox UI's
cache-inclusive number — a recurring support question. The v6 rebuild
deleted the field from the backend model, so the split and the
reconciliation vanished product-wide. Flagged by the Proxmox overview
parity audit.

Backend: re-add Memory.Cache; split it out via a shared
splitReclaimableMemory helper at the node resolver (node status reports
truly-free directly) and the VM builder (when guest meminfo reported
free pages); transport as proxmox.memoryCache on unified resources
alongside swap/balloon; mock mode populates plausible cache for nodes
and VMs.

Frontend: cache prop on StackedMemoryBar with the v5 muted-amber
segment between active and balloon, tooltip rows for reclaimable cache,
truly-free Free (balloon-capped), and the 'Shown in Proxmox'
reconciliation; guest and node memory adapters normalize free to
truly-free at the boundary; guest and node drawers grow a Reclaimable
cache row; the Proxmox nodes table passes cache and node swap through.
This commit is contained in:
rcourtman 2026-06-11 19:42:40 +01:00
parent 40058fbc49
commit f62f35e24d
18 changed files with 263 additions and 30 deletions

View file

@ -351,6 +351,7 @@ export function GuestRow(props: GuestRowProps) {
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
percentOnly={memoryPercentOnly()}
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

@ -246,6 +246,14 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
)}`,
},
{ label: 'Total', value: formatBytes(props.node.memory?.total || 0) },
...(props.node.memory?.cache
? [
{
label: 'Reclaimable cache',
value: formatBytes(props.node.memory.cache),
} satisfies NodeOverviewRow,
]
: []),
{ label: 'Free', value: formatBytes(props.node.memory?.free || 0) },
...(props.node.memory?.swapTotal
? [

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel';
const GiB = 1024 ** 3;
describe('buildStackedMemoryBarPresentation', () => {
it('renders the v5 used | reclaimable cache split with the Proxmox reconciliation row', () => {
const presentation = buildStackedMemoryBarPresentation(
{ used: 4 * GiB, total: 16 * GiB, cache: 6 * GiB },
400,
);
expect(presentation.segments.map((segment) => segment.label)).toEqual([
'Active',
'Reclaimable',
]);
expect(presentation.segments[1].leftPercent).toBeCloseTo(25);
expect(presentation.segments[1].widthPercent).toBeCloseTo(37.5);
const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value]));
expect(rows['Used']).toBe('4.00 GB');
expect(rows['Reclaimable cache']).toBe('6.00 GB');
// Truly free excludes the reclaimable cache: 16 - 4 - 6.
expect(rows['Free']).toBe('6.00 GB');
// Proxmox counts cache as used: (4 + 6) / 16.
expect(rows['Shown in Proxmox']).toBe('63%');
});
it('keeps the cache segment between active and the balloon limit', () => {
const presentation = buildStackedMemoryBarPresentation(
{ used: 4 * GiB, total: 16 * GiB, cache: 2 * GiB, balloon: 8 * GiB },
400,
);
expect(presentation.segments.map((segment) => segment.label)).toEqual([
'Active',
'Reclaimable',
'Balloon',
]);
const balloonSegment = presentation.segments[2];
expect(balloonSegment.leftPercent).toBeCloseTo(37.5);
expect(balloonSegment.widthPercent).toBeCloseTo(12.5);
const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value]));
// Ballooning caps the usable ceiling: free = 8 - 4 - 2.
expect(rows['Free']).toBe('2.00 GB');
});
it('matches the pre-cache layout when no cache is reported', () => {
const presentation = buildStackedMemoryBarPresentation(
{ used: 4 * GiB, total: 16 * GiB },
400,
);
expect(presentation.segments.map((segment) => segment.label)).toEqual(['Active']);
const labels = presentation.tooltipRows.map((row) => row.label);
expect(labels).toEqual(['Used', 'Free']);
const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value]));
expect(rows['Free']).toBe('12.0 GB');
});
});

View file

@ -298,10 +298,14 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] =
const rows: GuestDrawerMemoryRow[] = [];
const total = memory.total ?? 0;
const used = memory.used ?? 0;
const cache = memory.cache ?? 0;
if (total > 0) {
rows.push({ label: 'Usage', value: `${formatPercent((used / total) * 100)} · ${formatBytes(used)}` });
rows.push({ label: 'Total', value: formatBytes(total) });
if (cache > 0) {
rows.push({ label: 'Reclaimable cache', value: formatBytes(cache) });
}
if (typeof memory.free === 'number') {
rows.push({ label: 'Free', value: formatBytes(memory.free) });
}

View file

@ -13,6 +13,8 @@ export interface StackedMemoryBarProps {
used: number;
total: number;
percentOnly?: number;
/** Reclaimable buff/cache (available - truly free); used + cache + free ≈ total. */
cache?: number;
swapUsed?: number;
swapTotal?: number;
balloon?: number;
@ -52,6 +54,8 @@ export interface StackedMemoryBarPresentation {
const MEMORY_COLORS = {
active: 'rgba(34, 197, 94, 0.6)',
// Muted amber: reclaimable buff/cache, matching the v5 segment tone.
cache: 'rgba(251, 191, 36, 0.45)',
balloon: 'rgba(59, 130, 246, 0.6)',
swap: 'rgba(168, 85, 247, 0.6)',
};
@ -87,20 +91,8 @@ function getSegments(
const balloon = props.balloon || 0;
const hasActiveBallooning = balloon > 0 && balloon < props.total;
const usedPercent = (props.used / props.total) * 100;
if (!hasActiveBallooning) {
if (props.used <= 0) {
return [];
}
return [
{
color: getMetricColorRgba(usedPercent, 'memory', props.thresholds),
label: 'Active',
leftPercent: 0,
widthPercent: usedPercent,
},
];
}
const cache = props.cache || 0;
const cachePercent = (cache / props.total) * 100;
const segments: StackedMemorySegment[] = [];
if (props.used > 0) {
@ -112,16 +104,32 @@ function getSegments(
});
}
const balloonLimitPercent = Math.max(0, (balloon / props.total) * 100 - usedPercent);
if (balloonLimitPercent > 0 && balloon > props.used) {
// Reclaimable buff/cache rides between active and the balloon limit, like v5.
if (cache > 0) {
segments.push({
color: MEMORY_COLORS.balloon,
label: 'Balloon',
color: MEMORY_COLORS.cache,
label: 'Reclaimable',
leftPercent: usedPercent,
widthPercent: balloonLimitPercent,
widthPercent: cachePercent,
});
}
if (hasActiveBallooning) {
const usedPlusCache = props.used + cache;
const balloonLimitPercent = Math.max(
0,
(balloon / props.total) * 100 - usedPercent - cachePercent,
);
if (balloonLimitPercent > 0 && balloon > usedPlusCache) {
segments.push({
color: MEMORY_COLORS.balloon,
label: 'Balloon',
leftPercent: usedPercent + cachePercent,
widthPercent: balloonLimitPercent,
});
}
}
return segments;
}
@ -131,6 +139,7 @@ function getTooltipRows(
): StackedMemoryTooltipRow[] {
const rows: StackedMemoryTooltipRow[] = [];
const balloon = props.balloon || 0;
const cache = props.cache || 0;
const hasActiveBallooning = props.total > 0 && balloon > 0 && balloon < props.total;
const hasSwap = (props.swapTotal || 0) > 0;
@ -142,6 +151,15 @@ function getTooltipRows(
value: formatBytes(props.used),
});
if (cache > 0) {
rows.push({
borderTop: true,
label: 'Reclaimable cache',
labelClass: 'text-amber-400',
value: formatBytes(cache),
});
}
if (hasActiveBallooning) {
rows.push({
borderTop: true,
@ -151,12 +169,26 @@ function getTooltipRows(
});
}
// Truly free pages exclude the reclaimable cache; capped at the balloon
// limit when ballooning is active (the guest cannot use past it).
const ceiling = hasActiveBallooning ? balloon : props.total;
rows.push({
borderTop: true,
label: 'Free',
labelClass: 'text-slate-400',
value: formatBytes(props.total - props.used),
value: formatBytes(Math.max(0, ceiling - props.used - cache)),
});
// Proxmox's UI counts reclaimable cache as used; this row explains why
// Pulse's percentage reads lower than the same guest in Proxmox.
if (cache > 0) {
rows.push({
borderTop: true,
label: 'Shown in Proxmox',
labelClass: 'text-slate-500 italic',
value: formatPercent(((props.used + cache) / props.total) * 100),
});
}
} else {
rows.push({
borderTop: true,

View file

@ -474,6 +474,9 @@ export const ProxmoxNodesTable: Component<{
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
cache={drawerNode()?.memory?.cache || 0}
swapUsed={drawerNode()?.memory?.swapUsed || 0}
swapTotal={drawerNode()?.memory?.swapTotal || 0}
/>
</Show>
}

View file

@ -93,6 +93,7 @@ type APIResource = {
swapUsed?: number;
swapTotal?: number;
balloon?: number;
memoryCache?: number;
isOci?: boolean;
osTemplate?: string;
osName?: string;
@ -455,8 +456,13 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
cpus: resource.proxmox?.cpus ?? 1,
memory: (() => {
const base = buildMetric(resource.metrics?.memory);
const cache = resource.proxmox?.memoryCache ?? 0;
return {
...base,
// buildMetric derives free as total-used (available); carve the
// reclaimable cache back out so free means truly-free pages.
free: Math.max(0, base.free - cache),
cache,
swapUsed: resource.proxmox?.swapUsed ?? 0,
swapTotal: resource.proxmox?.swapTotal ?? 0,
balloon: resource.proxmox?.balloon ?? 0,

View file

@ -1000,6 +1000,8 @@ export interface Memory {
total: number;
used: number;
free: number;
/** Reclaimable buff/cache (available - truly free); used + cache + free ≈ total. */
cache?: number;
usage: number;
balloon?: number;
swapUsed?: number;

View file

@ -617,6 +617,9 @@ export interface ResourceProxmoxMeta {
swapUsed?: number;
swapTotal?: number;
balloon?: number;
/** Reclaimable buff/cache split out of the memory metric's free bytes. */
memoryCache?: number;
pendingUpdates?: number;
isOci?: boolean;
osTemplate?: string;
pveVersion?: string;

View file

@ -774,10 +774,18 @@ export const mergeCanonicalResourceSnapshot = (
);
};
const buildMemory = (metric: Resource['memory'], fallback?: Record<string, unknown>): Memory => {
const buildMemory = (
metric: Resource['memory'],
fallback?: Record<string, unknown>,
proxmoxMeta?: Record<string, unknown>,
): Memory => {
const total = metric?.total ?? asNumber(fallback?.total) ?? 0;
const used = metric?.used ?? asNumber(fallback?.used) ?? 0;
const free = metric?.free ?? asNumber(fallback?.free) ?? Math.max(total - used, 0);
const cache = asNumber(proxmoxMeta?.memoryCache) ?? asNumber(fallback?.cache) ?? 0;
// The metric ships no free bytes for PVE payloads; total-used is the
// reclaimable-inclusive available, so carve the cache back out when known.
const free =
metric?.free ?? asNumber(fallback?.free) ?? Math.max(total - used - cache, 0);
const usage =
metric?.current ?? (total > 0 ? (used / total) * 100 : (asNumber(fallback?.usage) ?? 0));
return {
@ -785,8 +793,9 @@ const buildMemory = (metric: Resource['memory'], fallback?: Record<string, unkno
used,
free,
usage,
swapUsed: asNumber(fallback?.swapUsed),
swapTotal: asNumber(fallback?.swapTotal),
cache: cache > 0 ? cache : undefined,
swapUsed: asNumber(proxmoxMeta?.swapUsed) ?? asNumber(fallback?.swapUsed),
swapTotal: asNumber(proxmoxMeta?.swapTotal) ?? asNumber(fallback?.swapTotal),
balloon: asNumber(fallback?.balloon),
};
};
@ -933,7 +942,7 @@ export const nodeFromResource = (resource: Resource): Node | null => {
status: resource.status || 'unknown',
type: resource.type,
cpu: resource.cpu?.current ?? 0,
memory: buildMemory(resource.memory, asRecord(proxmox?.memory)),
memory: buildMemory(resource.memory, asRecord(proxmox?.memory), proxmox),
disk: buildDisk(resource.disk, asRecord(proxmox?.disk)),
networkIn: resource.network?.rxBytes,
networkOut: resource.network?.txBytes,

View file

@ -873,7 +873,10 @@ func generateNode(name string, highLoad bool, config MockConfig) models.Node {
Memory: models.Memory{
Total: totalMem * 1024 * 1024 * 1024, // Convert to bytes
Used: usedMem * 1024 * 1024 * 1024,
Free: (totalMem - usedMem) * 1024 * 1024 * 1024,
// Roughly a third of the non-used pages read as reclaimable
// buff/cache so the node memory split is exercisable in mock mode.
Cache: (totalMem - usedMem) / 3 * 1024 * 1024 * 1024,
Free: ((totalMem - usedMem) - (totalMem-usedMem)/3) * 1024 * 1024 * 1024,
Usage: float64(usedMem) / float64(totalMem) * 100,
},
Disk: models.Disk{
@ -1116,6 +1119,12 @@ func generateVM(nodeName string, instance string, vmid int, config MockConfig) m
SwapTotal: swapTotal,
SwapUsed: swapUsed,
}
// Carve reclaimable buff/cache out of the free pages so the
// used | cache | free memory split is exercisable in mock mode.
if mem.Free > 0 {
mem.Cache = int64(float64(mem.Free) * (0.2 + rand.Float64()*0.4))
mem.Free -= mem.Cache
}
uptime = int64(3600 * (1 + rand.Intn(720))) // 1-720 hours
}

View file

@ -71,6 +71,9 @@ type Alert struct {
Acknowledged bool `json:"acknowledged"`
AckTime *time.Time `json:"ackTime,omitempty"`
AckUser string `json:"ackUser,omitempty"`
// Metadata carries alert-engine annotations (notably resourceType) so the
// frontend can classify an alert without re-deriving resource identity.
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// ResolvedAlert represents a recently resolved alert
@ -2649,9 +2652,11 @@ type PMGQueueStatus struct {
// Memory represents memory usage
type Memory struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
Free int64 `json:"free"`
Total int64 `json:"total"`
Used int64 `json:"used"`
Free int64 `json:"free"`
// Reclaimable buff/cache (available - truly free); used + cache + free ≈ total.
Cache int64 `json:"cache,omitempty"`
Usage float64 `json:"usage"`
Balloon int64 `json:"balloon,omitempty"`
SwapUsed int64 `json:"swapUsed,omitempty"`

View file

@ -0,0 +1,21 @@
package monitoring
import "github.com/rcourtman/pulse-go-rewrite/internal/models"
// splitReclaimableMemory splits the reclaimable page cache out of a memory
// snapshot whose Free currently holds "available" (Total - Used, with Used
// already excluding cache). trulyFree is the node/guest-reported free page
// count; when it is known and smaller than Free, the gap is reclaimable
// buff/cache and the snapshot becomes used | cache | free, which is what the
// frontend memory bar needs to reconcile Pulse's percentage with the
// cache-inclusive number the Proxmox UI shows.
func splitReclaimableMemory(memory *models.Memory, trulyFree uint64) {
if memory == nil || trulyFree == 0 {
return
}
if memory.Free <= 0 || int64(trulyFree) >= memory.Free {
return
}
memory.Cache = memory.Free - int64(trulyFree)
memory.Free = int64(trulyFree)
}

View file

@ -0,0 +1,55 @@
package monitoring
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestSplitReclaimableMemory(t *testing.T) {
t.Run("splits cache out of available-based free", func(t *testing.T) {
memory := models.Memory{Total: 16, Used: 6, Free: 10, Usage: 37.5}
splitReclaimableMemory(&memory, 4)
if memory.Cache != 6 {
t.Fatalf("Cache = %d, want 6", memory.Cache)
}
if memory.Free != 4 {
t.Fatalf("Free = %d, want 4", memory.Free)
}
if memory.Used != 6 || memory.Total != 16 {
t.Fatalf("Used/Total mutated: %+v", memory)
}
if got := memory.Used + memory.Cache + memory.Free; got != memory.Total {
t.Fatalf("used+cache+free = %d, want %d", got, memory.Total)
}
})
t.Run("no-op when truly free is unknown", func(t *testing.T) {
memory := models.Memory{Total: 16, Used: 6, Free: 10}
splitReclaimableMemory(&memory, 0)
if memory.Cache != 0 || memory.Free != 10 {
t.Fatalf("unexpected split: %+v", memory)
}
})
t.Run("no-op when truly free is not smaller than free", func(t *testing.T) {
memory := models.Memory{Total: 16, Used: 6, Free: 10}
splitReclaimableMemory(&memory, 10)
if memory.Cache != 0 || memory.Free != 10 {
t.Fatalf("unexpected split: %+v", memory)
}
splitReclaimableMemory(&memory, 12)
if memory.Cache != 0 || memory.Free != 10 {
t.Fatalf("unexpected split with oversized trulyFree: %+v", memory)
}
})
t.Run("no-op on nil or non-positive free", func(t *testing.T) {
splitReclaimableMemory(nil, 4)
memory := models.Memory{Total: 16, Used: 16, Free: 0}
splitReclaimableMemory(&memory, 4)
if memory.Cache != 0 {
t.Fatalf("unexpected split on zero free: %+v", memory)
}
})
}

View file

@ -332,6 +332,9 @@ func (m *Monitor) buildVMFromClusterResource(
if memory.Used > memory.Total {
memory.Used = memory.Total
}
// Free above is total-used, i.e. available. When the guest reported its
// truly-free pages (meminfo), split the reclaimable cache back out.
splitReclaimableMemory(&memory, state.guestRaw.MemInfoFree)
if state.detailedStatus != nil && state.detailedStatus.Balloon > 0 {
memory.Balloon = int64(state.detailedStatus.Balloon)
}

View file

@ -202,6 +202,9 @@ func (m *Monitor) resolveNodeMemory(
Free: free,
Usage: safePercentage(float64(actualUsed), float64(memory.Total)),
}
// Free above is total-used, i.e. available. The node status reports its
// truly-free pages directly, so split the reclaimable cache back out.
splitReclaimableMemory(&resolved, memory.Free)
return resolved, source, fallbackReason, raw, true
}

View file

@ -63,6 +63,9 @@ func resourceFromProxmoxNode(node models.Node, linkedHost *models.Host) (Resourc
PendingUpdates: node.PendingUpdates,
TemperatureMonitoringEnabled: cloneBoolPtr(node.TemperatureMonitoringEnabled),
PendingUpdatesCheckedAt: zeroTimeToPtr(node.PendingUpdatesCheckedAt),
MemoryCache: node.Memory.Cache,
SwapUsed: node.Memory.SwapUsed,
SwapTotal: node.Memory.SwapTotal,
LinkedAgentID: linkedAgentID,
}
@ -1509,6 +1512,7 @@ func resourceFromVM(vm models.VM) (Resource, ResourceIdentity) {
SwapUsed: vm.Memory.SwapUsed,
SwapTotal: vm.Memory.SwapTotal,
Balloon: vm.Memory.Balloon,
MemoryCache: vm.Memory.Cache,
Lock: vm.Lock,
}
resource := Resource{
@ -1553,6 +1557,7 @@ func resourceFromContainer(ct models.Container) (Resource, ResourceIdentity) {
SwapUsed: ct.Memory.SwapUsed,
SwapTotal: ct.Memory.SwapTotal,
Balloon: ct.Memory.Balloon,
MemoryCache: ct.Memory.Cache,
Lock: ct.Lock,
}
resource := Resource{

View file

@ -354,7 +354,9 @@ type ProxmoxData struct {
SwapUsed int64 `json:"swapUsed,omitempty"`
SwapTotal int64 `json:"swapTotal,omitempty"`
Balloon int64 `json:"balloon,omitempty"`
Lock string `json:"lock,omitempty"` // Proxmox lock state (e.g. "backup", "migrate", "snapshot")
// Reclaimable buff/cache split out of the memory metric's free bytes.
MemoryCache int64 `json:"memoryCache,omitempty"`
Lock string `json:"lock,omitempty"` // Proxmox lock state (e.g. "backup", "migrate", "snapshot")
// Internal link hint to a host agent resource.
LinkedAgentID string `json:"-"`
}