diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 616b32f2b..929845865 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5266,3 +5266,19 @@ separate conflicting IDs, devices, hosts and controller members; the linked-disk ambiguity test rejects multiple opposite-source candidates in both directions. These are synthetic source proofs, not appliance acceptance or a claim about which collector produced a reporter's row. + +### Drawer tab selection survives a transient snapshot change (#1723) + +The shared resource detail drawer keeps the operator's selected tab while a +live snapshot is being replaced. `useResourceDetailDrawerState` falls back to +Overview only when the drawer starts showing a different resource; it must not +reset merely because the current tab is momentarily absent from the derived tab +list. A merged host's metrics target can be omitted for one snapshot during a +refresh, and treating that as a permanent loss of the History tab made the +Proxmox -> Backups drawer silently jump back to Overview. Each tab body already +renders its own availability notice while its tab is unavailable, so the +selection can be retained and recovered without manufacturing a target. The +`ResourceDetailDrawer.history.test.tsx` guard pins the single-resource reset +boundary, and `scripts/check-drawer-tab-retention.cjs` exercises the rendered +drawer at desktop and narrow widths: selecting History, dropping the merged +metrics target, and restoring it must keep the selection and recover the chart. diff --git a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx index 72e46eb0a..ce4a4fd31 100644 --- a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx +++ b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx @@ -107,6 +107,9 @@ const resources = const Fixture = () => { const [servers, setServers] = createSignal(structuredClone(resources)); const [snapshot, setSnapshot] = createSignal(0); + // Simulates a snapshot in which the merged host correlation target is + // transiently absent, which is what gates the drawer's History tab. + const [dropMetricsTarget, setDropMetricsTarget] = createSignal(false); let timer: ReturnType | undefined; onCleanup(() => clearTimeout(timer)); const refresh = () => { @@ -120,6 +123,9 @@ const Fixture = () => { if (query.get('order') !== 'stable' && count % 2 === 1) { next[0].pbs!.datastores!.reverse(); } + if (dropMetricsTarget()) { + for (const resource of next) delete resource.metricsTarget; + } setServers(next); setSnapshot(count); }; @@ -134,6 +140,22 @@ const Fixture = () => { > Schedule automatic snapshot + + {snapshot()} diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 83ce52216..8b331c5c3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,36 +1,38 @@ { "version": 1, - "base_sha": "702278cd8cca6bfbd65747c72fbb7afdc7f7a48e", - "verified_at": "2026-09-22T16:26:06.944232Z", + "base_sha": "79cf2d0058c374adc7fcc2e7268ab2261e10fa24", + "verified_at": "2026-09-22T23:54:27Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/utils/agentInstallCommand.ts" + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts" ], "content_sha256": { - "frontend-modern/src/utils/agentInstallCommand.ts": "5c456b9b8cf2d114d5be62bb46c27b265d30fc49c3df88d2ec42631414f65c64" + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "24f77d42e2542dd0e8fcf9c55f25a8373c563069c5a0dc112afa6a4456d5a93a" }, "routes": [ - "Production buildUnixAgentInstallCommand in synthetic clipboard/text-input fixture" + "/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)" ], "viewports": [ { "width": 1280, - "height": 850 + "height": 900 }, { "width": 390, - "height": 850 + "height": 844 } ], "states": [ - "Tokenless HTTPS, token HTTP, quoted URL/token, insecure HTTPS, custom CA with extra flags" + "Drawer History tab selected with a populated host history target", + "Snapshot with the merged host metrics target transiently absent (availability notice)", + "Snapshot with the metrics target restored on the same selection" ], "interactions": [ - "Copy generated command with browser clipboard API and paste with Control+V into input type=text", - "Assert five variants preserve exact bytes at both viewports; old source changes all five", - "Check pasted output syntax with sh and bash: old five fail, repaired five pass", - "Inspect desktop-before and narrow-after screenshots" + "Expand the Proxmox Backups server row and select the History tab; assert the chart paths render", + "Apply a snapshot that removes the merged host metrics target; assert the drawer is not remounted and the History selection is retained, showing the in-tab availability notice", + "Apply a snapshot that restores the metrics target; assert the same History selection recovers the chart paths", + "Repeat at 1280x900 and 390x844; the same check fails on the pre-fix source (no notice, selection reset)" ], - "command": "pulse-worker-browser .proof-install/browser.cjs", - "notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. TypeScript-transpiled production utility, synthetic pulse.invalid and invented tokens. Generic text field, not pfSense DOM or installation. No backend/network/service proof. Retained proof was rebound after the test-only assertion correction and exact candidate integration; production utility bytes are unchanged from the browser run." + "command": "pulse-worker-browser scripts/check-drawer-tab-retention.cjs", + "notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Production ResourceDetailDrawer mounted through the real ProxmoxBackupServersTable fixture with synthetic API responses. Proves the drawer tab-retention behaviour, not backend persistence or installed release behaviour. The identical check timed out on the pre-fix source because the selected tab reset to Overview." } diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 5753f8901..dd722dc9d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -216,6 +216,11 @@ describe('ResourceDetailDrawer change history section', () => { ); expect(resourceDetailDrawerStateSource).not.toContain('createResource('); expect(resourceDetailDrawerStateSource).not.toContain('MonitoringAPI.'); + // #1723: a transient snapshot change (for example a momentarily absent + // merged metrics target) must not discard the selected drawer tab. The + // guard resets only when the drawer starts showing a different resource. + expect(resourceDetailDrawerStateSource).toContain('lastResourceId'); + expect(resourceDetailDrawerStateSource).toContain('resourceId !== lastResourceId'); expect(resourceDetailDrawerHistoryStateSource).toContain( "from '@/hooks/createNonSuspendingQuery'", ); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx index 7b0193696..4bae8028c 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createStore, reconcile } from 'solid-js/store'; import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '../ResourceDetailDrawer'; import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; @@ -314,4 +315,43 @@ describe('ResourceDetailDrawer machine metrics history', () => { expect(within(hostSection).queryByRole('button', { name: 'Hide host' })).toBeNull(); expect(within(hostSection).queryByText('Hardware')).toBeNull(); }); + + it('keeps the selected History tab across a snapshot refresh that briefly drops the metrics target', async () => { + const initial = resource({ + id: 'pbs-1', + type: 'pbs', + metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' }, + cpu: { current: 12 }, + }); + const [pbs, setPbs] = createStore(initial); + render(() => ); + + await fireEvent.click(screen.getByRole('tab', { name: 'History' })); + expect(screen.getByTestId('machine-history')).toHaveAttribute( + 'data-resource-id', + 'agent-pbs-1', + ); + + // A snapshot refresh arrives without the merged metrics target. The drawer + // must not silently discard the user's History selection: it should show + // the in-tab notice and recover when the target returns on the next poll. + setPbs(reconcile({ ...initial, metricsTarget: undefined }, { merge: false })); + expect(screen.getByText('Metrics history is unavailable.')).toBeInTheDocument(); + + setPbs( + reconcile( + resource({ + id: 'pbs-1', + type: 'pbs', + metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' }, + cpu: { current: 12 }, + }), + { merge: false }, + ), + ); + expect(screen.getByTestId('machine-history')).toHaveAttribute( + 'data-resource-id', + 'agent-pbs-1', + ); + }); }); diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts index c745d6250..21f761045 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -87,11 +87,21 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta } }); + // Fall back to Overview only when the drawer starts showing a different + // resource. A live snapshot can transiently omit a field that gates a tab + // (for example a merged metrics target during a refresh), and discarding the + // user's selection on that change made the History tab silently jump back to + // Overview. Each tab body already renders an availability notice while its + // tab is unavailable, so a same-resource change must not reset the selection. + let lastResourceId: string | undefined; createEffect(() => { - const current = activeTab(); + const resourceId = resource.id; const available = new Set(derived.tabs().map((tab) => tab.id)); - if (!available.has(current)) { - setActiveTab('overview'); + if (resourceId !== lastResourceId) { + if (!available.has(activeTab())) { + setActiveTab('overview'); + } + lastResourceId = resourceId; } }); diff --git a/scripts/check-drawer-tab-retention.cjs b/scripts/check-drawer-tab-retention.cjs new file mode 100644 index 000000000..607ab4072 --- /dev/null +++ b/scripts/check-drawer-tab-retention.cjs @@ -0,0 +1,196 @@ +// Offline real-browser proof for the #1723 drawer tab-retention repair. +// +// Opens the production Proxmox Backups server drawer, selects History, then +// drives a snapshot that transiently removes the merged host metrics target +// (the field that gates the History tab). The drawer must keep the user's +// History selection and show the in-tab availability notice instead of +// silently falling back to Overview; restoring the target must bring the chart +// back on the same selection. Synthetic API responses prove UI behaviour, not +// backend persistence or installed release behaviour. +// Run: pulse-worker-browser scripts/check-drawer-tab-retention.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'], +}; + +(async () => { + process.chdir(ROOT); + const { createServer } = await import( + path.join(ROOT, 'node_modules', 'vite', 'dist', 'node', 'index.js') + ); + const server = await createServer({ + root: ROOT, + configFile: path.join(ROOT, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5199, strictPort: true }, + }); + let browser; + const results = []; + const pageErrors = []; + const failures = []; + try { + await server.listen(); + browser = await chromium.launch(launchOptions); + for (const viewport of [ + { width: 1280, height: 900 }, + { width: 390, height: 844 }, + ]) { + const caseName = `${viewport.width}`; + 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; + await route.fulfill({ + json: { + resourceType: query.get('resourceType'), + resourceId: query.get('resourceId'), + 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, + })), + ]), + ), + }, + }); + }); + + try { + await page.goto( + 'http://127.0.0.1:5199/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable', + { timeout: 60000 }, + ); + const serverRow = page + .locator('tr') + .filter({ + has: page.getByRole('button', { + name: 'Expand details for proxback', + exact: true, + }), + }) + .first(); + await serverRow.waitFor({ timeout: 20000 }); + await serverRow.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({ timeout: 20000 }); + 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, 'history plot did not render before the drop'); + + // Snapshot arrives without the merged host metrics target. + await page.getByRole('button', { name: 'Drop metrics target' }).click(); + await page + .getByText('Metrics history is unavailable.', { exact: true }) + .waitFor({ timeout: 20000 }); + assert.equal( + await detail.getAttribute('data-proof-identity'), + 'retained', + 'drawer remounted on the transient target loss', + ); + + // Next snapshot restores it; the same History selection must recover. + await page.getByRole('button', { name: 'Restore metrics target' }).click(); + await page + .locator('[data-testid="guest-history-plot"] path') + .first() + .waitFor({ timeout: 20000 }); + assert.equal( + await page + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + 'history selection was discarded across the transient target loss', + ); + assert.equal( + await detail + .locator('[data-testid="guest-history-plot"] path') + .count(), + pathsBefore, + ); + results.push({ viewport, paths: pathsBefore, result: 'passed' }); + } catch (error) { + failures.push(`${caseName}: ${error.message}`); + results.push({ viewport, result: 'failed', error: error.message }); + } finally { + await page.screenshot({ + path: path.join(ROOT, 'browser-tests', `drawer-tab-retention-${caseName}.png`), + }); + await page.close(); + } + } + + if (pageErrors.length > 0) { + throw new Error(`page errors: ${pageErrors.join(' | ')}`); + } + console.log( + JSON.stringify( + { + result: failures.length ? 'failed' : 'passed', + browser: browser.version(), + results, + failures, + }, + null, + 2, + ), + ); + assert.equal(failures.length, 0, failures.join(' | ')); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error('FAILED:', error && error.stack ? error.stack : error); + process.exitCode = 1; +});