)}
+ item.deferred)}>
+ {(item) => (
+
+ {item.label} live reading: {item.deferred?.message}
+
+ )}
+
+
{(tick) => (
@@ -720,6 +747,7 @@ export const GuestDrawerHistory: Component = (props) =>
loading={historyQuery.loading() && !historyQuery.resolvedOnce()}
metrics={metrics()}
currentMetrics={props.currentMetrics}
+ deferredMetrics={props.deferredMetrics}
range={props.range}
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
timeBounds={timeBounds()}
diff --git a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx
index b11f2534e1..441f7b36e7 100644
--- a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx
@@ -168,6 +168,27 @@ describe('DiskList', () => {
});
describe('usage calculation', () => {
+ it('withdraws one incomplete filesystem reading without losing another mount or inventing zero', () => {
+ const stable = makeDisk({ mountpoint: '/stable', used: 26843545600 });
+ const changing = makeDisk({ mountpoint: '/changing', device: '/dev/sda2' });
+ const [disks, update] = createSignal([stable, changing]);
+ render(() => );
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText('50%')).toBeInTheDocument();
+
+ update([stable, { ...changing, used: undefined }]);
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText('/changing').parentElement).toHaveTextContent('?/100 GB');
+ expect(screen.getByText('—')).toBeInTheDocument();
+ expect(screen.queryByText('0%')).not.toBeInTheDocument();
+ expect(screen.queryByText('50%')).not.toBeInTheDocument();
+
+ update([stable, { ...changing, used: 0, usage: undefined }]);
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ expect(screen.queryByText('—')).not.toBeInTheDocument();
+ });
+
it('derives usage percent through the canonical disk-list model', () => {
const disk = makeDisk({ used: 26843545600, total: 107374182400 });
expect(getWorkloadsDiskUsagePercent(disk)).toBe(25);
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx
new file mode 100644
index 0000000000..6c1bcb45e5
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx
@@ -0,0 +1,261 @@
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
+import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
+import type { WorkloadGuest } from '@/types/workloads';
+import { GuestDrawerHistory } from '../GuestDrawerHistory';
+import { getGuestDrawerCurrentMetrics, getGuestDrawerDeferredMetrics } from '../guestDrawerModel';
+import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
+
+vi.mock('@/stores/license', () => ({
+ loadRuntimeCapabilities: vi.fn(async () => undefined),
+ maxHistoryDays: () => 90,
+ isRangeLocked: () => false,
+}));
+
+afterEach(() => {
+ cleanup();
+ resetCreateNonSuspendingQueryCacheForTest();
+ vi.restoreAllMocks();
+});
+
+const guest = (reason: string, usage = 50): WorkloadGuest => ({
+ id: 'fixture:pve-a:101',
+ vmid: 101,
+ name: 'backup-guest',
+ node: 'pve-a',
+ instance: 'fixture',
+ type: 'qemu',
+ status: 'running',
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: 100, used: 25, free: 75, usage: 25 },
+ disk: { total: 100, used: usage, usage },
+ networkIn: 100,
+ networkOut: 200,
+ diskRead: 300,
+ diskWrite: 400,
+ uptime: 3600,
+ template: false,
+ lastBackup: 0,
+ tags: [],
+ lock: '',
+ diskStatusReason: reason,
+ lastSeen: '2026-10-04T03:00:00Z',
+});
+const start = Date.UTC(2026, 9, 4, 2);
+const end = start + 60 * 60_000;
+const response = (
+ metrics: AllMetricsHistoryResponse['metrics'] = {},
+): AllMetricsHistoryResponse => ({
+ resourceType: 'vm',
+ resourceId: 'fixture:pve-a:101',
+ range: '1h',
+ start,
+ end,
+ metrics,
+ source: 'store',
+});
+const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value });
+const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve-a:101' };
+const mount = (value: () => WorkloadGuest) =>
+ render(() => (
+
+ ));
+const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
+
+// Actual backend provenance remains authoritative. Removing a VM lock alone
+// cannot turn retained readings into current data or prove native thaw.
+describe('GuestDrawerHistory filesystem provenance', () => {
+ it.each(guestDiskDeferrals)(
+ 'labels retained %s as last known without creating a stored observation',
+ async (reason, message) => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ mount(() => guest(`prev-${reason}`));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const chart = utilization();
+ expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
+ const retained = chart.querySelector('[data-history-last-known="disk"]')!;
+ expect(retained).toHaveTextContent('Disk50.0%last known');
+ expect(retained).toHaveAccessibleDescription(
+ `Disk live reading: Using last known disk stats. ${message}`,
+ );
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
+ expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent(
+ 'CPU10.0%current',
+ );
+ expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
+ 'Memory25.0%current',
+ );
+ expect(chart.querySelector('path')).toBeNull();
+ expect(chart.querySelector('[data-history-observation]')).toBeNull();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each(guestDiskDeferrals)(
+ 'shows unavailable %s without promoting an unretained numeric summary',
+ async (reason, message) => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ mount(() => guest(reason));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const chart = utilization();
+ expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
+ expect(chart.querySelector('[data-history-last-known]')).toBeNull();
+ expect(chart).toHaveTextContent('Disk-');
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(message);
+ },
+ );
+
+ it.each([-1, NaN, Infinity, undefined])(
+ 'does not display unusable retained disk usage %s',
+ (usage) => {
+ const value = guest('prev-vm-locked');
+ value.disk.usage = usage as number;
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
+ expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
+ expect(getGuestDrawerDeferredMetrics(value).disk.message).toContain('Guest reads paused');
+ },
+ );
+
+ it('keeps retained zero valid and honours explicit disk telemetry unavailability', () => {
+ const value = guest('prev-agent-busy', 0);
+ expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBe(0);
+ value.telemetryAvailability = {
+ cpu: true,
+ memory: true,
+ disk: false,
+ networkIO: true,
+ diskIO: true,
+ uptime: true,
+ };
+ expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
+ expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({
+ cpu: 10,
+ memory: 25,
+ diskread: 300,
+ diskwrite: 400,
+ });
+ });
+
+ it('keeps non-VM data independent and maps unknown reason text without exposing it', () => {
+ for (const type of ['lxc', 'app-container'] as const) {
+ const value = { ...guest('prev-vm-locked'), type };
+ expect(getGuestDrawerDeferredMetrics(value)).toEqual({});
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBe(50);
+ }
+ const value = guest('private transport error');
+ expect(getGuestDrawerDeferredMetrics(value).disk.message).toBe(
+ 'Disk stats unavailable. Guest agent may not be installed.',
+ );
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
+ });
+
+ it('keeps real stored points and dated keyboard inspection separate from live deferral', async () => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
+ response({
+ disk: [point(start, 20), point(end, 30)],
+ cpu: [point(start, 10), point(end, 15)],
+ }),
+ );
+ mount(() => guest('prev-agent-timeout'));
+ await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(2));
+ const chart = utilization();
+ expect(chart).toHaveTextContent('Disk30.0%');
+ expect(chart.querySelector('[data-history-last-known]')).toBeNull();
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
+ 'Completion is uncertain. Do not restart the guest agent during a backup.',
+ );
+ const slider = within(chart).getByRole('slider');
+ slider.focus();
+ fireEvent.input(slider, { target: { value: '0' } });
+ expect(slider).toHaveAttribute(
+ 'aria-valuetext',
+ `${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk 20.0%.`,
+ );
+ expect(chart).toHaveTextContent('Disk20.0%');
+ expect(chart.querySelector('[data-history-current]')).toBeNull();
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not borrow retained live disk evidence during another metric's dated inspection", async () => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
+ response({ cpu: [point(start, 10), point(end, 15)] }),
+ );
+ mount(() => guest('prev-vm-locked'));
+ await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
+ const slider = screen.getByRole('slider');
+ slider.focus();
+ fireEvent.input(slider, { target: { value: '0' } });
+ expect(utilization()).toHaveTextContent('Disk-');
+ expect(utilization().querySelector('[data-history-last-known]')).toBeNull();
+ expect(slider).toHaveAttribute(
+ 'aria-valuetext',
+ expect.stringContaining('Disk no observation.'),
+ );
+ fireEvent.blur(slider);
+ expect(utilization().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
+ '50.0%last known',
+ );
+ });
+
+ it('withdraws retained context only with fresh same-source data, without refetch or remount', async () => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ const [value, setValue] = createSignal(guest('prev-vm-locked'));
+ mount(value);
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const chart = utilization();
+ setValue({ ...value(), lock: '', diskStatusReason: 'prev-agent-busy' });
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
+ 'still in progress',
+ );
+ expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
+ setValue(guest('', 75));
+ expect(utilization()).toBe(chart);
+ expect(chart.querySelector('[data-history-deferred]')).toBeNull();
+ expect(chart.querySelector('[data-history-last-known]')).toBeNull();
+ expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent('75.0%current');
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not reveal retained evidence through denied History', async () => {
+ const fetch = vi
+ .spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce(response())
+ .mockRejectedValueOnce(Object.assign(new Error('private refusal'), { status: 403 }))
+ .mockResolvedValueOnce(response());
+ mount(() => guest('prev-vm-locked'));
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute(
+ 'aria-busy',
+ 'false',
+ ),
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Retry history' })).toBeInTheDocument(),
+ );
+ expect(screen.queryByTestId('guest-history-group-chart')).not.toBeInTheDocument();
+ expect(document.querySelector('[data-history-deferred]')).toBeNull();
+ expect(screen.queryByText('private refusal')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
+ await waitFor(() =>
+ expect(utilization().querySelector('[data-history-last-known]')).not.toBeNull(),
+ );
+ expect(fetch).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx
index 84e17dd262..5ca01afbf3 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx
@@ -49,6 +49,30 @@ const group = (id = 'utilization') =>
const dots = () => document.querySelectorAll('[data-history-observation]');
describe('GuestDrawerHistory sparse observations and current provenance', () => {
+ it('gives explicit deferred evidence precedence over a numeric current fallback', async () => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ render(() => (
+
+ ));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ expect(group().querySelector('[data-history-current]')).toBeNull();
+ expect(group().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
+ 'Disk50.0%last known',
+ );
+ expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription(
+ 'Disk live reading: Guest reads paused.',
+ );
+ expect(group().querySelector('path')).toBeNull();
+ expect(dots()).toHaveLength(0);
+ });
+
it.each([
['cpu', 'utilization', 'CPU 0.0%'],
['netin', 'network', 'In 0 B/s'],
diff --git a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts
index a5d0882717..f112dc6a29 100644
--- a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts
@@ -60,8 +60,8 @@ describe('diskListModel (branch coverage 0713)', () => {
});
describe('getWorkloadsDiskUsagePercent', () => {
- it('returns 0 when the disk has no capacity (false arm of the guard)', () => {
- expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBe(0);
+ it('returns null when the disk has no capacity rather than a measured zero', () => {
+ expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBeNull();
});
it('returns (used / total) * 100 for a normal ratio (true arm of the guard)', () => {
@@ -142,8 +142,8 @@ describe('diskListModel (branch coverage 0713)', () => {
);
});
- it('maps a no-capacity disk (percent 0) to the normal class', () => {
- expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe(NORMAL_CLASS);
+ it('keeps a no-capacity disk neutral rather than classifying an unmeasured zero', () => {
+ expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe('bg-surface-hover');
});
it('falls back to the default disk thresholds when thresholds is null (nullish arm)', () => {
@@ -280,7 +280,7 @@ describe('diskListModel (branch coverage 0713)', () => {
key: '/foo:/dev/sdb:5',
label: '/foo',
labelTitle: '/foo',
- progressClass: NORMAL_CLASS,
+ progressClass: 'bg-surface-hover',
progressValue: null,
progressWidth: '0%',
typeLabel: 'EXT4',
diff --git a/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx
new file mode 100644
index 0000000000..4ffbe725b5
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx
@@ -0,0 +1,164 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render, screen, within } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+
+import type { Disk, VM } from '@/types/api';
+import { DiskList } from '../DiskList';
+import { GuestDrawerOverview } from '../GuestDrawerOverview';
+import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel';
+
+const capacity = 10 * 1024 ** 3;
+const disk = (overrides: Partial = {}): Disk => ({
+ mountpoint: '/data',
+ type: 'ext4',
+ total: capacity,
+ used: capacity / 2,
+ usage: 50,
+ ...overrides,
+});
+
+const unavailable: [string, Partial][] = [
+ ['missing used bytes', { used: undefined }],
+ ['negative used bytes', { used: -1 }],
+ ['NaN used bytes', { used: NaN }],
+ ['infinite used bytes', { used: Infinity }],
+ ['negative infinite used bytes', { used: -Infinity }],
+ ['missing capacity', { total: undefined }],
+ ['zero capacity', { total: 0 }],
+ ['negative capacity', { total: -1 }],
+ ['NaN capacity', { total: NaN }],
+ ['infinite capacity', { total: Infinity }],
+ ['negative infinite capacity', { total: -Infinity }],
+ ['unknown usage sentinel', { usage: -1, used: 0 }],
+ ['NaN usage', { usage: NaN }],
+ ['infinite usage', { usage: Infinity }],
+ ['negative infinite usage', { usage: -Infinity }],
+ ['overflowing derived percentage', { total: 1, used: Number.MAX_VALUE }],
+];
+
+afterEach(cleanup);
+
+describe('filesystem readings need measured bytes, not zero defaults', () => {
+ it.each(unavailable)('withdraws a numeric reading for %s', (_name, values) => {
+ const input = disk(values);
+ const presentation = buildWorkloadsDiskPresentation(input, 0);
+ expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
+ expect(presentation.progressValue).toBeNull();
+ expect(presentation.progressWidth).toBe('0%');
+ expect(presentation.progressClass).toBe('bg-surface-hover');
+ expect(presentation.usagePercentLabel).toBe('—');
+ expect(presentation.usageText).not.toMatch(/NaN|Infinity|^0 B\//);
+ expect(presentation.label).toBe('/data');
+ expect(presentation.typeLabel).toBe('EXT4');
+ });
+
+ it('retains known capacity without inventing used bytes from a percentage or free bytes', () => {
+ const input = disk({ used: undefined, usage: 50, free: capacity / 2 });
+ expect(buildWorkloadsDiskPresentation(input, 0).usageText).toBe('?/10.0 GB');
+ expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
+ });
+
+ it('keeps an explicit measured zero, including when the redundant usage field is omitted', () => {
+ const presentation = buildWorkloadsDiskPresentation(disk({ used: 0, usage: undefined }), 0);
+ expect(presentation.progressValue).toBe(0);
+ expect(presentation.usagePercentLabel).toBe('0%');
+ expect(presentation.usageText).toBe('0 B/10.0 GB');
+ expect(presentation.progressClass).toContain('metric-normal');
+ });
+
+ it('derives ordinary and over-capacity percentages from the measured bytes', () => {
+ const input = disk({ used: capacity * 1.25, usage: 0 });
+ const presentation = buildWorkloadsDiskPresentation(input, 0);
+ expect(getWorkloadsDiskUsagePercent(disk({ usage: undefined }))).toBe(50);
+ expect(presentation.progressValue).toBe(125);
+ expect(presentation.usagePercentLabel).toBe('125%');
+ expect(presentation.progressWidth).toBe('100%');
+ expect(presentation.progressClass).toContain('metric-critical');
+ });
+});
+
+function renderPanels(initial: Disk) {
+ const [observation, setObservation] = createSignal(initial);
+ const guest = (): VM => ({
+ id: 'fixture:pve-a:101',
+ vmid: 101,
+ node: 'pve-a',
+ instance: 'fixture',
+ name: 'filesystem-evidence',
+ status: 'running',
+ type: 'qemu',
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
+ disk: observation(),
+ disks: [observation()],
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: 0,
+ tags: [],
+ lock: '',
+ lastSeen: '2026-10-04T04:00:00Z',
+ });
+ render(() => (
+ <>
+
+
+ >
+ ));
+ return setObservation;
+}
+
+describe('both filesystem panels preserve unavailable versus measured zero', () => {
+ it.each([
+ ['missing used bytes', { used: undefined }],
+ ['invalid used bytes', { used: NaN }],
+ ['invalid capacity', { total: Infinity }],
+ ['unknown usage sentinel', { usage: -1, used: 0 }],
+ ] satisfies [string, Partial][])(
+ 'does not show an empty healthy disk for %s',
+ (_name, values) => {
+ renderPanels(disk(values));
+ for (const id of ['disk-list-panel', 'guest-technical-details']) {
+ const panel = screen.getByTestId(id);
+ expect(panel.textContent).toContain('—');
+ expect(panel.textContent).not.toMatch(/0%|NaN|Infinity/);
+ expect(within(panel).queryByRole('progressbar')).not.toBeInTheDocument();
+ }
+ },
+ );
+
+ it('withdraws a prior measurement and restores zero only after explicit same-filesystem evidence', () => {
+ const update = renderPanels(disk());
+ const details = screen.getByTestId('guest-technical-details');
+ expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
+ update(disk({ used: undefined }));
+ expect(within(details).queryByRole('progressbar')).not.toBeInTheDocument();
+ expect(details).toHaveTextContent('— · ?/10.0 GB · EXT4');
+ update(disk({ used: 0, usage: undefined }));
+ expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
+ expect(details).toHaveTextContent('0% · 0 B/10.0 GB · EXT4');
+ });
+});
diff --git a/frontend-modern/src/components/Workloads/diskListModel.ts b/frontend-modern/src/components/Workloads/diskListModel.ts
index 15da466a7d..61ba4c3d28 100644
--- a/frontend-modern/src/components/Workloads/diskListModel.ts
+++ b/frontend-modern/src/components/Workloads/diskListModel.ts
@@ -22,21 +22,32 @@ export interface WorkloadsDiskPresentation {
usagePercentLabel: string;
}
-export const hasWorkloadsDiskCapacity = (disk: Disk): boolean =>
- typeof disk.total === 'number' && disk.total > 0;
+export const hasWorkloadsDiskCapacity = (disk: Disk): disk is Disk & { total: number } =>
+ typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0;
// The poller reports usage -1 for mounts it can only see in the container
// config (capacity may be known, live usage is not).
export const isWorkloadsDiskUsageUnknown = (disk: Disk): boolean =>
- typeof disk.usage === 'number' && disk.usage < 0;
+ typeof disk.used !== 'number' ||
+ !Number.isFinite(disk.used) ||
+ disk.used < 0 ||
+ (typeof disk.usage === 'number' && (!Number.isFinite(disk.usage) || disk.usage < 0));
-export const getWorkloadsDiskUsagePercent = (disk: Disk): number => {
- const total = disk.total ?? 0;
- if (total <= 0) {
- return 0;
+// Capacity alone is not a measurement. In particular, an omitted `used`
+// field must not become a healthy empty filesystem, even if usage/free exist.
+export const getWorkloadsDiskUsagePercent = (disk: Disk): number | null => {
+ const { total, used } = disk;
+ if (
+ !hasWorkloadsDiskCapacity(disk) ||
+ isWorkloadsDiskUsageUnknown(disk) ||
+ typeof total !== 'number' ||
+ typeof used !== 'number'
+ ) {
+ return null;
}
- return ((disk.used ?? 0) / total) * 100;
+ const percent = (used / total) * 100;
+ return Number.isFinite(percent) ? percent : null;
};
export const getWorkloadsDiskLabel = (disk: Disk): string =>
@@ -46,35 +57,34 @@ export const getWorkloadsDiskLabelTitle = (label: string): string | undefined =>
label !== 'Unknown' ? label : undefined;
export const getWorkloadsDiskUsageText = (disk: Disk): string => {
- if (isWorkloadsDiskUsageUnknown(disk)) {
- return hasWorkloadsDiskCapacity(disk)
- ? `?/${formatBytes(disk.total ?? 0)}`
- : 'Usage unavailable';
- }
- return hasWorkloadsDiskCapacity(disk)
- ? `${formatBytes(disk.used ?? 0)}/${formatBytes(disk.total ?? 0)}`
- : 'Usage unavailable';
+ if (!hasWorkloadsDiskCapacity(disk)) return 'Usage unavailable';
+ const used =
+ getWorkloadsDiskUsagePercent(disk) === null || typeof disk.used !== 'number'
+ ? '?'
+ : formatBytes(disk.used);
+ return `${used}/${formatBytes(disk.total)}`;
};
-export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string =>
- hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
- ? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%`
- : '—';
+export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string => {
+ const percent = getWorkloadsDiskUsagePercent(disk);
+ return percent === null ? '—' : `${percent.toFixed(0)}%`;
+};
export const getWorkloadsDiskProgressClass = (
disk: Disk,
thresholds?: MetricDisplayThresholds | null,
-): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds);
+): string => {
+ const percent = getWorkloadsDiskUsagePercent(disk);
+ return percent === null ? 'bg-surface-hover' : getMetricColorClass(percent, 'disk', thresholds);
+};
export const getWorkloadsDiskProgressValue = (disk: Disk): number | null =>
- hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
- ? getWorkloadsDiskUsagePercent(disk)
- : null;
+ getWorkloadsDiskUsagePercent(disk);
-export const getWorkloadsDiskProgressWidth = (disk: Disk): string =>
- isWorkloadsDiskUsageUnknown(disk)
- ? '0%'
- : `${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`;
+export const getWorkloadsDiskProgressWidth = (disk: Disk): string => {
+ const percent = getWorkloadsDiskUsagePercent(disk);
+ return percent === null ? '0%' : `${Math.min(percent, 100)}%`;
+};
export const getWorkloadsDiskTypeLabel = (disk: Disk): string => disk.type?.toUpperCase() ?? '';
diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
index 88fcafee6b..47ba99773e 100644
--- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
@@ -8,7 +8,10 @@ import type {
import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel';
import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format';
-import { getWorkloadsGuestBackupStatusPresentation } from '@/utils/workloadGuestPresentation';
+import {
+ getWorkloadGuestDiskStatusMessage,
+ getWorkloadsGuestBackupStatusPresentation,
+} from '@/utils/workloadGuestPresentation';
import { getWorkloadCPUPercent, resolveWorkloadType } from '@/utils/workloads';
import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
@@ -60,6 +63,11 @@ export interface GuestDrawerHistoryTimeBounds {
endTime: number;
}
+export interface GuestDrawerHistoryDeferredMetric {
+ lastKnownValue?: number;
+ message: string;
+}
+
export interface GuestDrawerBackupPresentation {
ageClass: string;
ageLabel: string;
@@ -93,6 +101,25 @@ export const getGuestDrawerAlertMessage = (
return `${alert.message} (${comparison})`;
};
+const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => {
+ const usage = guest.telemetryAvailability?.disk === false ? undefined : guest.disk?.usage;
+ return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined;
+};
+
+export const getGuestDrawerDeferredMetrics = (
+ guest: Guest,
+): Record => {
+ if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {};
+ return {
+ disk: {
+ lastKnownValue: guest.diskStatusReason.startsWith('prev-')
+ ? getGuestDrawerDiskUsage(guest)
+ : undefined,
+ message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason),
+ },
+ };
+};
+
// Current-value metrics displayed beside history legends while the metrics
// store is still accumulating samples. These values never become chart points:
// a current reading is not evidence of a historical trend.
@@ -103,13 +130,10 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record= 0
- ? reportedDiskUsage
- : undefined;
+ isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest);
const finite = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
return {