mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
5498575b8f
commit
7dab977d91
8 changed files with 82 additions and 21 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue