diff --git a/tests/integration/README.md b/tests/integration/README.md index b1065a33c..59a860d05 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -143,8 +143,10 @@ Credentials used by the E2E suite can be overridden: - `PULSE_E2E_ALLOW_NODE_MUTATION=1` to enable the optional "Add Proxmox node" test (disabled by default for safety) - `PULSE_E2E_PERF=1` to enable navigation performance budget checks - `PULSE_E2E_PERF_ITERATIONS` (default `3`) -- `PULSE_E2E_PERF_INFRA_TO_WORKLOADS_BUDGET_MS` (default `2200`) -- `PULSE_E2E_PERF_WORKLOADS_TO_INFRA_BUDGET_MS` (default `2200`) +- `PULSE_E2E_PERF_PROXMOX_TO_DOCKER_BUDGET_MS` (default `2200`) +- `PULSE_E2E_PERF_DOCKER_TO_PROXMOX_BUDGET_MS` (default `2200`) +- `PULSE_E2E_PERF_PROXMOX_OVERVIEW_TO_STORAGE_BUDGET_MS` (default `2200`) +- `PULSE_E2E_PERF_PROXMOX_STORAGE_TO_OVERVIEW_BUDGET_MS` (default `2200`) ### Run Against An Existing Pulse Instance ```bash diff --git a/tests/integration/tests/02-navigation-perf.spec.ts b/tests/integration/tests/02-navigation-perf.spec.ts index 5815cee1c..70bfbf92d 100644 --- a/tests/integration/tests/02-navigation-perf.spec.ts +++ b/tests/integration/tests/02-navigation-perf.spec.ts @@ -96,6 +96,123 @@ const measureTabTransition = async ( return Date.now() - start; }; +const makeLargeProxmoxEstate = () => { + const nodes = Array.from({ length: 50 }, (_, index) => { + const nodeNumber = index + 1; + return { + id: `synthetic:proxmox:node-${nodeNumber}`, + type: 'agent', + name: `pve-${String(nodeNumber).padStart(2, '0')}`, + status: 'online', + lastSeen: '2026-09-01T10:00:00Z', + sources: ['proxmox'], + platformScopes: ['proxmox-pve'], + metrics: { + cpu: { percent: (nodeNumber % 70) + 10 }, + memory: { used: 24, total: 64, percent: 37.5 }, + disk: { used: 320, total: 1024, percent: 31.25 }, + }, + proxmox: { + nodeName: `pve-${String(nodeNumber).padStart(2, '0')}`, + clusterName: 'synthetic-large-estate', + instance: 'synthetic-lab', + pveVersion: '9.0.3', + uptime: 1_000_000 + nodeNumber, + }, + }; + }); + + const guests = Array.from({ length: 900 }, (_, index) => { + const guestNumber = index + 1; + const nodeIndex = index % nodes.length; + const node = nodes[nodeIndex]; + const nodeName = node.proxmox.nodeName; + const vmid = 100 + guestNumber; + return { + id: `synthetic:proxmox:${nodeName}:${vmid}`, + type: guestNumber % 4 === 0 ? 'system-container' : 'vm', + name: `workload-${String(guestNumber).padStart(4, '0')}`, + status: guestNumber % 9 === 0 ? 'stopped' : 'online', + parentId: node.id, + parentName: nodeName, + lastSeen: '2026-09-01T10:00:00Z', + sources: ['proxmox'], + platformScopes: ['proxmox-pve'], + metrics: { + cpu: { percent: guestNumber % 80 }, + memory: { used: 2, total: 8, percent: 25 }, + disk: { used: 20, total: 100, percent: 20 }, + }, + proxmox: { + nodeName, + clusterName: 'synthetic-large-estate', + instance: 'synthetic-lab', + vmid, + cpus: 4, + uptime: 100_000 + guestNumber, + }, + }; + }); + + const storage = Array.from({ length: 300 }, (_, index) => { + const storageNumber = index + 1; + const node = nodes[index % nodes.length]; + const physicalDisk = storageNumber % 3 === 0; + return { + id: `synthetic:storage:${storageNumber}`, + type: physicalDisk ? 'physical_disk' : 'storage', + name: physicalDisk + ? `/dev/disk/by-id/synthetic-${storageNumber}` + : `pool-${String(storageNumber).padStart(3, '0')}`, + status: 'online', + parentId: node.id, + parentName: node.proxmox.nodeName, + lastSeen: '2026-09-01T10:00:00Z', + sources: physicalDisk ? ['agent', 'proxmox'] : ['proxmox'], + platformScopes: ['proxmox-pve'], + metrics: { disk: { used: 400, total: 1000, percent: 40 } }, + storage: physicalDisk ? undefined : { type: 'zfs', isZfs: true }, + physicalDisk: physicalDisk + ? { device: `/dev/sd${String.fromCharCode(97 + (storageNumber % 20))}` } + : undefined, + proxmox: { + nodeName: node.proxmox.nodeName, + clusterName: 'synthetic-large-estate', + instance: 'synthetic-lab', + }, + }; + }); + + return { overview: [...nodes, ...guests], storage: [...nodes, ...storage] }; +}; + +const measureProxmoxSectionTransition = async ( + page: Page, + section: 'Overview' | 'Storage', +): Promise => { + const start = Date.now(); + await page + .getByRole('navigation', { name: 'Proxmox sections' }) + .getByRole('link', { name: section, exact: true }) + .click(); + + if (section === 'Storage') { + await expect(page).toHaveURL(/\/proxmox\/storage(?:\?|$)/); + await expect(page.getByTestId('storage-page')).toBeVisible(); + await expect( + page.getByTestId('storage-page').locator('table').first(), + ).toBeVisible(); + } else { + await expect(page).toHaveURL(/\/proxmox\/overview(?:\?|$)/); + await expect(page.getByTestId('proxmox-guests-section')).toBeVisible(); + await expect( + page.getByTestId('proxmox-guests-section').locator('table').first(), + ).toBeVisible(); + } + + return Date.now() - start; +}; + test.describe.serial('Navigation performance budgets', () => { test.skip(!truthy(process.env.PULSE_E2E_PERF), 'Set PULSE_E2E_PERF=1 to enable navigation perf checks'); @@ -162,4 +279,120 @@ test.describe.serial('Navigation performance budgets', () => { } } }); + + test('large-estate Proxmox section switches stay within budget', async ({ + page, + browserName, + isMobile, + }) => { + test.skip( + browserName !== 'chromium' || Boolean(isMobile), + 'Perf budgets are pinned to desktop Chromium', + ); + test.slow(); + + const iterations = toPositiveInt(process.env.PULSE_E2E_PERF_ITERATIONS, 3); + const overviewToStorageBudgetMs = toPositiveInt( + process.env.PULSE_E2E_PERF_PROXMOX_OVERVIEW_TO_STORAGE_BUDGET_MS, + 2200, + ); + const storageToOverviewBudgetMs = toPositiveInt( + process.env.PULSE_E2E_PERF_PROXMOX_STORAGE_TO_OVERVIEW_BUDGET_MS, + 2200, + ); + const estate = makeLargeProxmoxEstate(); + const typeCounts = { + agent: 50, + vm: 675, + 'system-container': 225, + storage: 200, + physical_disk: 100, + }; + + await page.routeWebSocket('**/ws*', () => {}); + await page.route('**/api/resources?**', async (route) => { + const url = new URL(route.request().url()); + const types = url.searchParams.get('type'); + const sources = url.searchParams.get('source'); + let resources: readonly Record[] | undefined; + + if ( + types === 'agent,vm,system-container,oci-container' && + sources === 'proxmox' + ) { + resources = estate.overview; + } else if ( + types === 'agent,pbs,storage,physical_disk,ceph' && + sources === 'proxmox,pbs,agent' + ) { + resources = estate.storage; + } + + if (!resources) { + await route.continue(); + return; + } + + const pageNumber = Math.max(1, Number(url.searchParams.get('page')) || 1); + const limit = Math.max(1, Number(url.searchParams.get('limit')) || 100); + const totalPages = Math.max(1, Math.ceil(resources.length / limit)); + const start = (pageNumber - 1) * limit; + const data = resources.slice(start, start + limit); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data, + meta: { + page: pageNumber, + limit, + total: resources.length, + totalPages, + }, + aggregations: { total: 1_250, byType: typeCounts }, + }), + }); + }); + + await ensureAuthenticated(page); + await gotoWithBackendRetry(page, '/proxmox/overview'); + await waitForProxmoxReady(page); + await expect(page.locator('tr[data-guest-id]').first()).toBeVisible(); + const mountedGuestRows = await page.locator('tr[data-guest-id]').count(); + expect(mountedGuestRows).toBeGreaterThan(0); + expect(mountedGuestRows).toBeLessThanOrEqual(140); + + // Warm both retained sections before measuring the operator's steady-state + // workflow. The synthetic estate matches the demo performance profile but + // keeps this browser gate independent of mutable backend fixture settings. + await measureProxmoxSectionTransition(page, 'Storage'); + await measureProxmoxSectionTransition(page, 'Overview'); + + const overviewToStorageSamples: number[] = []; + const storageToOverviewSamples: number[] = []; + for (let index = 0; index < iterations; index++) { + overviewToStorageSamples.push( + await measureProxmoxSectionTransition(page, 'Storage'), + ); + storageToOverviewSamples.push( + await measureProxmoxSectionTransition(page, 'Overview'), + ); + } + + const overviewToStorageMedianMs = median(overviewToStorageSamples); + const storageToOverviewMedianMs = median(storageToOverviewSamples); + console.log( + `[perf] proxmox overview->storage samples=${overviewToStorageSamples.join(',')} median=${overviewToStorageMedianMs}ms budget=${overviewToStorageBudgetMs}ms`, + ); + console.log( + `[perf] proxmox storage->overview samples=${storageToOverviewSamples.join(',')} median=${storageToOverviewMedianMs}ms budget=${storageToOverviewBudgetMs}ms`, + ); + + expect(overviewToStorageMedianMs).toBeLessThanOrEqual( + overviewToStorageBudgetMs, + ); + expect(storageToOverviewMedianMs).toBeLessThanOrEqual( + storageToOverviewBudgetMs, + ); + }); });