mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +00:00
fix(dashboard): use balloon-adjusted memory percent on the Memory tile (#1847)
* fix(dashboard): use balloon-adjusted memory percent on the Memory tile When balloon fields are present, the Memory tile hero, tone, and gauge bar now use effectiveUsagePercent so they match the used/total bytes. Health and host RAM alerts still use working-set usagePercent. * fix(dashboard): align health banner and Memory tile on balloon-adjusted memory The health banner read the raw working-set percent while the Memory tile showed the balloon-adjusted one, so a hypervisor ballooning an otherwise healthy VM could flag it critical. Both now use effectiveUsagePercent, falling back to the raw percent when balloon fields are absent. Host RAM alerts keep the working-set percent: ballooned pages are reclaimed by the hypervisor and cannot be recovered by the guest on demand. Adds guarded context lines to the Memory tile: Current VM Memory (guest retained), Current pressure (effective used over retained), Balloon reclaimable, and, for ZFS nodes, Current memory in use (used plus ARC reclaimable) above ZFS ARC reclaimable. Validated with unit tests for both components including boundary cases, type checks, lint, and visual regression comparison confirming no change outside the Memory tile.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ResourceGauges } from './ResourceGauges';
|
||||
import type { SystemStats } from './types';
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
const MiB = 1024 ** 2;
|
||||
|
||||
function baseStats(memory: SystemStats['memory']): SystemStats {
|
||||
return {
|
||||
cpu: { usage: '4.0', cores: 8 },
|
||||
memory,
|
||||
disk: { fs: '/', mount: '/', total: 100 * GiB, used: 40 * GiB, free: 60 * GiB, usagePercent: '40' },
|
||||
};
|
||||
}
|
||||
|
||||
// Fixture mirrors the backend contract (adjustForBalloon): effectiveTotal ===
|
||||
// total and effectiveUsed === max(used - ballooned, 0). Numbers follow the
|
||||
// issue report: 753.8 MB effective of a 5.3 GB VM with 4 GB ballooned.
|
||||
function balloonedMemory(opts: { total: number; effectiveUsed: number; ballooned: number }): SystemStats['memory'] {
|
||||
const { total, effectiveUsed, ballooned } = opts;
|
||||
const used = Math.min(effectiveUsed + ballooned, total);
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
free: Math.max(total - used, 0),
|
||||
usagePercent: ((used / total) * 100).toFixed(1),
|
||||
ballooned,
|
||||
effectiveUsed,
|
||||
effectiveTotal: total,
|
||||
effectiveFree: Math.min(total - effectiveUsed, total),
|
||||
effectiveUsagePercent: ((effectiveUsed / total) * 100).toFixed(1),
|
||||
};
|
||||
}
|
||||
|
||||
function memoryTile(): HTMLElement {
|
||||
const label = screen.getByText('MEMORY');
|
||||
const tile = label.parentElement;
|
||||
if (!tile) throw new Error('MEMORY tile parent missing');
|
||||
return tile;
|
||||
}
|
||||
|
||||
function memoryHero(tile: HTMLElement): HTMLElement {
|
||||
const hero = tile.querySelector('.text-2xl');
|
||||
if (!(hero instanceof HTMLElement)) throw new Error('MEMORY hero missing');
|
||||
return hero;
|
||||
}
|
||||
|
||||
function memoryBar(tile: HTMLElement): HTMLElement | null {
|
||||
return tile.querySelector('.h-1 > div');
|
||||
}
|
||||
|
||||
describe('ResourceGauges memory tile', () => {
|
||||
it('shows balloon context lines and uses the adjusted percent for hero, tone, and bar', () => {
|
||||
// Rendered values derived from this fixture: effective 13.9% -> hero 14%,
|
||||
// retained 1.3 GB, pressure 753.8 MB / 1.3 GB = 57%.
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={baseStats(balloonedMemory({ total: 5.3 * GiB, effectiveUsed: 753.8 * MiB, ballooned: 4 * GiB }))}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
const hero = memoryHero(tile);
|
||||
expect(hero.textContent).toBe('14%');
|
||||
expect(hero.className).toContain('text-stat-value');
|
||||
expect(hero.className).not.toContain('text-destructive');
|
||||
expect(hero.className).not.toContain('text-warning');
|
||||
|
||||
expect(tile.textContent).toContain('753.8 MB / 5.3 GB');
|
||||
expect(tile.textContent).toMatch(/Current VM Memory: 1\.3 GB/);
|
||||
expect(tile.textContent).toMatch(/Current pressure: 57%/);
|
||||
expect(tile.textContent).toContain('Balloon reclaimable: 4 GB');
|
||||
expect(tile.textContent).not.toContain('Ballooned to host');
|
||||
expect(tile.textContent).not.toMatch(/effective\s+14%/i);
|
||||
expect(tile.textContent).not.toMatch(/NaN/);
|
||||
|
||||
const bar = memoryBar(tile);
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar?.style.width).toBe('13.9%');
|
||||
expect(bar?.style.backgroundColor).toBe('var(--brand)');
|
||||
});
|
||||
|
||||
it('hides retained-memory lines when the denominator is nonpositive but keeps the reclaimable line', () => {
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={baseStats(balloonedMemory({ total: 2 * GiB, effectiveUsed: 0, ballooned: 4 * GiB }))}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
expect(tile.textContent).not.toContain('Current VM Memory:');
|
||||
expect(tile.textContent).not.toContain('Current pressure:');
|
||||
expect(tile.textContent).toContain('Balloon reclaimable: 4 GB');
|
||||
expect(tile.textContent).not.toMatch(/NaN/);
|
||||
});
|
||||
|
||||
it('treats ballooned equal to total as fully reclaimed and hides retained-memory lines', () => {
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={baseStats(balloonedMemory({ total: 2 * GiB, effectiveUsed: 0, ballooned: 2 * GiB }))}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
expect(tile.textContent).not.toContain('Current VM Memory:');
|
||||
expect(tile.textContent).not.toContain('Current pressure:');
|
||||
expect(tile.textContent).toContain('Balloon reclaimable: 2 GB');
|
||||
});
|
||||
|
||||
it('renders zero pressure when the guest uses none of its retained memory', () => {
|
||||
// Retained 1.5 GiB, nothing effectively used: pressure line must read 0%.
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={baseStats(balloonedMemory({ total: 4 * GiB, effectiveUsed: 0, ballooned: 2.5 * GiB }))}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
expect(tile.textContent).toMatch(/Current VM Memory: 1\.5 GB/);
|
||||
expect(tile.textContent).toMatch(/Current pressure: 0%/);
|
||||
expect(tile.textContent).toContain('Balloon reclaimable: 2.5 GB');
|
||||
expect(tile.textContent).not.toMatch(/NaN/);
|
||||
});
|
||||
|
||||
it('omits pressure but shows retained bytes when effectiveUsed is absent', () => {
|
||||
const memory = balloonedMemory({ total: 5.3 * GiB, effectiveUsed: 753.8 * MiB, ballooned: 4 * GiB });
|
||||
delete memory.effectiveUsed;
|
||||
delete memory.effectiveUsagePercent;
|
||||
render(
|
||||
<ResourceGauges systemStats={baseStats(memory)} cpuHistory={[]} netHistory={[]} historyEndAt={null} />,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
// Hero falls back to the raw working-set percent (89.4 -> 89).
|
||||
expect(memoryHero(tile).textContent).toBe('89%');
|
||||
expect(tile.textContent).toMatch(/Current VM Memory: 1\.3 GB/);
|
||||
expect(tile.textContent).not.toContain('Current pressure:');
|
||||
expect(tile.textContent).toContain('Balloon reclaimable: 4 GB');
|
||||
expect(tile.textContent).not.toMatch(/NaN/);
|
||||
});
|
||||
|
||||
it('renders ZFS raw current-memory line as used plus arcReclaimable', () => {
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={baseStats({
|
||||
total: 125.5 * GiB,
|
||||
used: 19.1 * GiB,
|
||||
free: 106.4 * GiB,
|
||||
usagePercent: '15.2',
|
||||
arcReclaimable: 98.1 * GiB,
|
||||
})}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
// Raw in use is reconstructed (used + reclaimable), not repeated from used.
|
||||
expect(tile.textContent).toMatch(/Current memory in use: 117\.2 GB/);
|
||||
expect(tile.textContent).toContain('ZFS ARC reclaimable: 98.1 GB');
|
||||
expect(tile.textContent).not.toContain('Current pressure:');
|
||||
expect(tile.textContent).not.toContain('Balloon reclaimable');
|
||||
expect(tile.textContent).not.toMatch(/NaN/);
|
||||
});
|
||||
|
||||
it('renders balloon and ZFS context blocks independently', () => {
|
||||
const memory = {
|
||||
...balloonedMemory({ total: 5.3 * GiB, effectiveUsed: 753.8 * MiB, ballooned: 4 * GiB }),
|
||||
arcReclaimable: 98.1 * GiB,
|
||||
};
|
||||
render(
|
||||
<ResourceGauges systemStats={baseStats(memory)} cpuHistory={[]} netHistory={[]} historyEndAt={null} />,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
expect(tile.textContent).toMatch(/Current VM Memory: 1\.3 GB/);
|
||||
expect(tile.textContent).toMatch(/Current pressure: 57%/);
|
||||
expect(tile.textContent).toContain('Balloon reclaimable: 4 GB');
|
||||
expect(tile.textContent).toContain('ZFS ARC reclaimable: 98.1 GB');
|
||||
// used (4.7 GiB raw working set, before balloon subtraction) plus reclaimable.
|
||||
expect(tile.textContent).toMatch(/Current memory in use: 102\.8 GB/);
|
||||
});
|
||||
|
||||
it('falls back to raw usagePercent when balloon fields are absent', () => {
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={baseStats({
|
||||
total: 16 * GiB,
|
||||
used: 14.4 * GiB,
|
||||
free: 1.6 * GiB,
|
||||
usagePercent: '90',
|
||||
})}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
const hero = memoryHero(tile);
|
||||
expect(hero.textContent).toBe('90%');
|
||||
expect(hero.className).toContain('text-destructive');
|
||||
expect(tile.textContent).toContain('14.4 GB / 16 GB');
|
||||
expect(tile.textContent).not.toContain('Balloon reclaimable');
|
||||
expect(tile.textContent).not.toContain('Current memory in use');
|
||||
|
||||
const bar = memoryBar(tile);
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar?.style.width).toBe('90%');
|
||||
expect(bar?.style.backgroundColor).toBe('var(--destructive)');
|
||||
});
|
||||
|
||||
it('renders a placeholder hero and no bar when stats are missing', () => {
|
||||
render(
|
||||
<ResourceGauges
|
||||
systemStats={null}
|
||||
cpuHistory={[]}
|
||||
netHistory={[]}
|
||||
historyEndAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = memoryTile();
|
||||
expect(memoryHero(tile).textContent).toBe('--');
|
||||
expect(memoryBar(tile)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -49,13 +49,21 @@ 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 memory = systemStats?.memory;
|
||||
// Balloon-adjusted percent so hero, tone, bar, and used/total agree with the
|
||||
// shared health verdict. Host RAM alerts still read working-set usagePercent.
|
||||
const ramVal = parseFloat(memory?.effectiveUsagePercent ?? memory?.usagePercent ?? '0');
|
||||
const ramUsed = memory?.effectiveUsed ?? memory?.used ?? 0;
|
||||
const ramTotal = memory?.effectiveTotal ?? memory?.total ?? 0;
|
||||
const ramBallooned = memory?.ballooned ?? 0;
|
||||
const ramArcReclaimable = memory?.arcReclaimable ?? 0;
|
||||
// Memory retained by the guest after hypervisor reclaim, with pressure as
|
||||
// effective used over that retained amount. The retained line needs a
|
||||
// positive value; pressure additionally needs the balloon-adjusted numerator
|
||||
// (never raw used).
|
||||
const vmRetained = ramTotal - ramBallooned;
|
||||
// Call sites are already inside the ramBallooned > 0 block.
|
||||
const showVmRetained = vmRetained > 0;
|
||||
const diskVal = parseFloat(systemStats?.disk?.usagePercent || '0');
|
||||
|
||||
const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0;
|
||||
@@ -110,15 +118,21 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{systemStats ? `${formatBytes(ramUsed)} / ${formatBytes(ramTotal)}` : '\u00A0'}
|
||||
</div>
|
||||
{systemStats?.memory.ballooned && systemStats.memory.ballooned > 0 ? (
|
||||
{ramBallooned > 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)}%)` : ''}
|
||||
{showVmRetained ? (
|
||||
<div>Current VM Memory: {formatBytes(vmRetained)}</div>
|
||||
) : null}
|
||||
{showVmRetained && memory?.effectiveUsed !== undefined ? (
|
||||
<div>Current pressure: {((memory.effectiveUsed / vmRetained) * 100).toFixed(0)}%</div>
|
||||
) : null}
|
||||
<div>Balloon reclaimable: {formatBytes(ramBallooned)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{systemStats?.memory.arcReclaimable && systemStats.memory.arcReclaimable > 0 ? (
|
||||
{memory && ramArcReclaimable > 0 ? (
|
||||
<div className="mt-1 font-mono text-[10px] text-stat-subtitle/70">
|
||||
ZFS ARC reclaimable: {formatBytes(systemStats.memory.arcReclaimable)}
|
||||
<div>Current memory in use: {formatBytes(memory.used + ramArcReclaimable)}</div>
|
||||
<div>ZFS ARC reclaimable: {formatBytes(ramArcReclaimable)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{systemStats ? <GaugeBar value={ramVal} /> : null}
|
||||
|
||||
@@ -6,7 +6,8 @@ const stats = (over: Partial<Stats> = {}): Stats => ({
|
||||
active: 5, managed: 5, unmanaged: 0, exited: 0, total: 5, ...over,
|
||||
});
|
||||
|
||||
// deriveHealth only reads cpu.usage, memory.usagePercent and disk?.usagePercent.
|
||||
// deriveHealth only reads cpu.usage, memory.effectiveUsagePercent (falling back
|
||||
// to memory.usagePercent) and disk?.usagePercent.
|
||||
const sys = (cpu: string, ram: string, disk: string | null): SystemStats => ({
|
||||
cpu: { usage: cpu, cores: 8 },
|
||||
memory: { total: 100, used: 50, free: 50, usagePercent: ram },
|
||||
@@ -64,4 +65,50 @@ describe('deriveHealth', () => {
|
||||
expect(deriveHealth(stats(), null, []).level).toBe('healthy');
|
||||
expect(deriveHealth(stats(), sys('10', '20', null), []).level).toBe('healthy');
|
||||
});
|
||||
|
||||
it('prefers the balloon-adjusted percent so health matches the Memory tile', () => {
|
||||
const system = sys('10', '90', '30');
|
||||
const r = deriveHealth(
|
||||
stats(),
|
||||
{
|
||||
...system,
|
||||
memory: {
|
||||
...system.memory,
|
||||
ballooned: 76,
|
||||
effectiveUsed: 14,
|
||||
effectiveTotal: 100,
|
||||
effectiveUsagePercent: '14',
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(r.level).toBe('healthy');
|
||||
expect(r.reasons).toEqual(['All systems nominal']);
|
||||
});
|
||||
|
||||
it('escalates on a high balloon-adjusted percent and reports the adjusted number', () => {
|
||||
const system = sys('10', '20', '30');
|
||||
const r = deriveHealth(
|
||||
stats(),
|
||||
{
|
||||
...system,
|
||||
memory: {
|
||||
...system.memory,
|
||||
ballooned: 10,
|
||||
effectiveUsed: 95,
|
||||
effectiveTotal: 100,
|
||||
effectiveUsagePercent: '95',
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(r.level).toBe('critical');
|
||||
expect(r.reasons).toContain('RAM 95%');
|
||||
});
|
||||
|
||||
it('falls back to raw usagePercent when balloon fields are absent', () => {
|
||||
const r = deriveHealth(stats(), sys('10', '90', '30'), []);
|
||||
expect(r.level).toBe('critical');
|
||||
expect(r.reasons).toContain('RAM 90%');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,12 @@ export interface HealthResult {
|
||||
// drift apart.
|
||||
export function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult {
|
||||
const cpu = parseFloat(systemStats?.cpu.usage || '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');
|
||||
// Health follows the same balloon-adjusted percent the Memory tile shows so
|
||||
// the verdict never contradicts the gauge. Host RAM alerts (MonitorService)
|
||||
// deliberately keep the raw working-set usagePercent: unlike ARC, ballooned
|
||||
// pages are host-reclaimed and the guest cannot get them back on demand, so
|
||||
// a hypervisor squeezing a healthy-looking VM must still fire an alert.
|
||||
const ram = parseFloat(systemStats?.memory.effectiveUsagePercent ?? systemStats?.memory.usagePercent ?? '0');
|
||||
const disk = parseFloat(systemStats?.disk?.usagePercent || '0');
|
||||
const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user