From 5e886b04d67fbf9edf555d7379db932d28117c3b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:45:12 +0100 Subject: [PATCH] Keep drawer History on one dated time window Use the fulfilled API window and a common observed envelope across metric groups so sparse samples cannot look like a full selected range. Show dated endpoints, retain them with matching failed-refresh data, and clear them on range replacement. Preserve edge observations and reject non-date geometry. Pin mounted/model regressions, update both affected contracts, and retain production PBS drawer browser proof with its installed-acceptance limits. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 26 ++ .../subsystems/performance-and-scalability.md | 17 + .../browser-tests/history-window.cjs | 393 ++++++++++++++++++ frontend-modern/browser-verification.json | 227 +++++++--- .../components/Workloads/GuestDrawer.test.tsx | 21 + .../Workloads/GuestDrawerHistory.tsx | 45 +- .../GuestDrawerHistory.window.test.tsx | 243 +++++++++++ .../components/Workloads/guestDrawerModel.ts | 37 +- 8 files changed, 939 insertions(+), 70 deletions(-) create mode 100644 frontend-modern/browser-tests/history-window.cjs create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 65006e043..c3bac1b5c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7597,3 +7597,29 @@ series, zero rates, single observations, ties, focus precedence and replacement. in Chromium/Firefox desktop and Chromium/WebKit phone emulation in both themes, including failed refreshes and source replacement. Scripted APIs establish presentation accuracy, not real PBS collection or installed #1723 acceptance. + +### Drawer History panels share a dated time window + +`GuestDrawerHistory` uses one common time interval for every configured metric +group, including utilization, network, disk I/O and thermals. The fulfilled +API response's valid `start`/`end` interval remains in view even if it contains +only a few minutes of observations. A sample at a given timestamp occupies the +same horizontal position in every panel; a group's sparse coverage must not +stretch independently to fill the selected range. Visible date/time endpoints +and full local timestamps in their accessible labels distinguish overnight and +multi-day windows. Native inspection still selects actual observations. + +Returned edge observations widen the common envelope instead of being clipped +or discarded, including aggregated bucket timestamps. An invalid API interval +falls back to the valid observed envelope across configured groups, not a +fabricated range. Non-date timestamps and unconfigured metrics cannot poison +that geometry. A valid empty window has labelled endpoints but no trend or +inspection control. Failed matching refreshes retain the window with its data; +target/range replacement clears both until matching data arrives. + +`GuestDrawerHistory.window.test.tsx` pins geometry, labels, refresh/replacement, +empty/invalid windows and edge observations. `browser-tests/history-window.cjs` +uses the production PBS table, resource drawers, History query and CSS with +synthetic APIs, checking three separately mapped drawers and range/refresh +behaviour in desktop and phone-emulated engines. This is presentation proof, +not installed PBS/VirtualBox collection or a complete #1723 acceptance result. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index e027d59a6..ed0ab8c5e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3360,3 +3360,20 @@ reproduced the CI magnitude or establishes an improvement. Keep the signal open as an environment-bound observation under the existing `performance-post-rc-headroom` follow-up; no chart-path code change is justified by this comparison alone. + +### Shared drawer History window calculation + +The drawer model computes bounds from the requested API interval and supplied +valid observation timestamps. The renderer derives one envelope per fulfilled +query from its configured metric groups and shares it across all panels; it +does not synthesize, resample or fetch extra points to fill an empty window. +Invalid dates cannot enter the labelled geometry, and returned edge buckets +remain inside the common envelope. The existing 240-point request cap per +metric, 30-second poll and retained-query cache limits are unchanged. No +backend, metrics-store, performance threshold or latency claim changes here. + +`GuestDrawer.test.tsx` verifies requested, expanded, invalid and absent bounds; +`GuestDrawerHistory.window.test.tsx` verifies the mounted query and geometry, +including selection retention while a same-source window advances. Browser +receipts exercise the actual PBS table/drawer renderer with synthetic APIs, +not installed collection or a performance benchmark. diff --git a/frontend-modern/browser-tests/history-window.cjs b/frontend-modern/browser-tests/history-window.cjs new file mode 100644 index 000000000..ea0a9bce7 --- /dev/null +++ b/frontend-modern/browser-tests/history-window.cjs @@ -0,0 +1,393 @@ +// Production PBS table/drawers/History with synthetic APIs, not installed collection. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, firefox, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const engine = + process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'chromium'; + assert.ok(['chromium', 'firefox', 'webkit'].includes(engine)); + const width = process.argv.includes('--phone') ? 390 : 1365; + const baseline = process.argv.includes('--baseline'); + const requestedTheme = process.argv.find((arg) => arg.startsWith('--theme='))?.split('=')[1]; + assert.ok(!requestedTheme || ['light', 'dark'].includes(requestedTheme)); + const artifacts = path.join( + root, + 'node_modules', + `history-window-${engine}-${width}${baseline ? '-base' : ''}${requestedTheme ? `-${requestedTheme}` : ''}`, + ); + fs.mkdirSync(artifacts, { recursive: true }); + fs.chmodSync(artifacts, 0o755); + const progress = (stage, detail = {}) => { + const record = { stage, ...detail, at: new Date().toISOString() }; + fs.writeFileSync(path.join(artifacts, 'progress.json'), JSON.stringify(record) + '\n', { + mode: 0o644, + }); + console.log(JSON.stringify(record)); + }; + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')), + server: { host: '127.0.0.1', port: 5225, strictPort: true }, + }); + const sourceHashes = Object.fromEntries( + [ + 'src/components/Workloads/GuestDrawerHistory.tsx', + 'src/components/Workloads/guestDrawerModel.ts', + ].map((file) => [ + file, + createHash('sha256') + .update(fs.readFileSync(path.join(root, file))) + .digest('hex'), + ]), + ); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + let browser; + const observations = []; + try { + await server.listen(); + browser = await { chromium, firefox, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const theme of requestedTheme ? [requestedTheme] : ['light', 'dark']) { + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + locale: 'en-GB', + timezoneId: 'UTC', + ...(width === 390 ? { isMobile: true, hasTouch: true } : {}), + }); + page.setDefaultTimeout(25_000); + const requests = [], + errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + let state = 'success', + advance = 0, + held; + const hour = 3_600_000; + const end = Date.UTC(2026, 9, 1, 12); + const durations = { '1h': hour, '6h': 6 * hour, '24h': 24 * hour, '7d': 7 * 24 * hour }; + const point = (minutes, value) => ({ + timestamp: end - minutes * 60_000, + value, + min: value, + max: value, + }); + const response = (type, id, range, empty = false) => ({ + resourceType: type, + resourceId: id, + range, + start: end + advance - durations[range], + end: end + advance, + source: 'store', + metrics: empty + ? {} + : Object.fromEntries( + Object.entries({ + cpu: [ + point(10, id.endsWith('one') ? 21 : id.endsWith('two') ? 31 : 41), + point(5, 45), + ], + memory: [point(5, 55)], + disk: [point(8, 20), point(4, 30)], + netin: [point(60, 0), point(5, 2048)], + netout: [point(5, 4096)], + diskread: [point(2, 1024), point(0, 2048)], + diskwrite: [point(5, 0), point(0, 0)], + temperature: [point(5, 50)], + }).map(([metric, points]) => [ + metric, + points.filter( + (point) => + point.timestamp >= end + advance - durations[range] && + point.timestamp <= end + advance, + ), + ]), + ), + }); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5225') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const type = url.searchParams.get('resourceType'), + id = url.searchParams.get('resourceId'), + range = url.searchParams.get('range'); + assert.ok( + ['vm-one', 'vm-two', 'agent-three'].includes(id), + `uncorroborated target ${id}`, + ); + assert.ok(durations[range], `unexpected or locked read ${range}`); + requests.push({ type, id, range, state, advance, method: route.request().method() }); + if (state === 'hold') { + held = { route, type, id, range }; + return; + } + if (state === 'failure') + return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } }); + return route.fulfill({ json: response(type, id, range, state === 'empty') }); + } + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }, + }); + return route.fulfill({ json: { data: [], enabled: false } }); + }); + progress('loading-production-table', { theme }); + await page.goto('http://127.0.0.1:5225/browser-tests/pbs-identity-boundary.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + await page.getByRole('button', { name: 'Corroborate links', exact: true }).click(); + progress('linked-table-ready', { theme }); + const xs = async (plot) => + [ + ...(await plot.locator('path').first().getAttribute('d')).matchAll(/[ML](-?[\d.]+),/g), + ].map((match) => Number(match[1])); + const expectedX = (minutes, range) => + 34 + ((durations[range] - advance - minutes * 60_000) / durations[range]) * 318; + const verifyWindow = async (detail, range) => { + const axes = detail.getByTestId('guest-history-time-window'); + const groups = await detail.getByTestId('guest-history-group-chart').count(); + await axes.first().waitFor(); + assert.equal(await axes.count(), groups); + for (const axis of await axes.all()) { + const times = axis.locator('time'); + assert.equal( + await times.nth(0).getAttribute('datetime'), + new Date(end + advance - durations[range]).toISOString(), + ); + assert.equal( + await times.nth(1).getAttribute('datetime'), + new Date(end + advance).toISOString(), + ); + assert.match(await times.nth(0).getAttribute('aria-label'), /^Window start: /); + assert.match(await times.nth(1).getAttribute('aria-label'), /^Window end: /); + assert.notEqual(await times.nth(0).innerText(), await times.nth(1).innerText()); + } + }; + let detail; + for (const suffix of baseline ? ['three'] : ['one', 'two', 'three']) { + const toggle = page.getByRole('button', { + name: `Expand details for backup-connection-${suffix}`, + exact: true, + }); + // WebKit phone uses the native keyboard disclosure; its full-row touch + // opening was not established. Touch inspection is exercised below. + if (width === 1365 || engine === 'webkit') { + await toggle.focus(); + await page.keyboard.press('Enter'); + } else await page.locator(`td[title="backup-connection-${suffix} · tank"]`).tap(); + assert.equal( + await page + .getByRole('button', { + name: `Collapse details for backup-connection-${suffix}`, + exact: true, + }) + .getAttribute('aria-expanded'), + 'true', + ); + detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`); + await detail.getByRole('tab', { name: 'History', exact: true }).click(); + const utilization = detail.locator('[data-history-group="utilization"]'); + const plot = utilization.getByTestId('guest-history-plot'); + await plot.locator('path').first().waitFor(); + const observedXs = await xs(plot); + const observed = { + suffix, + theme, + observedXs, + expectedXs: [expectedX(10, '24h'), expectedX(5, '24h')], + axes: await detail.getByTestId('guest-history-time-window').count(), + }; + console.log(JSON.stringify({ timeWindowObservation: observed, sourceHashes })); + await detail.screenshot({ path: path.join(artifacts, `${suffix}-24h-${theme}.png`) }); + assert.ok(Math.abs(observedXs[0] - expectedX(10, '24h')) < 0.01, JSON.stringify(observed)); + assert.ok(Math.abs(observedXs[1] - expectedX(5, '24h')) < 0.01, JSON.stringify(observed)); + await verifyWindow(detail, '24h'); + assert.ok( + requests.some( + (request) => request.id === (suffix === 'three' ? 'agent-three' : `vm-${suffix}`), + ), + ); + const dims = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + width: innerWidth, + })); + assert.ok(dims.scroll <= dims.width + 1, JSON.stringify(dims)); + observations.push({ ...observed, dims }); + progress('drawer-verified', { suffix, theme }); + } + const utilization = detail.locator('[data-history-group="utilization"]'); + const slider = utilization.getByRole('slider', { name: 'Inspect Utilization history' }); + const network = detail.locator('[data-history-group="network"]'); + const networkSlider = network.getByRole('slider', { name: 'Inspect Network I/O history' }); + const readsBefore = requests.length; + await slider.focus(); + await slider.press('Home'); + await slider.press('ArrowRight'); + await slider.press('ArrowRight'); + assert.match( + await slider.getAttribute('aria-valuetext'), + /01\/10\/2026, 11:55:00\. CPU 45\.0%\. Memory 55\.0%\. Disk no observation/, + ); + const utilizationX = await utilization + .getByTestId('guest-history-plot') + .locator('circle[r="3"]') + .first() + .getAttribute('cx'); + await networkSlider.focus(); + await networkSlider.press('End'); + const networkX = await network + .getByTestId('guest-history-plot') + .locator('circle[r="3"]') + .first() + .getAttribute('cx'); + assert.equal(networkX, utilizationX); + assert.match( + (await network.innerText()).replace(/\s+/g, ' '), + /In\s*2\.00 KB\/s Out\s*4\.00 KB\/s/, + ); + if (width === 390) { + await networkSlider.scrollIntoViewIfNeeded(); + const box = await networkSlider.boundingBox(); + assert.ok(box.height >= 44); + await page.touchscreen.tap(box.x + 3, box.y + box.height / 2); + assert.match( + await networkSlider.getAttribute('aria-valuetext'), + /In 0 B\/s\. Out no observation/, + ); + } + assert.equal(requests.length, readsBefore, 'inspection must not request metrics'); + progress('inspection-verified', { theme }); + await networkSlider.press('Tab'); + state = 'failure'; + await detail.getByRole('button', { name: 'Refresh history' }).click(); + await detail + .getByText('History refresh failed. Showing previously loaded history.') + .waitFor(); + await verifyWindow(detail, '24h'); + assert.ok( + Math.abs( + (await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '24h'), + ) < 0.01, + ); + assert.ok(!(await detail.innerText()).includes('Private fixture detail')); + await detail.screenshot({ path: path.join(artifacts, `retained-window-${theme}.png`) }); + progress('retained-window-verified', { theme }); + advance = 10 * 60_000; + state = 'success'; + await detail.getByRole('button', { name: 'Retry history' }).click(); + await detail.getByRole('button', { name: 'Refresh history' }).waitFor(); + await page.waitForFunction( + (expected) => + document + .querySelector('[data-testid="guest-history-time-window"] time:last-child') + ?.getAttribute('datetime') === expected, + new Date(end + advance).toISOString(), + ); + await verifyWindow(detail, '24h'); + assert.ok( + Math.abs( + (await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '24h'), + ) < 0.01, + ); + // Source/range replacement must clear the old window as well as its samples. + state = 'hold'; + progress('refreshed-window-verified', { theme }); + await detail.getByRole('combobox', { name: 'History range' }).selectOption('1h'); + await detail.getByText('Loading history', { exact: true }).first().waitFor(); + assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0); + assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0); + assert.equal(await detail.getByRole('slider').count(), 0); + assert.ok(held); + state = 'success'; + await held.route.fulfill({ json: response(held.type, held.id, held.range) }); + await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor(); + await verifyWindow(detail, '1h'); + assert.ok( + Math.abs( + (await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '1h'), + ) < 0.01, + ); + await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) }); + progress('replacement-window-verified', { theme }); + state = 'empty'; + await detail.getByRole('button', { name: 'Refresh history' }).click(); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0, + ); + await verifyWindow(detail, '1h'); + assert.equal(await detail.getByRole('slider').count(), 0); + assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4); + state = 'success'; + await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d'); + await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor(); + await verifyWindow(detail, '7d'); + const readsBeforeLock = requests.length; + await detail.getByRole('combobox', { name: 'History range' }).selectOption('14d'); + await detail.getByText(/14 days history requires a higher license plan/).waitFor(); + assert.equal(requests.length, readsBeforeLock); + assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0); + assert.equal(errors.length, 0, errors.join('\n')); + observations.push({ theme, requests, errors, utilizationX, networkX }); + progress('completed-theme', { theme }); + await page.close(); + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify( + { + engine, + width, + browserVersion: browser.version(), + playwrightVersion, + sourceHashes, + observations, + }, + null, + 2, + ) + '\n', + ); + console.log( + JSON.stringify({ + result: 'passed', + engine, + width, + browserVersion: browser.version(), + sourceHashes, + cases: observations.length, + }), + ); + } finally { + await browser?.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3454045d2..6fd543733 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,18 @@ { "version": 1, - "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", - "verified_at": "2026-10-01T00:37:51Z", + "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", + "verified_at": "2026-10-01T01:42:43Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" }, "routes": [ - "/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)" + "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)" ], "viewports": [ { @@ -23,65 +25,174 @@ } ], "states": [ - "light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp", - "a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data", - "zero-valued lone observations can be inspected without a synthetic trend", - "genuinely aligned series share cursor geometry and their own observed values", - "network directions preserve independently missing and zero rates", - "native keyboard/touch selection takes precedence over pointer movement", - "failed same-source refresh retains correctly attributed observations and a visible warning", - "a successful refresh updates the hovered timestamp values without choosing other latest metrics", - "held service-target replacement has no former-host observations, cursor or dated pointer description" + "light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot", + "all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels", + "three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities", + "same-time observations occupy the same horizontal coordinate across groups with different sample coverage", + "failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window", + "held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window", + "empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read" ], "interactions": [ - "move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry", - "leave the plot to restore latest/current legends; verify pointer inspection makes no API reads", - "focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection", - "Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area", - "fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response" + "desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)", + "focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network", + "Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads", + "fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", + "command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.", "browser_versions": { "chromium": "141.0.7390.37", "firefox": "142.0.1", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log" + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log" ], - "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability.", + "notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.", "prior_verifications": [ + { + "version": 1, + "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", + "verified_at": "2026-10-01T00:37:51Z", + "result": "passed", + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" + }, + "routes": [ + "/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp", + "a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data", + "zero-valued lone observations can be inspected without a synthetic trend", + "genuinely aligned series share cursor geometry and their own observed values", + "network directions preserve independently missing and zero rates", + "native keyboard/touch selection takes precedence over pointer movement", + "failed same-source refresh retains correctly attributed observations and a visible warning", + "a successful refresh updates the hovered timestamp values without choosing other latest metrics", + "held service-target replacement has no former-host observations, cursor or dated pointer description" + ], + "interactions": [ + "move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry", + "leave the plot to restore latest/current legends; verify pointer inspection makes no API reads", + "focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection", + "Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area", + "fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", + "browser_versions": { + "chromium": "141.0.7390.37", + "firefox": "142.0.1", + "webkit": "26.0" + }, + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log" + ], + "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability." + }, { "version": 1, "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", "verified_at": "2026-09-30T23:28:26Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" - ], + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" }, @@ -221,9 +332,7 @@ "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", "verified_at": "2026-09-30T20:12:09Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" - ], + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" }, @@ -278,9 +387,7 @@ "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", "verified_at": "2026-09-30T01:40:13Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" - ], + "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" }, @@ -329,9 +436,7 @@ "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", "verified_at": "2026-09-29T23:04:22Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" - ], + "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" }, @@ -427,9 +532,7 @@ "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", "verified_at": "2026-09-29T19:50:45Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/pages/Actions.tsx" - ], + "changed_paths": ["frontend-modern/src/pages/Actions.tsx"], "content_sha256": { "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" }, @@ -470,9 +573,7 @@ "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", "verified_at": "2026-09-29T19:09:52Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/actions/ActionReviewDialog.tsx" - ], + "changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"], "content_sha256": { "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" }, diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 4a0624e37..8b44aefcb 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -16,9 +16,30 @@ import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeSt import guestDrawerSource from './GuestDrawer.tsx?raw'; import guestDrawerManageSource from './GuestDrawerManage.tsx?raw'; import guestDrawerOverviewSource from './GuestDrawerOverview.tsx?raw'; +import { getGuestDrawerHistoryRangeBounds } from './guestDrawerModel'; // ── Mocks ────────────────────────────────────────────────────────────── +describe('shared drawer History window geometry', () => { + it('preserves the requested interval and edge observations without manufacturing timestamps', () => { + const sample = (timestamp: number) => ({ timestamp, value: 0, min: 0, max: 0 }); + const series = [{ points: [sample(800), sample(900)] }, { points: [sample(300)] }]; + expect(getGuestDrawerHistoryRangeBounds(series, { start: 0, end: 1000 })).toEqual({ + startTime: 0, + endTime: 1000, + }); + expect(getGuestDrawerHistoryRangeBounds(series, { start: 500, end: 1000 })).toEqual({ + startTime: 300, + endTime: 1000, + }); + expect(getGuestDrawerHistoryRangeBounds(series, { start: 1000, end: 0 })).toEqual({ + startTime: 300, + endTime: 900, + }); + expect(getGuestDrawerHistoryRangeBounds([], { start: 0, end: 0 })).toBeNull(); + }); +}); + const chartsApiMocks = vi.hoisted(() => ({ getMetricsHistory: vi.fn(), })); diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 9e70e0dc8..6cf02dac9 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -41,6 +41,7 @@ import { normalizeGuestDrawerHistoryPoints, type GuestDrawerHistoryGroupConfig, type GuestDrawerHistoryTarget, + type GuestDrawerHistoryTimeBounds, } from './guestDrawerModel'; interface GuestDrawerHistoryProps { @@ -68,6 +69,7 @@ interface GuestDrawerHistoryGroupChartProps { metrics: Record; range: HistoryTimeRange; sourceKey: string; + timeBounds: GuestDrawerHistoryTimeBounds | null; } const GUEST_DRAWER_HISTORY_MAX_POINTS = 240; @@ -273,7 +275,7 @@ const GuestDrawerHistoryGroupChart: Component }); const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2)); const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit)); - const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series())); + const bounds = () => props.timeBounds; const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null); const hoveredSeries = createMemo(() => { const timestamp = activeTimestamp(); @@ -480,6 +482,36 @@ const GuestDrawerHistoryGroupChart: Component + + {(window) => ( +
+ + {(endpoint) => ( + + )} + +
+ )} +

