diff --git a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx index dc7f87f460..8b787ae09d 100644 --- a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx +++ b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx @@ -4,6 +4,7 @@ // treated as ambiguous, so the PBS row kept its service metrics target and the // History tab showed "Collecting history" for a host that has history. // Synthetic props only; the check script intercepts the metrics-history request. +import { createSignal } from 'solid-js'; import { render } from 'solid-js/web'; import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable'; @@ -30,13 +31,16 @@ const pbs = { hostname: 'proxback-vm', version: '3.2.1', connectionHealth: 'healthy', - datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }], + datastores: [ + { name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }, + { name: 'archive', total: 2000, used: 600, available: 1400, usagePercent: 30 }, + ], }, // The PBS service target: correct for the service, but it has no host series. metricsTarget: { resourceType: 'agent', resourceId: 'pbs-1' }, platformData: { sources: ['pbs'], - pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 1 }, + pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 2 }, }, } as unknown as Resource; @@ -74,7 +78,29 @@ const standalone = { platformData: { sources: ['agent', 'pbs'], agent: sharedAgent }, } as unknown as Resource; -render( - () => , - document.getElementById('root') as HTMLElement, -); +const Fixture = () => { + const [servers, setServers] = createSignal(structuredClone([pbs, guest, standalone])); + let snapshot = 0; + return ( + <> + + + + ); +}; + +render(() => , document.getElementById('root') as HTMLElement); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index e0ed0fc122..d2bc7ad833 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,57 +1,40 @@ { "version": 1, - "base_sha": "76f96f990638652ed3d4af890bbcdaa8d47374aa", - "verified_at": "2026-09-22T09:47:00Z", + "base_sha": "014190ea4c630397e13088e3888bffb93012b8f3", + "verified_at": "2026-09-22T11:10:32.326040Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", - "frontend-modern/src/utils/alertOverviewPresentation.ts" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "18535683c37f5b5a481ac012a49b7dcdd6264ac992076381c74fdfbb037e31eb", - "frontend-modern/src/utils/alertOverviewPresentation.ts": "7f68a70800deb0cb8214d4fd25c7454c5b0fde1084e08365eb93bc361b80846a" + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b463452e00e796a0d9ff3639f21ff7ac0d7219539cfacf50f6101282d829f78a" }, "routes": [ - "/browser-tests/alert-card-footer.html", - "/proxmox/backups (production surface represented by the component fixture)", + "/proxmox/backups (production component fixture)", "/browser-tests/pbs-host-history-correlation.html" ], "viewports": [ - { - "width": 2010, - "height": 1072 - }, { "width": 1280, "height": 900 }, - { - "width": 480, - "height": 800 - }, { "width": 390, "height": 844 } ], "states": [ - "Alerts overview alert-card footer with the Started timestamp and the adjacent delivery-status run", - "The Started run classed by getAlertOverviewStartedAtClass() inside the items-center footer flex row", - "The Backups PBS server row renders for a host whose agent is surfaced as both a PVE guest and a standalone source=pbs host row", - "Expanding the PBS row opens the canonical drawer; History is selectable and renders the host metric groups", - "The PBS drawer's merged header shows the guest's current CPU/memory (15.4% / 16.9%) rather than the PBS service values", - "The History request resolves to the guest series vm/proxmox:100; the PBS service target agent/pbs-1 is never requested", - "Desktop and narrow layouts for both surfaces" + "Two datastores on one PBS, with a single agent represented by guest and standalone rows", + "History on tank, archive, then tank again, with populated synthetic CPU/memory/disk/network history", + "Fresh resource snapshots with changing usage and alternating datastore array order" ], "interactions": [ - "Mount the production footer markup from AlertOverviewAlertCard.tsx with the class returned by getAlertOverviewStartedAtClass() and the project CSS", - "Measure the text bounding rects of the Started run and the status run and assert their top and bottom edges match within 1px", - "Mount the production ProxmoxBackupServersTable with synthetic PBS server, PVE guest and standalone agent resources sharing one agent identity", - "Focus the PBS row disclosure button and activate it with Enter, then select the History subtab", - "Intercept /api/metrics-store/history requests and assert the requested resourceType/resourceId pair", - "Repeat both surfaces at desktop and narrow widths and capture a screenshot of each rendered surface", - "Record Chromium from the offline Playwright runtime with no uncaught page errors" + "Keyboard-expand each datastore row and select History", + "Check both datastore identities before each switch", + "Refresh three snapshots at each viewport; assert updated usage, retained drawer DOM, selected History tab and seven chart paths", + "Assert all six history requests use vm/proxmox:100, never the PBS service target", + "Inspect desktop and mobile screenshots; no uncaught page errors" ], - "command": "pulse-worker-browser scripts/check-alert-card-footer.cjs; pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs", - "notes": "Two offline Playwright Chromium runs, each against the production component with the project CSS and synthetic props; no live Pulse, backend, WebSocket or credential path. #2119: the alert-card footer is an items-center flex row that already carries the top margin, so the child 'mt-1' pushed the Started run 2px below the delivery-status span; removing it aligns both runs (top 66 / bottom 82 at 2010x1072 and 480x800, delta 0). #1723: the fixture feeds synthetic PBS/guest/agent resources and answers /api/metrics-store/history with empty points, so the panels correctly show the collecting-history state; the assertion is the requested history target, vm/proxmox:100 (the guest series), never agent/pbs-1. No page errors and no failed responses. This receipt is content-addressed to the two changed frontend sources at this candidate base." + "command": "pulse-worker-browser .pbs-proof/check.cjs (assigned workspace root; invokes scripts/check-pbs-host-history-correlation.cjs in the disposable source copy)", + "notes": "Chromium 141.0.7390.37 / installed Playwright 1.56.1. Production source SHA-256 matched the assigned tree; package-lock SHA-256 87e579d28080ad1fb42992dd5b398c373aa08aff63bbf3f0ed63ac8b4a8f89a0, matching offline dependency snapshot dac6a9b347a1c76ad3bf0f5eadc17b84d468ce0d3878354e5d39e745101ccb53. Before the fix, a reversed datastore array overwrote the archive row to tank, reproduced in Chromium and the new component test. Isolating the row datastore object corrects it. Synthetic metrics/capability/AI responses, not live backend or reporter data. History reset to Summary was not reproduced, and this is not whole-issue resolution. Final screenshots visually inspected. Logs retained in web-product-h8pgc3la/pbs-browser-final.log; disposable copy retained in that output directory." } diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 1cd03c2d1f..8d6272e576 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -279,7 +279,10 @@ export function buildBackupServerRows( rows.push({ key: `${server.id}:${datastore.name}`, ...host, - datastore, + // Row stores reconcile independently from the nested PBS snapshot. Do + // not alias its datastore object: a reordered snapshot reconciles that + // array by position and would overwrite this row's datastore identity. + datastore: { ...datastore }, backupCount: countFor(server, datastore.name), }); } diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx index 7d3282f1bf..c8782b2898 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx @@ -83,6 +83,35 @@ describe('ProxmoxBackupServersTable details', () => { expect(screen.getByTestId('pbs-resource-detail')).toBeInTheDocument(); }); + it('keeps each datastore identity and open drawer through reordered snapshots', async () => { + const makeServers = (reverse = false) => { + const pbs = makePbsResource(); + pbs.pbs!.datastores!.push({ + name: 'archive', + total: 2000, + used: 600, + available: 1400, + usagePercent: 30, + }); + if (reverse) pbs.pbs!.datastores!.reverse(); + return [pbs]; + }; + const [servers, setServers] = createSignal(makeServers()); + const { container } = render(() => ); + const datastoreNames = () => + Array.from(container.querySelectorAll('td[title]')).map((cell) => cell.getAttribute('title')); + fireEvent.click(screen.getAllByRole('button', { name: 'Expand details for pbs-main' })[1]); + const detail = screen.getByTestId('pbs-resource-detail'); + expect(datastoreNames()).toEqual(['pbs-main · archive', 'pbs-main · tank']); + + setServers(makeServers(true)); + await waitFor(() => + expect(datastoreNames()).toEqual(['pbs-main · archive', 'pbs-main · tank']), + ); + expect(screen.getByTestId('pbs-resource-detail')).toBe(detail); + expect(resourceDetailDrawerMount).toHaveBeenCalledTimes(1); + }); + it('opens the canonical resource drawer with merged host details expanded', () => { render(() => ); diff --git a/scripts/check-pbs-host-history-correlation.cjs b/scripts/check-pbs-host-history-correlation.cjs index 215a75a4c1..8e12ba2e1d 100644 --- a/scripts/check-pbs-host-history-correlation.cjs +++ b/scripts/check-pbs-host-history-correlation.cjs @@ -2,16 +2,23 @@ // // Mounts the production ProxmoxBackupServersTable with a PBS host whose agent // is present twice (PVE guest with agent telemetry, and a standalone source=pbs -// host row). Expands the PBS row, opens History, and records the metrics +// host row), plus two datastores. Switches between them, replaces/reorders +// snapshots with fresh CPU telemetry, and checks chart paths and tab retention. +// Records the metrics // history target the drawer requests. The correct target is the guest's vm // series; the PBS service target has no host history and leaves the tab on // "Collecting history". Run with: // pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs const path = require('node:path'); +const assert = require('node:assert/strict'); const { chromium } = require('playwright'); const ROOT = path.resolve(process.cwd(), 'frontend-modern'); -const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbox'] }; +const launchOptions = { + headless: true, + channel: 'chromium', + args: ['--no-sandbox'], +}; (async () => { process.chdir(ROOT); @@ -39,6 +46,22 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo const page = await browser.newPage({ viewport }); page.on('pageerror', (error) => pageErrors.push(`${viewport.width}px: ${error.message}`)); + await page.route('**/api/license/runtime-capabilities', (route) => + route.fulfill({ + json: { + capabilities: [], + limits: [], + hosted_mode: false, + max_history_days: 7, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }, + }), + ); + await page.route('**/api/settings/ai', (route) => + route.fulfill({ json: { enabled: false } }), + ); + await page.route('**/api/metrics-store/history?**', async (route) => { const query = new URL(route.request().url()).searchParams; targets.push(`${query.get('resourceType')}/${query.get('resourceId')}`); @@ -46,22 +69,96 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo json: { resourceType: query.get('resourceType'), resourceId: query.get('resourceId'), - points: [], + range: query.get('range'), + start: Date.now() - 3600000, + end: Date.now(), + source: 'store', + metrics: Object.fromEntries( + ['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite'].map( + (metric) => [ + metric, + [30, 20, 10].map((minutes, i) => ({ + timestamp: Date.now() - minutes * 60000, + value: 10 + i * 5, + min: 10 + i * 5, + max: 10 + i * 5, + })), + ], + ), + ), }, }); }); await page.goto('http://127.0.0.1:5198/browser-tests/pbs-host-history-correlation.html'); - const expand = page.getByRole('button', { - name: 'Expand details for proxback', - exact: true, + const rows = page.locator('tr').filter({ + has: page.getByRole('button', { + name: 'Expand details for proxback', + exact: true, + }), }); - await expand.waitFor({ timeout: 20000 }); - await expand.focus(); - await page.keyboard.press('Enter'); - await page.getByRole('tab', { name: 'History', exact: true }).click(); - // Give the history fetch a beat to fire. - await page.waitForTimeout(750); + await rows.first().waitFor({ timeout: 20000 }); + const openDatastore = async (name) => { + const row = page + .locator('tr') + .filter({ has: page.locator(`td[title="proxback · ${name}"]`) }); + await row.getByRole('button').focus(); + await page.keyboard.press('Enter'); + await page.getByRole('tab', { name: 'History', exact: true }).click(); + await page.locator('[data-testid="guest-history-plot"] path').first().waitFor(); + assert.equal( + await page + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + ); + }; + const observations = []; + // Both datastore rows must use the same host series, including revisiting + // the first row after its history component has been disposed. + for (const [index, datastore] of ['tank', 'archive', 'tank'].entries()) { + assert.deepEqual( + await page + .locator('td[title^="proxback · "]') + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('title'))), + ['proxback · archive', 'proxback · tank'], + ); + await openDatastore(datastore); + const detail = page.locator('[data-inline-platform-resource-detail-for="pbs-1"]'); + await detail.evaluate((node) => { + node.dataset.proofIdentity = 'retained'; + }); + const pathsBefore = await detail.locator('[data-testid="guest-history-plot"] path').count(); + assert.ok(pathsBefore > 0); + await page.getByRole('button', { name: 'Refresh resource snapshot' }).click(); + // Datastore usage is visible at both widths and proves that the fresh + // snapshot reached the UI (the CPU column is hidden on narrow screens). + await page + .getByText(`${(41 + index).toFixed(1)}%`, { exact: true }) + .first() + .waitFor(); + assert.equal( + await detail.getAttribute('data-proof-identity'), + 'retained', + 'drawer remounted', + ); + assert.equal( + await page + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + 'history reset after snapshot', + ); + assert.equal( + await detail.locator('[data-testid="guest-history-plot"] path').count(), + pathsBefore, + ); + observations.push({ + datastore, + paths: pathsBefore, + refreshPreservedHistory: true, + }); + } if (!targets.includes(expected)) { throw new Error( @@ -77,7 +174,7 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo await page.screenshot({ path: path.join(ROOT, 'browser-tests', `pbs-host-history-${viewport.width}.png`), }); - results.push({ viewport, targets }); + results.push({ viewport, targets, observations }); await page.close(); } @@ -85,7 +182,9 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo throw new Error(`page errors: ${pageErrors.join(' | ')}`); } - console.log(JSON.stringify({ result: 'passed', expected, results }, null, 2)); + console.log( + JSON.stringify({ result: 'passed', browser: browser.version(), expected, results }, null, 2), + ); } finally { if (browser) await browser.close(); await server.close();