From 0ad41e4c9a309ce459dd717d16d343b46efcac8a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:29:06 +0100 Subject: [PATCH] Reproduce drawer History joining missing observations Mounted regressions distinguish actual stored gaps from elapsed-time guesses, live numeric carriers, valid zero rates and missing values at a dated inspection. Pin segment and isolated-observation rendering through recovery and denied/transient reads. Change-source: pulse-maintainer --- .../GuestDrawerHistory.gaps.test.tsx | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.gaps.test.tsx diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.gaps.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.gaps.test.tsx new file mode 100644 index 0000000000..f4c659a21e --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.gaps.test.tsx @@ -0,0 +1,245 @@ +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 { GuestDrawerHistory } from '../GuestDrawerHistory'; + +vi.mock('@/stores/license', () => ({ + loadRuntimeCapabilities: vi.fn(async () => undefined), + maxHistoryDays: () => 90, + isRangeLocked: () => false, +})); +afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); +}); + +const start = Date.UTC(2026, 9, 4, 12); +const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve:101' }; +const point = (minute: number, value: number) => ({ + timestamp: start + minute * 60_000, + value, + min: value, + max: value, +}); +const response = ( + metrics: AllMetricsHistoryResponse['metrics'] = { + cpu: [0, 1, 2, 3, 4].map((minute) => point(minute, 10)), + memory: [point(0, 20), point(1, 25), point(4, 30)], + disk: [point(0, 50), point(4, 50)], + }, +): AllMetricsHistoryResponse => ({ + ...target, + range: '1h', + start, + end: start + 60 * 60_000, + source: 'store', + metrics, +}); +const group = (id = 'utilization') => + screen.getAllByTestId('guest-history-group-chart').find((el) => el.dataset.historyGroup === id)!; +const paths = (colour: string, id = 'utilization') => [ + ...group(id).querySelectorAll(`path[stroke="${colour}"]`), +]; +const marks = (metric: string, id = 'utilization') => + group(id).querySelectorAll(`[data-history-observation="${metric}"]`); +const note = (id = 'utilization') => group(id).querySelector('[data-history-gaps]'); +const mount = () => render(() => ); +const refresh = () => fireEvent.click(screen.getByRole('button', { name: 'Refresh history' })); + +describe('drawer History does not join missing stored observations', () => { + it('breaks only the affected metric and keeps isolated recovery observations visible', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(paths('#8b5cf6')[0].getAttribute('d')?.match(/L/g)).toHaveLength(4); + expect(paths('#f59e0b')).toHaveLength(1); + expect(paths('#f59e0b')[0].getAttribute('d')?.match(/L/g)).toHaveLength(1); + expect(marks('memory')).toHaveLength(1); + expect(paths('#10b981')).toHaveLength(0); + expect(marks('disk')).toHaveLength(2); + expect(note()).toHaveTextContent('Missing observations: Memory, Disk.'); + expect(note()).toHaveTextContent( + 'Lines stop where a series has no reading at another stored time in this panel.', + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('retains isolated measured zero, not an invented zero during a gap', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + cpu: [point(0, 0), point(1, 1), point(2, 2)], + memory: [point(0, 0), point(2, 0)], + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(paths('#f59e0b')).toHaveLength(0); + expect(marks('memory')).toHaveLength(2); + const slider = within(group()).getByRole('slider'); + fireEvent.input(slider, { target: { value: '1' } }); + expect(slider).toHaveAttribute( + 'aria-valuetext', + expect.stringContaining('Memory no observation.'), + ); + expect(group()).toHaveTextContent('Memory-'); + fireEvent.input(slider, { target: { value: '2' } }); + expect(group()).toHaveTextContent('Memory0.0%'); + }); + + it('does not fabricate a trend for interleaved, independently sampled series', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + cpu: [point(0, 10), point(2, 20)], + memory: [point(1, 30), point(3, 40)], + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(group().querySelectorAll('path')).toHaveLength(0); + expect(marks('cpu')).toHaveLength(2); + expect(marks('memory')).toHaveLength(2); + expect(note()).toHaveTextContent('Missing observations: CPU, Memory.'); + }); + + it('preserves multiple continuous segments without dropping either side of the gap', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + cpu: [0, 1, 2, 3, 4].map((minute) => point(minute, 10)), + memory: [0, 1, 3, 4].map((minute) => point(minute, 20)), + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(paths('#f59e0b')).toHaveLength(2); + for (const path of paths('#f59e0b')) + expect(path.getAttribute('d')?.match(/L/g)).toHaveLength(1); + expect(marks('memory')).toHaveLength(0); + expect(within(group()).getByRole('img')).toHaveAccessibleDescription( + expect.stringContaining('Missing observations: Memory.'), + ); + }); + + it('applies the same rule to network and disk I/O, retaining valid zero rates', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + netin: [point(0, 0), point(1, 100), point(2, 200)], + netout: [point(0, 0), point(2, 0)], + diskread: [point(0, 0), point(2, 0)], + diskwrite: [point(0, 0), point(1, 50), point(2, 100)], + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Network I/O history' }); + expect(paths('#10b981', 'network')).toHaveLength(1); + expect(paths('#fb923c', 'network')).toHaveLength(0); + expect(marks('netout', 'network')).toHaveLength(2); + expect(note('network')).toHaveTextContent('Missing observations: Out.'); + expect(paths('#3b82f6', 'disk-io')).toHaveLength(0); + expect(marks('diskread', 'disk-io')).toHaveLength(2); + expect(paths('#f59e0b', 'disk-io')).toHaveLength(1); + expect(note('disk-io')).toHaveTextContent('Missing observations: Read.'); + }); + + it('does not guess missed polls from elapsed time or timestamps in other panels', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + cpu: [point(0, 10), point(59, 20)], + memory: [point(0, 30), point(59, 40)], + netin: [point(20, 100)], + unconfigured: [point(30, 100)], + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(paths('#8b5cf6')).toHaveLength(1); + expect(paths('#f59e0b')).toHaveLength(1); + expect(note()).toBeNull(); + }); + + it('does not create a gap from invalid samples or an entirely absent series', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + cpu: [point(0, 10), point(1, NaN), point(2, 20)], + memory: [point(0, 30), point(2, 40)], + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(paths('#8b5cf6')).toHaveLength(1); + expect(paths('#f59e0b')).toHaveLength(1); + expect(note()).toBeNull(); + expect(marks('disk')).toHaveLength(0); + }); + + it('cannot close a stored gap with live recovery or retained numeric carriers', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + const [live, setLive] = createSignal(false); + render(() => ( + + )); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const chart = group(); + expect(marks('memory')).toHaveLength(1); + setLive(true); + expect(group()).toBe(chart); + expect(marks('memory')).toHaveLength(1); + expect(paths('#10b981')).toHaveLength(0); + expect(note()).toHaveTextContent('Memory, Disk'); + expect(group()).not.toHaveTextContent('99.0%'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('closes a gap only with matching stored observations and preserves dated inspection', async () => { + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce( + response({ + cpu: [0, 1, 2, 3, 4].map((minute) => point(minute, 10)), + memory: [0, 1, 2, 3, 4].map((minute) => point(minute, 20)), + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const chart = group(); + const slider = within(chart).getByRole('slider'); + fireEvent.input(slider, { target: { value: '2' } }); + expect(slider).toHaveAttribute( + 'aria-valuetext', + expect.stringContaining('Memory no observation.'), + ); + refresh(); + await waitFor(() => expect(note()).toBeNull()); + expect(group()).toBe(chart); + expect(paths('#f59e0b')).toHaveLength(1); + expect(paths('#f59e0b')[0].getAttribute('d')?.match(/L/g)).toHaveLength(4); + expect(slider).toHaveValue('2'); + expect(slider).toHaveAttribute('aria-valuetext', expect.stringContaining('Memory 20.0%.')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('retains gaps on a transient refresh failure, but withdraws them on access denial', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(Object.assign(new Error('private transient detail'), { status: 503 })) + .mockRejectedValueOnce(Object.assign(new Error('private denial'), { status: 403 })); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + refresh(); + await screen.findByRole('button', { name: 'Retry history' }); + expect(note()).toHaveTextContent('Memory, Disk'); + expect(paths('#10b981')).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: 'Retry history' })); + await waitFor(() => expect(screen.queryByTestId('guest-history-group-chart')).toBeNull()); + expect(document.querySelector('[data-history-gaps]')).toBeNull(); + expect(document.body).not.toHaveTextContent(/private transient detail|private denial/); + }); +});