{chartDescription()}

@@ -560,6 +592,16 @@ export const GuestDrawerHistory: Component = (props) => const metrics = createMemo(() => historyQuery.value().metrics ?? {}); const groups = createMemo(() => props.groups ?? GUEST_DRAWER_HISTORY_GROUPS); + const timeBounds = createMemo(() => + getGuestDrawerHistoryRangeBounds( + groups().flatMap((group) => + group.series.map((series) => ({ + points: normalizeGuestDrawerHistoryPoints(metrics()[series.metric], series.unit), + })), + ), + historyQuery.value(), + ), + ); const hasHistoryPoints = createMemo(() => groups().some((group) => group.series.some( @@ -628,6 +670,7 @@ export const GuestDrawerHistory: Component = (props) => currentMetrics={props.currentMetrics} range={props.range} sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`} + timeBounds={timeBounds()} /> )} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx new file mode 100644 index 000000000..cdcdde797 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx @@ -0,0 +1,243 @@ +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, type HistoryTimeRange } from '@/api/charts'; +import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { GuestDrawerHistory } from '../GuestDrawerHistory'; +import { + getGuestDrawerHistoryRangeBounds, + normalizeGuestDrawerHistoryPoints, +} from '../guestDrawerModel'; + +vi.mock('@/stores/license', () => ({ + loadRuntimeCapabilities: vi.fn(async () => undefined), + maxHistoryDays: () => 7, + isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range), +})); + +afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +const start = Date.UTC(2026, 8, 30, 12); +const hour = 3_600_000; +const end = start + 24 * hour; +const target = { resourceType: 'agent' as const, resourceId: 'pbs-host' }; +const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value }); +const response = ( + overrides: Partial = {}, +): AllMetricsHistoryResponse => ({ + ...target, + range: '24h', + start, + end, + source: 'store', + metrics: { + cpu: [point(end - 10 * 60_000, 12), point(end - 5 * 60_000, 25)], + memory: [point(end - 5 * 60_000, 40)], + netin: [point(end - hour, 0), point(end - 5 * 60_000, 2048)], + diskwrite: [point(end - 5 * 60_000, 0), point(end, 0)], + temperature: [point(end - 5 * 60_000, 50)], + }, + ...overrides, +}); +const mount = () => + render(() => ( + + )); +const group = (id: string) => + screen + .getAllByTestId('guest-history-group-chart') + .find((chart) => chart.dataset.historyGroup === id)!; +const pathXs = (chart: HTMLElement) => + [ + ...chart + .querySelector('path')! + .getAttribute('d')! + .matchAll(/[ML](-?[\d.]+),/g), + ].map((match) => Number(match[1])); +const expectedX = (timestamp: number, first = start, last = end) => + 34 + ((timestamp - first) / Math.max(1, last - first)) * 318; +const axis = (chart: HTMLElement) => within(chart).getByTestId('guest-history-time-window'); +const selectSharedTime = (chart: HTMLElement) => { + const slider = within(chart).getByRole('slider'); + slider.focus(); + fireEvent.input(slider, { target: { value: '1' } }); + return chart.querySelector('circle[r="3"]')!; +}; + +describe('GuestDrawerHistory shared time window', () => { + it('does not stretch five minutes of stored CPU across a 24-hour query', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const xs = pathXs(group('utilization')); + expect(xs[0]).toBeCloseTo(expectedX(end - 10 * 60_000), 2); + expect(xs[1]).toBeCloseTo(expectedX(end - 5 * 60_000), 2); + expect(xs[1] - xs[0]).toBeLessThan(2); + }); + + it('places the same observation time at the same x in panels with different sample coverage', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Network I/O history' }); + const utilizationX = selectSharedTime(group('utilization')).getAttribute('cx'); + const networkX = selectSharedTime(group('network')).getAttribute('cx'); + expect(networkX).toBe(utilizationX); + expect(Number(networkX)).toBeCloseTo(expectedX(end - 5 * 60_000)); + }); + + it('labels every panel with the full dated interval, including empty and lone-observation panels', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + for (const chart of screen.getAllByTestId('guest-history-group-chart')) { + const times = axis(chart).querySelectorAll('time'); + expect(times).toHaveLength(2); + expect(times[0]).toHaveAttribute('datetime', new Date(start).toISOString()); + expect(times[1]).toHaveAttribute('datetime', new Date(end).toISOString()); + expect(times[0]).toHaveAccessibleName(`Window start: ${new Date(start).toLocaleString()}`); + expect(times[1]).toHaveAccessibleName(`Window end: ${new Date(end).toLocaleString()}`); + expect(times[0].textContent).not.toBe(times[1].textContent); + } + }); + + it('retains the last fulfilled window with its data after a failed refresh', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(new Error('Private fixture detail')); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const before = group('utilization').querySelector('path')!.getAttribute('d'); + fireEvent.click(screen.getByRole('button', { name: 'Refresh history' })); + await screen.findByText('History refresh failed. Showing previously loaded history.'); + expect(group('utilization').querySelector('path')).toHaveAttribute('d', before); + expect(axis(group('utilization')).querySelector('time')).toHaveAttribute( + 'datetime', + new Date(start).toISOString(), + ); + expect(document.body).not.toHaveTextContent('Private fixture detail'); + }); + + it('moves the window on a successful poll without moving a selected observation to a different time', async () => { + vi.useFakeTimers(); + const shift = 10 * 60_000; + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(response({ start: start + shift, end: end + shift })); + mount(); + await vi.advanceTimersByTimeAsync(0); + selectSharedTime(group('utilization')); + const valueText = within(group('utilization')) + .getByRole('slider') + .getAttribute('aria-valuetext'); + await vi.advanceTimersByTimeAsync(30_000); + expect(within(group('utilization')).getByRole('slider')).toHaveAttribute( + 'aria-valuetext', + valueText, + ); + expect( + Number(group('utilization').querySelector('circle[r="3"]')!.getAttribute('cx')), + ).toBeCloseTo(expectedX(end - 5 * 60_000, start + shift, end + shift)); + expect(axis(group('utilization')).querySelectorAll('time')[1]).toHaveAttribute( + 'datetime', + new Date(end + shift).toISOString(), + ); + }); + + it('does not keep former-range endpoints while a replacement read is pending', async () => { + let finish!: (value: AllMetricsHistoryResponse) => void; + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const [range, setRange] = createSignal('24h'); + render(() => ( + + )); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + setRange('1h'); + await waitFor(() => + expect(screen.queryAllByTestId('guest-history-time-window')).toHaveLength(0), + ); + expect(group('utilization').querySelectorAll('path')).toHaveLength(0); + finish(response({ range: '1h', start: end - hour })); + await waitFor(() => expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4)); + expect(pathXs(group('utilization'))[0]).toBeCloseTo(expectedX(end - 10 * 60_000, end - hour)); + }); + + it('keeps an empty valid query window without inventing observations or a trend', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ metrics: {} })); + mount(); + await waitFor(() => expect(screen.getAllByText('Collecting history')).toHaveLength(4)); + expect(screen.queryByRole('slider')).toBeNull(); + expect(document.querySelectorAll('path')).toHaveLength(0); + expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4); + }); + + it('falls back to one shared observed interval when the API window is invalid', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ start: NaN, end: Infinity }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const xs = pathXs(group('utilization')); + expect(xs[0]).toBeCloseTo(expectedX(end - 10 * 60_000, end - hour)); + expect(axis(group('utilization')).querySelector('time')).toHaveAttribute( + 'datetime', + new Date(end - hour).toISOString(), + ); + expect(selectSharedTime(group('utilization')).getAttribute('cx')).toBe( + selectSharedTime(group('network')).getAttribute('cx'), + ); + }); + + it('encloses returned edge observations rather than clipping or discarding an aggregated bucket', async () => { + const edge = start - 5 * 60_000; + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + metrics: { ...response().metrics, cpu: [point(edge, 10), point(end - 5 * 60_000, 25)] }, + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(axis(group('network')).querySelector('time')).toHaveAttribute( + 'datetime', + new Date(edge).toISOString(), + ); + expect(pathXs(group('network'))[0]).toBeCloseTo(expectedX(end - hour, edge)); + expect(within(group('utilization')).getByRole('slider')).toHaveAttribute('max', '1'); + }); + + it('rejects non-dates before geometry and labels can be poisoned', () => { + expect(normalizeGuestDrawerHistoryPoints([point(9e15, 12), point(start, 0)], '%')).toEqual([ + point(start, 0), + ]); + expect( + getGuestDrawerHistoryRangeBounds([{ points: [point(9e15, 12), point(start, 0)] }]), + ).toEqual({ startTime: start, endTime: start }); + }); + + it('does not let an unconfigured metric change the visible panels time window', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + metrics: { ...response().metrics, unrelated: [point(end + 90 * 24 * hour, 10)] }, + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(axis(group('utilization')).querySelectorAll('time')[1]).toHaveAttribute( + 'datetime', + new Date(end).toISOString(), + ); + expect(pathXs(group('utilization'))[0]).toBeCloseTo(expectedX(end - 10 * 60_000), 2); + }); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 8299c05fa..88fcafee6 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -55,6 +55,11 @@ export interface GuestDrawerHistoryScale { maxValue: number; } +export interface GuestDrawerHistoryTimeBounds { + startTime: number; + endTime: number; +} + export interface GuestDrawerBackupPresentation { ageClass: string; ageLabel: string; @@ -157,12 +162,15 @@ const clampHistoryPointValue = (value: number, unit: string): number => { return unit === '%' ? Math.min(100, nonNegative) : nonNegative; }; +const isHistoryTimestamp = (timestamp: number): boolean => + Number.isFinite(timestamp) && Number.isFinite(new Date(timestamp).getTime()); + export const normalizeGuestDrawerHistoryPoints = ( points: AggregatedMetricPoint[] | undefined, unit: string, ): AggregatedMetricPoint[] => (points ?? []) - .filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.value)) + .filter((point) => isHistoryTimestamp(point.timestamp) && Number.isFinite(point.value)) .map((point) => { const value = clampHistoryPointValue(point.value, unit); return { @@ -271,12 +279,29 @@ export const getGuestDrawerHistoryValueLabel = ( export const getGuestDrawerHistoryRangeBounds = ( groupedSeries: readonly { points: readonly AggregatedMetricPoint[] }[], -): { startTime: number; endTime: number } | null => { - const timestamps = groupedSeries.flatMap((item) => item.points.map((point) => point.timestamp)); - if (timestamps.length === 0) return null; + window?: { start: number; end: number }, +): GuestDrawerHistoryTimeBounds | null => { + const timestamps = groupedSeries + .flatMap((item) => item.points.map((point) => point.timestamp)) + .filter(isHistoryTimestamp); + const windowBounds = + window && + isHistoryTimestamp(window.start) && + isHistoryTimestamp(window.end) && + window.end > window.start + ? { startTime: window.start, endTime: window.end } + : null; + if (timestamps.length === 0) return windowBounds; + + // The API's requested window is shared across every panel. Do not stretch + // a few recent readings across the whole selected range, or scale each + // metric group to different times. Preserve returned edge observations + // (including aggregate bucket timestamps) by widening the common envelope. + const first = Math.min(...timestamps); + const last = Math.max(...timestamps); return { - startTime: Math.min(...timestamps), - endTime: Math.max(...timestamps), + startTime: Math.min(windowBounds?.startTime ?? first, first), + endTime: Math.max(windowBounds?.endTime ?? last, last), }; };