From d38e2ec61a933b1621895cc71c17ec366a8e99ce Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:28:29 +0100 Subject: [PATCH 1/3] Make drawer History inspectable by keyboard and touch Expose existing stored history through labelled native observation controls and dated accessible descriptions. Keep missing samples distinct from live or neighbouring readings, preserve the selected timestamp through refreshes and reset selection at resource/range boundaries. Cover input state and real native browser interaction without changing collection or licence gates. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 21 ++ .../browser-tests/history-inspection.cjs | 345 ++++++++++++++++++ frontend-modern/browser-verification.json | 174 ++++++--- .../Workloads/GuestDrawerHistory.tsx | 120 +++++- .../GuestDrawerHistory.inspection.test.tsx | 273 ++++++++++++++ 5 files changed, 869 insertions(+), 64 deletions(-) create mode 100644 frontend-modern/browser-tests/history-inspection.cjs create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.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 a646bca4a2..644b9ffc1f 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7556,3 +7556,24 @@ catalog, the header metadata and every locale catalog; the product name is not translated. Its route id, feature gate and read capability are unchanged. Relay never provided remote access to the web UI, so no settings chrome may label it Remote Access. + +### Drawer History is inspectable without pointer hover + +`GuestDrawerHistory` exposes a labelled native range input for groups with +multiple stored observation times. Native arrow keys, Home/End and touch input +select real stored timestamps; the control's value text includes the full local +date/time and separately formatted series values. A series without a sample at +the selected time remains missing rather than borrowing a neighbouring or live +reading. The SVG has a linked textual description, including lone observations +and the absence of stored data; a lone observation does not fabricate a trend. + +Selection follows its timestamp across same-source refreshes, not an ordinal +index that shifts when samples arrive. An expired selection snaps to an actual +remaining observation. Changing resource type, id or range clears pointer and +keyboard selection even when matching cached data arrives immediately. Empty, +failed initial, absent-target and licence-locked views expose no inspection +control. Existing matching-point retention and scoped retry remain unchanged. +Mounted inspection regressions cover these boundaries. The direct-renderer +browser fixture verifies native keys, focus, pointer coexistence and touch at +desktop/phone widths across Chromium, Firefox and WebKit; it is not installed +PBS collection or a screen-reader announcement-quality claim. diff --git a/frontend-modern/browser-tests/history-inspection.cjs b/frontend-modern/browser-tests/history-inspection.cjs new file mode 100644 index 0000000000..5bdcadae63 --- /dev/null +++ b/frontend-modern/browser-tests/history-inspection.cjs @@ -0,0 +1,345 @@ +// Direct production History/query + CSS with synthetic API data. Not installed PBS acceptance. +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 artifacts = path.join(root, 'node_modules', `history-inspection-${engine}-${width}`); + fs.mkdirSync(artifacts, { recursive: true }); + 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: 5223, strictPort: true }, + }); + 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 ['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(20_000); + const requests = [], + errors = []; + let action = 'original', + held; + page.on('pageerror', (error) => errors.push(error.message)); + const time = 1_700_000_000_000; + const point = (offset, value) => ({ + timestamp: time + offset, + value, + min: value, + max: value, + }); + const metrics = (kind) => { + if (kind === 'single') return { cpu: [point(0, 11)] }; + if (kind === 'empty') return {}; + if (kind === 'service') + return { + cpu: [point(0, 77), point(120_000, 88)], + memory: [point(0, 33), point(120_000, 44)], + }; + return { + cpu: + kind === 'updated' + ? [ + point(-60_000, 8), + point(0, 11), + point(60_000, 22), + point(120_000, 43), + point(180_000, 66), + ] + : [point(120_000, 43), point(0, 11), point(60_000, 22)], + memory: [point(30_000, 30), point(120_000, 55)], + // Zero throughput is an observation, not missing history. + netin: [point(0, 0), point(120_000, 2048)], + netout: [point(0, 1024), point(120_000, 4096)], + diskread: [point(0, 2048), point(120_000, 8192)], + diskwrite: [point(0, 0), point(120_000, 0)], + }; + }; + const response = (id, range, kind) => ({ + resourceType: 'agent', + resourceId: id, + range, + start: time, + end: time + 180_000, + source: 'store', + metrics: metrics(kind), + }); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5223') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const id = url.searchParams.get('resourceId'), + range = url.searchParams.get('range'); + assert.ok(['agent-three', 'pbs-three'].includes(id)); + const kind = action; + action = id === 'pbs-three' ? 'service' : 'original'; + requests.push({ id, range, kind, method: route.request().method() }); + if (kind === 'hold') { + held = route; + return; + } + if (kind === 'failure') + return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } }); + return route.fulfill({ json: response(id, range, kind) }); + } + 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 } }); + }); + await page.goto('http://127.0.0.1:5223/browser-tests/pbs-history-refresh.html', { + waitUntil: 'domcontentloaded', + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + const fixture = page.getByTestId('history-refresh-fixture'); + const utilization = fixture.locator('[data-history-group="utilization"]'); + const slider = fixture.getByRole('slider', { name: 'Inspect Utilization history' }); + await slider.waitFor(); + const countBeforeInspection = requests.length; + const value = () => slider.getAttribute('aria-valuetext'); + const screenshot = (state) => + fixture.screenshot({ path: path.join(artifacts, `${state}-${theme}.png`) }); + const assertValue = async (pattern) => assert.match(await value(), pattern); + const layout = async () => { + const dimensions = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + inner: innerWidth, + })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + const box = await slider.boundingBox(); + assert.ok(box && box.x >= 0 && box.x + box.width <= width + 1); + if (width === 390) assert.ok(box.height >= 44, JSON.stringify(box)); + return dimensions; + }; + // Tab reaches a native slider after the existing range/refresh controls. + await fixture.getByTestId('guest-history-range-control').focus(); + await page.keyboard.press('Tab'); + assert.equal( + await fixture + .getByRole('button', { name: 'Refresh history' }) + .evaluate((el) => document.activeElement === el), + true, + ); + await page.keyboard.press('Tab'); + assert.equal(await slider.evaluate((el) => document.activeElement === el), true); + const focusStyle = await slider.evaluate((el) => { + const style = getComputedStyle(el); + return { + visible: el.matches(':focus-visible'), + colour: style.outlineColor, + width: style.outlineWidth, + style: style.outlineStyle, + background: getComputedStyle(el.closest('section')).backgroundColor, + }; + }); + assert.equal(focusStyle.visible, true); + assert.equal(focusStyle.colour, 'rgb(59, 130, 246)'); + assert.equal(focusStyle.style, 'solid'); + assert.ok(parseFloat(focusStyle.width) >= 2); + const luminance = (colour) => { + const channels = colour + .match(/\d+/g) + .slice(0, 3) + .map((n) => { + const s = Number(n) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }; + const a = luminance(focusStyle.colour), + b = luminance(focusStyle.background); + focusStyle.contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + assert.ok(focusStyle.contrast >= 3, JSON.stringify(focusStyle)); + await assertValue(/CPU 43\.0%\. Memory 55\.0%\. Disk no observation\./); + await slider.press('Home'); + assert.equal(await slider.inputValue(), '0'); + await assertValue(/CPU 11\.0%\. Memory no observation\. Disk no observation\./); + await slider.press('ArrowRight'); + assert.equal(await slider.inputValue(), '1'); + await assertValue(/CPU no observation\. Memory 30\.0%\. Disk no observation\./); + assert.match(await utilization.innerText(), /CPU\s*-/); + assert.doesNotMatch(await utilization.innerText(), /53\.0%/); + await layout(); + await screenshot('selected-sparse-time'); + await slider.press('End'); + assert.equal(await slider.inputValue(), '3'); + await slider.press('ArrowLeft'); + await assertValue(/CPU 22\.0%\. Memory no observation/); + assert.equal( + requests.length, + countBeforeInspection, + 'inspection must not start another API read', + ); + const selectedBeforePoll = await value(); + // Exercise an actual same-source refresh without moving focus, as a background poll would. + action = 'updated'; + await fixture.getByRole('button', { name: 'Refresh history' }).evaluate((el) => el.click()); + await page.waitForFunction( + () => + document.querySelector('input[aria-label="Inspect Utilization history"]')?.max === '5', + ); + assert.equal(await slider.inputValue(), '3'); + assert.equal(await value(), selectedBeforePoll); + assert.equal(await slider.evaluate((el) => document.activeElement === el), true); + const plot = utilization.getByTestId('guest-history-plot'); + const box = await plot.boundingBox(); + await page.mouse.move(box.x + box.width - 4, box.y + box.height / 2); + await page.mouse.move(1, 1); + assert.equal(await value(), selectedBeforePoll); + assert.match(await utilization.innerText(), /CPU\s*22\.0%/); + // Native blur restores the latest legend; mouse inspection still works. + await slider.press('Tab'); + assert.match(await utilization.innerText(), /CPU\s*66\.0%/); + await page.mouse.move(box.x + (34 / 360) * box.width, box.y + box.height / 2); + assert.match(await utilization.innerText(), /CPU\s*8\.0%/); + await page.mouse.move(1, 1); + + if (width === 390) { + await slider.scrollIntoViewIfNeeded(); + const touchBox = await slider.boundingBox(); + await page.touchscreen.tap(touchBox.x + 3, touchBox.y + touchBox.height / 2); + await assertValue(/CPU 8\.0%\. Memory no observation/); + assert.match(await utilization.innerText(), /CPU\s*8\.0%/); + await page.touchscreen.tap( + touchBox.x + touchBox.width - 3, + touchBox.y + touchBox.height / 2, + ); + await assertValue(/CPU 66\.0%\. Memory no observation/); + assert.match(await utilization.innerText(), /CPU\s*66\.0%/); + } + action = 'failure'; + await fixture.getByRole('button', { name: 'Refresh history' }).click(); + await fixture + .getByRole('status', { name: 'History refresh status' }) + .getByText('History refresh failed. Showing previously loaded history.', { exact: true }) + .waitFor(); + await slider.focus(); + await slider.press('Home'); + await assertValue(/CPU 8\.0%\. Memory no observation/); + assert.doesNotMatch(await fixture.innerText(), /Private fixture detail/); + await layout(); + await screenshot('failed-refresh-inspection'); + const semantics = await slider.ariaSnapshot(); + action = 'single'; + await fixture.getByRole('button', { name: 'Retry history' }).click(); + await page.waitForFunction( + () => document.querySelectorAll('input[type="range"]').length === 0, + ); + const description = await utilization + .getByRole('img') + .evaluate((el) => document.getElementById(el.getAttribute('aria-describedby')).textContent); + assert.match(description, /1 stored observation time\. .*CPU 11\.0%/); + assert.equal(await utilization.locator('path').count(), 0); + action = 'empty'; + await fixture.getByRole('button', { name: 'Refresh history' }).click(); + await page.waitForFunction(() => { + const image = document.querySelector('[data-history-group="utilization"] svg[role="img"]'); + return ( + document.getElementById(image.getAttribute('aria-describedby')).textContent === + 'No stored history observations.' + ); + }); + assert.equal(await fixture.getByRole('slider').count(), 0); + // Changing resource cannot carry an inspected former-host point into the new target. + action = 'original'; + await fixture.getByRole('button', { name: 'Refresh history' }).click(); + await slider.waitFor(); + await slider.focus(); + await slider.press('Home'); + action = 'hold'; + await page.getByRole('button', { name: 'Switch to service target' }).click(); + await page.waitForFunction( + () => document.querySelectorAll('input[type="range"]').length === 0, + ); + assert.equal(await fixture.locator('svg path').count(), 0); + assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0); + assert.ok(held, 'new target read must be held'); + await held.fulfill({ json: response('pbs-three', '24h', 'service') }); + await slider.waitFor(); + assert.equal(await slider.inputValue(), '1'); + await assertValue(/CPU 88\.0%\. Memory 44\.0%\. Disk no observation/); + assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0); + const readsBeforeLock = requests.length; + await fixture.getByTestId('guest-history-range-control').selectOption('14d'); + await fixture.getByText(/14 days history requires a higher license plan/).waitFor(); + assert.equal(await fixture.getByRole('slider').count(), 0); + assert.equal(requests.length, readsBeforeLock); + assert.deepEqual(errors, []); + observations.push({ + engine, + version: browser.version(), + width, + theme, + requests, + errors, + semantics, + layout: { width }, + singleDescription: description, + focusStyle, + nativeKeys: ['Tab', 'Home', 'ArrowRight', 'End', 'ArrowLeft'], + touch: width === 390, + }); + console.log(JSON.stringify({ engine, width, theme, result: 'passed' })); + await page.close(); + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify( + { + result: 'passed', + engine, + version: browser.version(), + playwright: require('playwright/package.json').version, + runtime_sha256: createHash('sha256') + .update( + fs.readFileSync(path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx')), + ) + .digest('hex'), + observations, + }, + null, + 2, + ), + ); + } finally { + if (browser) 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 b95b05717e..57f187b9d8 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,14 @@ { "version": 1, - "base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3", - "verified_at": "2026-09-30T22:01:47Z", + "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", + "verified_at": "2026-09-30T23:28:26Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts" - ], + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" }, "routes": [ - "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)" + "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)" ], "viewports": [ { @@ -25,51 +21,127 @@ } ], "states": [ - "light/dark current host history", - "real 30-second poll fails while matching points remain explicitly labelled", - "keyboard retry held with busy/activation guard and retained points", - "successful retry keeps keyboard focus and updates the same control", - "real background polling supersedes a slow foreground read and settles loading", - "late aborted response cannot replace current paths", - "uncached service-target failure has neither former-host points nor a collecting claim", - "service-target retry renders CPU/memory only", - "locked range exposes no read or refresh action" + "light/dark ordered and sparse stored series", + "native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface", + "missing series at a selected timestamp stay missing, not nearby or live values", + "same-source refresh adds samples without changing the selected timestamp or keyboard focus", + "failed refresh retains inspectable matching observations and honest warning", + "single stored observation has a dated description but no fabricated trend or pointless slider", + "empty store/current readings expose no inspection control", + "held replacement target has no former-host points or selection; new target starts at its own latest observation", + "licence-locked range has no reads or inspection action" ], "interactions": [ - "Enter retry twice while its GET is held; exactly one retry read starts", - "fulfil retry; inspect warning removal, changed paths and retained focus", - "hold manual refresh across the real polling interval; inspect loading settlement and aborted read", - "switch to service target, fail its GET, retry the same key, select locked 14d" + "Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads", + "refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp", + "move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works", + "Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values", + "fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).", + "command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command 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-jybdl1pm/receipts/renderer-browser-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png" + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log" ], - "notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed.", + "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten.", "prior_verifications": [ + { + "version": 1, + "base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3", + "verified_at": "2026-09-30T22:01:47Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79" + }, + "routes": [ + "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "light/dark current host history", + "real 30-second poll fails while matching points remain explicitly labelled", + "keyboard retry held with busy/activation guard and retained points", + "successful retry keeps keyboard focus and updates the same control", + "real background polling supersedes a slow foreground read and settles loading", + "late aborted response cannot replace current paths", + "uncached service-target failure has neither former-host points nor a collecting claim", + "service-target retry renders CPU/memory only", + "locked range exposes no read or refresh action" + ], + "interactions": [ + "Enter retry twice while its GET is held; exactly one retry read starts", + "fulfil retry; inspect warning removal, changed paths and retained focus", + "hold manual refresh across the real polling interval; inspect loading settlement and aborted read", + "switch to service target, fail its GET, retry the same key, select locked 14d" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).", + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png" + ], + "notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed." + }, { "version": 1, "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" }, @@ -124,9 +196,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" }, @@ -175,9 +245,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" }, @@ -273,9 +341,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" }, @@ -316,9 +382,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/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 42a7d1435d..2d6a711ebf 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -1,4 +1,14 @@ -import { For, Show, createMemo, createSignal, onMount, type Component } from 'solid-js'; +import { + For, + Show, + createEffect, + createMemo, + createSignal, + createUniqueId, + on, + onMount, + type Component, +} from 'solid-js'; import { ChartsAPI, @@ -57,6 +67,7 @@ interface GuestDrawerHistoryGroupChartProps { loading: boolean; metrics: Record; range: HistoryTimeRange; + sourceKey: string; } const GUEST_DRAWER_HISTORY_MAX_POINTS = 240; @@ -196,24 +207,73 @@ const findClosestGuestDrawerHistoryPoint = ( const GuestDrawerHistoryGroupChart: Component = (props) => { const [hoverTimestamp, setHoverTimestamp] = createSignal(null); + const [selectedTimestamp, setSelectedTimestamp] = createSignal(null); + const inspectionId = `history-inspection-${createUniqueId()}`; + createEffect( + on( + () => props.sourceKey, + () => { + setHoverTimestamp(null); + setSelectedTimestamp(null); + }, + ), + ); const series = createMemo(() => props.group.series.map((config) => ({ ...config, points: normalizeGuestDrawerHistoryPoints(props.metrics[config.metric], config.unit), })), ); + // Step through actual stored times, not synthetic points between samples. + // Keep the selected time across polls even if its ordinal index changes. + const observationTimes = createMemo(() => + [...new Set(series().flatMap((item) => item.points.map((point) => point.timestamp)))].sort( + (a, b) => a - b, + ), + ); + const selectedIndex = createMemo(() => { + const times = observationTimes(); + const selected = selectedTimestamp(); + if (selected === null) return Math.max(0, times.length - 1); + let index = 0; + for (let i = 1; i < times.length; i++) { + if (Math.abs(times[i] - selected) < Math.abs(times[index] - selected)) index = i; + } + return index; + }); + const inspectionTimestamp = createMemo(() => + selectedTimestamp() === null ? null : (observationTimes()[selectedIndex()] ?? null), + ); + const inspectionValueText = createMemo(() => { + const timestamp = observationTimes()[selectedIndex()]; + if (timestamp === undefined) return 'No stored history observations.'; + const values = series().map((item) => { + const point = item.points.find((point) => point.timestamp === timestamp); + return `${item.label} ${point ? formatHistoryChartTooltipValue(point.value, item.unit) : 'no observation'}`; + }); + return `${new Date(timestamp).toLocaleString()}. ${values.join('. ')}.`; + }); + const chartDescription = createMemo(() => { + if (props.loading) return 'Loading history.'; + const count = observationTimes().length; + if (count === 0) return inspectionValueText(); + return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`; + }); const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2)); const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit)); const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series())); const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null); const hoveredSeries = createMemo(() => { - const timestamp = hoverTimestamp(); + const timestamp = inspectionTimestamp() ?? hoverTimestamp(); const rangeBounds = bounds(); if (timestamp === null || !rangeBounds) return []; - return drawableSeries() + return (inspectionTimestamp() === null ? drawableSeries() : series()) .map((item) => { - const point = findClosestGuestDrawerHistoryPoint(item.points, timestamp); + const point = + inspectionTimestamp() === null + ? findClosestGuestDrawerHistoryPoint(item.points, timestamp) + : item.points.find((point) => point.timestamp === timestamp); if (!point) return null; return { ...item, @@ -240,11 +300,13 @@ const GuestDrawerHistoryGroupChart: Component ...item, valueLabel: hovered ? getGuestDrawerHistoryValueLabel([hovered.point], item.unit) - : item.points.length > 0 - ? getGuestDrawerHistoryValueLabel(item.points, item.unit) - : typeof currentValue === 'number' && Number.isFinite(currentValue) - ? formatHistoryChartTooltipValue(currentValue, item.unit) - : '-', + : inspectionTimestamp() !== null + ? '-' + : item.points.length > 0 + ? getGuestDrawerHistoryValueLabel(item.points, item.unit) + : typeof currentValue === 'number' && Number.isFinite(currentValue) + ? formatHistoryChartTooltipValue(currentValue, item.unit) + : '-', }; }), ); @@ -254,6 +316,7 @@ const GuestDrawerHistoryGroupChart: Component }); const handleHoverMove = (event: MouseEvent & { currentTarget: SVGSVGElement }) => { + if (inspectionTimestamp() !== null) return; const rangeBounds = bounds(); if (!rangeBounds) return; @@ -321,6 +384,7 @@ const GuestDrawerHistoryGroupChart: Component +

+ {chartDescription()} +

+ 1}> +
+ + { + setHoverTimestamp(null); + setSelectedTimestamp(observationTimes()[selectedIndex()] ?? null); + }} + onBlur={() => setSelectedTimestamp(null)} + onInput={(event) => { + const index = event.currentTarget.valueAsNumber; + if (Number.isFinite(index)) setSelectedTimestamp(observationTimes()[index] ?? null); + }} + /> +

+ Use arrow keys, Home or End to inspect stored observations. +

+
+
); }; @@ -517,6 +618,7 @@ export const GuestDrawerHistory: Component = (props) => metrics={metrics()} currentMetrics={props.currentMetrics} range={props.range} + sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`} /> )} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx new file mode 100644 index 0000000000..141f4f674e --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx @@ -0,0 +1,273 @@ +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 { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { GuestDrawerHistory } from '../GuestDrawerHistory'; +import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel'; +import type { GuestDrawerHistoryTarget } 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 time = 1_700_000_000_000; +const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' }; +const point = (offset: number, value: number) => ({ + timestamp: time + offset, + value, + min: value, + max: value, +}); +const response = ( + metrics: AllMetricsHistoryResponse['metrics'] = { + cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)], + memory: [point(30_000, 30), point(120_000, 55)], + }, +): AllMetricsHistoryResponse => ({ + ...target, + range: '24h', + start: time, + end: time + 120_000, + source: 'store', + metrics, +}); +const inspect = () => screen.getByRole('slider', { name: 'Inspect Utilization history' }); +const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0]; +const choose = (slider: HTMLElement, index: number) => + fireEvent.input(slider, { target: { value: String(index) } }); + +// Native keyboard/touch stepping is exercised in the offline browser; jsdom +// tests the renderer's input/focus contract rather than simulating browser defaults. +describe('GuestDrawerHistory stored-observation inspection', () => { + it('exposes an ordered native control, dated values and chart description without another read', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + await waitFor(() => expect(inspect()).toHaveAttribute('max', '3')); + const slider = inspect(); + expect(slider).toHaveAttribute('type', 'range'); + expect(slider).toHaveAttribute('min', '0'); + expect(slider).toHaveAttribute('step', '1'); + expect(slider).toHaveValue('3'); + expect(slider).toHaveAttribute( + 'aria-valuetext', + `${new Date(time + 120_000).toLocaleString()}. CPU 43.0%. Memory 55.0%. Disk no observation.`, + ); + expect( + within(utilization()).getByRole('img', { name: 'Utilization history' }), + ).toHaveAccessibleDescription(/4 stored observation times/); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('inspects exact stored times and does not borrow nearby or current values for missing series', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ( + + )); + await waitFor(() => expect(inspect()).toHaveValue('3')); + const slider = inspect(); + slider.focus(); + choose(slider, 0); + expect(slider).toHaveAttribute( + 'aria-valuetext', + `${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`, + ); + expect(utilization()).toHaveTextContent('CPU11.0%'); + expect(utilization()).toHaveTextContent('Memory-'); + expect(utilization()).not.toHaveTextContent('88.0%'); + choose(slider, 1); + expect(slider).toHaveAttribute( + 'aria-valuetext', + `${new Date(time + 30_000).toLocaleString()}. CPU no observation. Memory 30.0%. Disk no observation.`, + ); + expect(utilization()).toHaveTextContent('CPU-'); + expect(utilization()).toHaveTextContent('Memory30.0%'); + expect(slider).toHaveFocus(); + }); + + it('keeps focused selection independent of plot pointer movement, then restores normal hover on blur', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + await waitFor(() => expect(inspect()).toHaveValue('3')); + const slider = inspect(); + const plot = within(utilization()).getByTestId('guest-history-plot'); + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 360 } as DOMRect); + slider.focus(); + choose(slider, 0); + fireEvent.pointerMove(plot, { clientX: 352 }); + fireEvent.pointerLeave(plot); + expect(utilization()).toHaveTextContent('CPU11.0%'); + fireEvent.blur(slider); + expect(utilization()).toHaveTextContent('CPU43.0%'); + fireEvent.mouseMove(plot, { clientX: 34 }); + expect(utilization()).toHaveTextContent('CPU11.0%'); + }); + + it('preserves the selected stored timestamp, not its index, when a poll adds observations', async () => { + vi.useFakeTimers(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce( + response({ + cpu: [ + point(-60_000, 8), + point(0, 11), + point(60_000, 22), + point(120_000, 43), + point(180_000, 66), + ], + memory: [point(30_000, 30), point(120_000, 55)], + }), + ); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + const slider = inspect(); + slider.focus(); + choose(slider, 2); + await vi.advanceTimersByTimeAsync(30_000); + expect(inspect()).toBe(slider); + expect(slider).toHaveFocus(); + expect(slider).toHaveValue('3'); + expect(slider).toHaveAttribute( + 'aria-valuetext', + `${new Date(time + 60_000).toLocaleString()}. CPU 22.0%. Memory no observation. Disk no observation.`, + ); + expect(utilization()).toHaveTextContent('CPU22.0%'); + }); + + it('snaps an expired inspected timestamp to an actual remaining observation on refresh', async () => { + vi.useFakeTimers(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(response({ cpu: [point(120_000, 43), point(180_000, 66)] })); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + const slider = inspect(); + slider.focus(); + choose(slider, 0); + await vi.advanceTimersByTimeAsync(30_000); + expect(slider).toHaveValue('0'); + expect(slider.getAttribute('aria-valuetext')).toContain( + new Date(time + 120_000).toLocaleString(), + ); + expect(utilization()).toHaveTextContent('CPU43.0%'); + }); + + it.each(['target', 'resource type', 'range'] as const)( + 'clears selection on a cached %s replacement even when its timestamps coincide', + async (change) => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(response({ cpu: [point(0, 77), point(120_000, 88)] })) + .mockImplementation(() => new Promise(() => {})); + const [current, setTarget] = createSignal(target); + const [range, setRange] = createSignal('24h'); + render(() => ); + await waitFor(() => expect(inspect()).toHaveValue('3')); + if (change === 'target') setTarget({ ...target, resourceId: 'other-pbs' }); + else if (change === 'resource type') setTarget({ ...target, resourceType: 'vm' }); + else setRange('6h'); + await waitFor(() => expect(inspect()).toHaveValue('1')); + inspect().focus(); + choose(inspect(), 0); + expect(utilization()).toHaveTextContent('CPU77.0%'); + if (change !== 'range') setTarget(target); + else setRange('24h'); + await waitFor(() => expect(inspect()).toHaveValue('3')); + expect(utilization()).toHaveTextContent('CPU43.0%'); + expect( + within(utilization()).queryByTestId('guest-history-hover-time'), + ).not.toBeInTheDocument(); + }, + ); + + it('inspects zero throughput and temperature using the existing host metric units', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + netin: [point(0, 0), point(60_000, 2048)], + temperature: [point(0, 42), point(60_000, 57)], + }), + ); + render(() => ( + + )); + const network = await screen.findByRole('slider', { name: 'Inspect Network I/O history' }); + network.focus(); + choose(network, 0); + expect(network.getAttribute('aria-valuetext')).toContain('In 0 B/s. Out no observation.'); + const thermal = screen.getByRole('slider', { name: 'Inspect Thermals history' }); + thermal.focus(); + choose(thermal, 1); + expect(thermal.getAttribute('aria-valuetext')).toContain('CPU 57°C.'); + }); + + it('keeps stored observations inspectable after a failed poll without exposing the error', async () => { + vi.useFakeTimers(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(new Error('private backend detail')); + render(() => ); + await vi.advanceTimersByTimeAsync(30_000); + expect(screen.getByRole('status', { name: 'History refresh status' })).toHaveTextContent( + 'Showing previously loaded history.', + ); + inspect().focus(); + choose(inspect(), 0); + expect(utilization()).toHaveTextContent('CPU11.0%'); + expect(utilization()).not.toHaveTextContent('private backend detail'); + }); + + it('describes a lone stored observation without manufacturing a trend or pointless slider', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ cpu: [point(0, 11)] })); + render(() => ); + await waitFor(() => + expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription( + /1 stored observation time/, + ), + ); + expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription( + `1 stored observation time. ${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`, + ); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + expect(utilization().querySelector('path')).toBeNull(); + }); + + it.each(['empty', 'invalid'] as const)( + 'does not offer inspection for %s stored history or live readings', + async (kind) => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response(kind === 'empty' ? {} : { cpu: [point(0, NaN)] }), + ); + render(() => ); + await waitFor(() => expect(screen.queryByText('Loading history')).not.toBeInTheDocument()); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription( + 'No stored history observations.', + ); + }, + ); + + it('offers no inspection or reads for an absent target or locked range', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory'); + const [current, setTarget] = createSignal(null); + render(() => ); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + setTarget(target); + await screen.findByText(/14 days history requires/); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); From 7cbde2e3bf368e9ee36a34b4fc9dcf87d3089497 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:26:14 +0100 Subject: [PATCH 2/3] Keep observed unacknowledged alerts through retention cleanup Age-based housekeeping deleted continuing conditions when automatic acknowledgement was disabled. Require last-observation inactivity as well as occurrence age, retain legacy timestamp fallback and inactive cleanup, and document the unchanged acknowledgement/recovery boundaries. Regression exercises cached, absent and failed Docker registry reports, callback/history identity and actual checkpoint restart. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 20 +++ internal/alerts/active_cleanup.go | 6 +- internal/alerts/alerts_test.go | 50 ++++++ .../cleanup_observation_retention_test.go | 143 ++++++++++++++++++ internal/alerts/config/types.go | 2 +- internal/alerts/default_config.go | 2 +- 6 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 internal/alerts/cleanup_observation_retention_test.go diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index b9fecc921f..d240ffdb16 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -795,6 +795,26 @@ inspectability, or convert missing/stale evidence into health. ## Current State +### Continuing unacknowledged alerts survive age-based cleanup + +`MaxAlertAgeDays` removes an unacknowledged alert only when both its occurrence +start and last observation predate the configured limit. A still-observed +condition keeps its occurrence identity, age and dispatch timestamp; cleanup +must not hide it or manufacture another firing on the next poll. Legacy alerts +without `LastSeen` use `StartTime` for inactivity. Zero disables this retention +rule. Acknowledged-alert cleanup, automatic acknowledgement, explicit clears, +confirmed recovery and notification eligibility remain unchanged. + +`TestCleanupRetentionRequiresObservationInactivity` in `alerts_test.go` covers +continuing/inactive, legacy, disabled, acknowledged and inconsistent-timestamp +controls. `TestCleanupContinuingDockerUpdateKeepsOccurrence` in +`cleanup_observation_retention_test.go` exercises the real Docker update +detector with cached, absent and failed registry observations through four +cleanup/next-poll cycles, JSON and durable active-state restart, callback counts, +history and affirmative recovery. This is source-fixture lifecycle proof, not +an installed registry/destination result or a diagnosis of a reporter's daily +resolve/reopen cycle. + ### Confirmed empty storage is recovery evidence Static storage capacity evaluation must admit a zero usage observation when diff --git a/internal/alerts/active_cleanup.go b/internal/alerts/active_cleanup.go index 76cf8ef70c..ccf3040e4b 100644 --- a/internal/alerts/active_cleanup.go +++ b/internal/alerts/active_cleanup.go @@ -73,7 +73,11 @@ func (m *Manager) Cleanup(maxAge time.Duration) { if m.config.MaxAlertAgeDays > 0 { alertTTL := time.Duration(m.config.MaxAlertAgeDays) * 24 * time.Hour for id, alert := range m.activeAlerts { - if !alert.Acknowledged && now.Sub(alert.StartTime) > alertTTL { + // Retention is housekeeping for inactive alerts, not recovery + // evidence. As with acknowledged alerts, keep a condition still + // observed; deleting it would re-fire on the next failing poll. + if !alert.Acknowledged && now.Sub(alert.StartTime) > alertTTL && + lastSeenTooOld(alert, alertTTL) { log.Info(). Str("alertID", id). Dur("age", now.Sub(alert.StartTime)). diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index fb16bccb97..ccafc38ed5 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -21639,3 +21639,53 @@ func TestAsyncActiveCheckpointBurstIsBounded(t *testing.T) { t.Fatalf("restart lost latest pending intent: %+v", pending) } } + +func TestCleanupRetentionRequiresObservationInactivity(t *testing.T) { + now := time.Now() + old := now.Add(-48 * time.Hour) + recent := now.Add(-5 * time.Minute) + for _, tc := range []struct { + name string + start, seen time.Time + acknowledged bool + ttl int + keep bool + }{ + {name: "continuing unacknowledged condition", start: old, seen: recent, ttl: 1, keep: true}, + {name: "inactive unacknowledged condition", start: old, seen: old, ttl: 1}, + {name: "legacy inactive without last seen", start: old, ttl: 1}, + {name: "legacy recent without last seen", start: recent, ttl: 1, keep: true}, + {name: "recent occurrence with inconsistent old last seen", start: recent, seen: old, ttl: 1, keep: true}, + {name: "disabled unacknowledged retention", start: old, seen: old, keep: true}, + {name: "continuing acknowledged condition", start: old, seen: recent, acknowledged: true, ttl: 1, keep: true}, + {name: "inactive acknowledged condition", start: old, seen: old, acknowledged: true, ttl: 1}, + {name: "legacy without either timestamp", ttl: 1}, + {name: "future observation does not expire", start: old, seen: now.Add(time.Hour), ttl: 1, keep: true}, + } { + t.Run(tc.name, func(t *testing.T) { + m := newCleanupRetentionManager(t, t.TempDir(), false) + alert := &Alert{ + ID: "retention-resource::metric-threshold:usage", ResourceID: "retention-resource", + CanonicalSpecID: "metric-threshold:usage", Type: "usage", Level: AlertLevelWarning, + StartTime: tc.start, LastSeen: tc.seen, Acknowledged: tc.acknowledged, + } + if tc.acknowledged { + ackTime := old + alert.AckTime = &ackTime + alert.AckUser = "operator" + } + m.mu.Lock() + m.config.MaxAlertAgeDays = tc.ttl + m.setActiveAlertNoLock(alert.ID, alert) + m.mu.Unlock() + m.Cleanup(time.Hour) + active := m.GetActiveAlerts() + if kept := len(active) == 1; kept != tc.keep { + t.Fatalf("cleanup kept = %v, want %v (start %s, last seen %s)", kept, tc.keep, tc.start, tc.seen) + } + if tc.keep && (!active[0].StartTime.Equal(tc.start) || !active[0].LastSeen.Equal(tc.seen) || active[0].Acknowledged != tc.acknowledged) { + t.Fatalf("cleanup changed the retained occurrence: %+v", active[0]) + } + }) + } +} diff --git a/internal/alerts/cleanup_observation_retention_test.go b/internal/alerts/cleanup_observation_retention_test.go new file mode 100644 index 0000000000..96aed2b1e6 --- /dev/null +++ b/internal/alerts/cleanup_observation_retention_test.go @@ -0,0 +1,143 @@ +package alerts + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func newCleanupRetentionManager(t *testing.T, dir string, durable bool) *Manager { + t.Helper() + var options []ManagerOption + if durable { + options = append(options, WithDurableAlertStore()) + } + m := NewManagerWithDataDir(dir, options...) + t.Cleanup(m.Stop) + m.mu.Lock() + m.config.Enabled = true + m.config.ActivationState = ActivationActive + m.config.AutoAcknowledgeAfterHours = 0 + m.config.MaxAlertAgeDays = 1 + m.config.MaxAcknowledgedAgeDays = 1 + m.config.DockerDefaults.UpdateAlertDelayHours = 24 + m.config.Schedule.Cooldown = 0 // Only the first notification per occurrence. + m.config.FlappingEnabled = false + m.mu.Unlock() + return m +} + +// Exercise the actual detector, callbacks, history, checkpoint and restart, +// not just a hand-seeded active map. A cached registry observation is a current +// report; missing/failed checks must not be invented as recovery either. +func TestCleanupContinuingDockerUpdateKeepsOccurrence(t *testing.T) { + for _, durable := range []bool{false, true} { + name := "JSON recovery" + if durable { + name = "durable active authority" + } + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + m := newCleanupRetentionManager(t, dir, durable) + host := models.DockerHost{ID: "retention-host", Hostname: "retention-host"} + pending := &models.DockerContainerUpdateStatus{ + UpdateAvailable: true, CurrentDigest: "sha256:old", LatestDigest: "sha256:new", + LastChecked: time.Now().Add(-6 * time.Hour), + } + container := models.DockerContainer{ID: "retention-container", Name: "web", Image: "mongo:7", UpdateStatus: pending} + resourceID := DockerResourceID(host.ID, container.ID) + trackingKey := dockerUpdateTrackingKey(host, container) + first := time.Now().Add(-25 * time.Hour) + m.mu.Lock() + m.dockerUpdateFirstSeen[resourceID] = first + m.dockerUpdateFirstSeenByIdentity[trackingKey] = first + m.mu.Unlock() + var deliveries, fired, resolved atomic.Int32 + m.SetAlertCallback(func(*Alert) { deliveries.Add(1) }) + m.SubscribeLifecycleCallback(func(event LifecycleEvent) { + switch event.Type { + case eventlog.TypeFired, eventlog.TypeRefired: + fired.Add(1) + case eventlog.TypeResolved: + resolved.Add(1) + } + }) + evaluate := func() { + m.checkDockerContainerImageUpdate(host, container, resourceID, container.Name, host.Hostname, host.Hostname) + } + evaluate() + active := m.GetActiveAlerts() + if len(active) != 1 || deliveries.Load() != 1 || fired.Load() != 1 { + t.Fatalf("initial update did not fire once: active %d, callbacks %d, firings %d", len(active), deliveries.Load(), fired.Load()) + } + initial := active[0] + for _, status := range []*models.DockerContainerUpdateStatus{pending, nil, {Error: "registry unavailable"}, pending} { + container.UpdateStatus = status + evaluate() + m.Cleanup(time.Hour) + if got := len(m.GetActiveAlerts()); got != 1 { + t.Errorf("cleanup hid the continuing condition: %d active alerts", got) + } + container.UpdateStatus = pending + evaluate() // The next poll must not fire a replacement occurrence. + } + if got := deliveries.Load(); got != 1 { + t.Errorf("continuing update dispatched %d notifications, want one", got) + } + if got := fired.Load(); got != 1 || resolved.Load() != 0 { + t.Errorf("continuing lifecycle has %d firings / %d resolutions, want 1 / 0", got, resolved.Load()) + } + active = m.GetActiveAlerts() + if len(active) != 1 || active[0].ID != initial.ID || !active[0].StartTime.Equal(first) || active[0].Acknowledged || active[0].LastNotified == nil || !active[0].LastNotified.Equal(*initial.LastNotified) { + t.Fatalf("continuing occurrence/notification identity changed: %+v", active) + } + if got := len(m.GetAlertHistory(0)); got != 1 { + t.Errorf("continuing condition produced %d history rows, want one", got) + } + if durable { + events := queryAlertEvents(t, m, eventlog.Filter{Types: []string{eventlog.TypeFired, eventlog.TypeRefired, eventlog.TypeResolved}}) + if len(events) != 1 || events[0].Type != eventlog.TypeFired { + t.Errorf("durable lifecycle invented transitions during cleanup: %+v", events) + } + } + t.Logf("four cleanup/next-poll cycles: %d firing callbacks, %d dispatch callbacks", fired.Load(), deliveries.Load()) + lastSeen := active[0].LastSeen + m.Stop() // Includes the real final active-state checkpoint. + + restarted := newCleanupRetentionManager(t, dir, durable) + active = restarted.GetActiveAlerts() + if len(active) != 1 || active[0].ID != initial.ID || !active[0].StartTime.Equal(first) || !active[0].LastSeen.Equal(lastSeen) || active[0].Acknowledged || active[0].LastNotified == nil || !active[0].LastNotified.Equal(*initial.LastNotified) { + t.Fatalf("restart did not preserve the continuing unacknowledged occurrence: %+v", active) + } + var afterRestartDeliveries, afterRestartFirings, afterRestartResolutions atomic.Int32 + restarted.SetAlertCallback(func(*Alert) { afterRestartDeliveries.Add(1) }) + restarted.SubscribeLifecycleCallback(func(event LifecycleEvent) { + switch event.Type { + case eventlog.TypeFired, eventlog.TypeRefired: + afterRestartFirings.Add(1) + case eventlog.TypeResolved: + afterRestartResolutions.Add(1) + } + }) + container.UpdateStatus = nil + restarted.checkDockerContainerImageUpdate(host, container, resourceID, container.Name, host.Hostname, host.Hostname) + restarted.Cleanup(time.Hour) + if len(restarted.GetActiveAlerts()) != 1 || afterRestartFirings.Load() != 0 || afterRestartDeliveries.Load() != 0 { + t.Fatal("restart/unknown report lost or re-fired the occurrence") + } + container.UpdateStatus = &models.DockerContainerUpdateStatus{LastChecked: time.Now()} + restarted.checkDockerContainerImageUpdate(host, container, resourceID, container.Name, host.Hostname, host.Hostname) + if len(restarted.GetActiveAlerts()) != 0 || afterRestartResolutions.Load() != 1 || afterRestartDeliveries.Load() != 0 { + t.Fatal("affirmative clear did not resolve once without another firing notification") + } + history := restarted.GetAlertHistory(0) + if len(history) != 1 || history[0].OperationalRecord == nil || history[0].OperationalRecord.ResolvedAt == nil { + t.Fatalf("affirmative clear did not preserve one resolved history occurrence: %+v", history) + } + t.Log("persisted restart preserved identity, age, last observation and dispatch time; affirmative clear resolved once") + }) + } +} diff --git a/internal/alerts/config/types.go b/internal/alerts/config/types.go index 934fcf3a88..4eeac5933a 100644 --- a/internal/alerts/config/types.go +++ b/internal/alerts/config/types.go @@ -381,7 +381,7 @@ type AlertConfig struct { TimeThresholds map[string]int `json:"timeThresholds"` // Per-type delays: guest, node, agent, storage, pbs MetricTimeThresholds map[string]map[string]int `json:"metricTimeThresholds"` // Optional per-metric delays keyed by resource type MetricEvaluationWindows map[string]map[string]int `json:"metricEvaluationWindows"` // Rolling-average seconds keyed by resource type and metric; explicit zero uses the current value - MaxAlertAgeDays int `json:"maxAlertAgeDays"` // Maximum age for alerts before auto-cleanup (0 = disabled) + MaxAlertAgeDays int `json:"maxAlertAgeDays"` // Inactivity limit in days for unacknowledged alert cleanup (0 = disabled) MaxAcknowledgedAgeDays int `json:"maxAcknowledgedAgeDays"` // Maximum age for acknowledged alerts (0 = disabled) AutoAcknowledgeAfterHours int `json:"autoAcknowledgeAfterHours"` // Auto-acknowledge alerts after X hours (0 = disabled) FlappingEnabled bool `json:"flappingEnabled"` // Enable flapping detection diff --git a/internal/alerts/default_config.go b/internal/alerts/default_config.go index 68ad22a5de..74f522f5d7 100644 --- a/internal/alerts/default_config.go +++ b/internal/alerts/default_config.go @@ -194,7 +194,7 @@ func defaultAlertConfig() AlertConfig { }, }, // Alert TTL defaults - MaxAlertAgeDays: 7, // Auto-cleanup alerts older than 7 days + MaxAlertAgeDays: 7, // Cleanup unacknowledged alerts inactive for 7 days MaxAcknowledgedAgeDays: 1, // Auto-cleanup acknowledged alerts older than 1 day AutoAcknowledgeAfterHours: 24, // Auto-acknowledge alerts after 24 hours // Flapping detection defaults From 02090fe3515e324159225acab0130f4b82e444fb Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:38:17 +0100 Subject: [PATCH 3/3] Keep drawer History pointer values at one observed time Resolve pointer inspection against the group's actual stored timestamps. Missing series stay unavailable instead of borrowing neighbours or live readings, while keyboard focus, single samples and scoped refresh recovery remain intact. Add mounted regressions, the shared contract and exact-content browser receipts. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 20 ++ .../browser-tests/history-pointer.cjs | 272 ++++++++++++++++++ frontend-modern/browser-verification.json | 164 ++++++++--- .../Workloads/GuestDrawerHistory.tsx | 61 ++-- .../GuestDrawerHistory.pointer.test.tsx | 204 +++++++++++++ 5 files changed, 651 insertions(+), 70 deletions(-) create mode 100644 frontend-modern/browser-tests/history-pointer.cjs create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.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 644b9ffc1f..65006e0435 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7577,3 +7577,23 @@ Mounted inspection regressions cover these boundaries. The direct-renderer browser fixture verifies native keys, focus, pointer coexistence and touch at desktop/phone widths across Chromium, Firefox and WebKit; it is not installed PBS collection or a screen-reader announcement-quality claim. + +### Drawer History pointer values share one observed time + +Pointer inspection snaps to the nearest actual stored timestamp across the +group's series, with equidistant observations resolved to the earlier time. +Every displayed value and marker must belong to that timestamp. Missing series +remain unavailable at the inspected time, not a nearest neighbour, latest +observation or current reading. A lone stored sample, including zero, can be +inspected without fabricating a trend. The SVG's dated description follows the +same active time as the visible legend. Leaving the plot restores the normal +latest/current legend; focused native inspection still takes precedence. + +Matching refreshes reconcile the pointer with the current set of actual +observations; resource/range replacement still clears pointer state. Mounted +`GuestDrawerHistory.pointer.test.tsx` regressions cover sparse and disjoint +series, zero rates, single observations, ties, focus precedence and replacement. +`browser-tests/history-pointer.cjs` verifies the production renderer and query +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. diff --git a/frontend-modern/browser-tests/history-pointer.cjs b/frontend-modern/browser-tests/history-pointer.cjs new file mode 100644 index 0000000000..4880ba1500 --- /dev/null +++ b/frontend-modern/browser-tests/history-pointer.cjs @@ -0,0 +1,272 @@ +// Production History/query/CSS with synthetic API data, not installed PBS acceptance. +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 artifacts = path.join(root, 'node_modules', `history-pointer-${engine}-${width}`); + fs.mkdirSync(artifacts, { recursive: true }); + 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: 5223, strictPort: true }, + }); + let browser; + const observations = []; + const sourceFile = path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx'); + const sourceHash = createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex'); + const playwrightVersion = require('playwright/package.json').version; + const integrationVersion = JSON.parse( + fs.readFileSync('/workspace/tests/integration/package-lock.json'), + ).packages['node_modules/@playwright/test'].version; + assert.equal(playwrightVersion, integrationVersion); + 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 ['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(20_000); + const requests = [], + errors = []; + let kind = 'original', + held; + page.on('pageerror', (error) => errors.push(error.message)); + const time = 1_700_000_000_000; + const point = (offset, value) => ({ + timestamp: time + offset, + value, + min: value, + max: value, + }); + const metrics = (state) => { + if (state === 'empty') return {}; + if (state === 'service') return { cpu: [point(0, 77), point(120_000, 88)] }; + return { + cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)], + memory: [point(30_000, state === 'updated' ? 31 : 30), point(120_000, 55)], + disk: [point(45_000, 0)], + netin: [point(0, 0), point(120_000, 2048)], + netout: [point(30_000, 4096)], + diskread: [point(0, 2048), point(120_000, 8192)], + diskwrite: [point(0, 0), point(120_000, 0)], + temperature: [point(0, 42), point(120_000, 57)], + }; + }; + const response = (id, range, state) => ({ + resourceType: 'agent', + resourceId: id, + range, + start: time, + end: time + 120_000, + source: 'store', + metrics: metrics(state), + }); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5223') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const id = url.searchParams.get('resourceId'), + range = url.searchParams.get('range'); + assert.ok(['agent-three', 'pbs-three'].includes(id)); + const state = kind; + requests.push({ id, range, state, method: route.request().method() }); + if (state === 'hold') { + held = route; + return; + } + if (state === 'failure') + return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } }); + return route.fulfill({ json: response(id, range, state) }); + } + 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 } }); + }); + await page.goto('http://127.0.0.1:5223/browser-tests/pbs-history-refresh.html', { + waitUntil: 'domcontentloaded', + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + const fixture = page.getByTestId('history-refresh-fixture'); + const utilization = fixture.locator('[data-history-group="utilization"]'); + const slider = utilization.getByRole('slider', { name: 'Inspect Utilization history' }); + await slider.waitFor(); + const readsBefore = requests.length; + const plot = utilization.getByTestId('guest-history-plot'); + const hover = async (chart, offset) => { + await chart.scrollIntoViewIfNeeded(); + const box = await chart.getByTestId('guest-history-plot').boundingBox(); + assert.ok(box); + await page.mouse.move( + box.x + ((34 + (offset / 120_000) * 318) / 360) * box.width, + box.y + box.height / 2, + ); + }; + const text = async () => (await utilization.innerText()).replace(/\s+/g, ' '); + const sparseTime = async (offset, expected, absent) => { + await hover(utilization, offset); + assert.match(await text(), expected); + assert.match(await text(), absent); + const markers = await plot + .locator('circle[r="3"]') + .evaluateAll((nodes) => + nodes.map((node) => ({ x: node.getAttribute('cx'), y: node.getAttribute('cy') })), + ); + assert.equal(new Set(markers.map((marker) => marker.x)).size, 1); + return markers; + }; + // Capture the exact base misattribution too, before assertions fail. + await hover(utilization, 30_000); + const observed = { theme, sourceHash, text: await text(), browser: browser.version() }; + console.log(JSON.stringify({ observedSparsePointer: observed })); + // Element screenshots scroll a tall phone fixture and end its hover. + // Capture the current viewport without disturbing the active pointer. + await page.screenshot({ path: path.join(artifacts, `sparse-pointer-${theme}.png`) }); + assert.match(await text(), /CPU\s*- Memory\s*30\.0% Disk\s*-/); + assert.equal(await plot.locator('circle[r="3"]').count(), 1); + assert.equal(await utilization.getByTestId('guest-history-hover-time').innerText(), '22:13'); + const descriptionId = await plot.getAttribute('aria-describedby'); + assert.match( + await page.locator(`[id="${descriptionId}"]`).innerText(), + /14\/11\/2023, 22:13:50\. CPU no observation\. Memory 30\.0%\. Disk no observation\./, + ); + await sparseTime(0, /CPU\s*11\.0%/, /Memory\s*- Disk\s*-/); + await sparseTime(45_000, /Disk\s*0\.0%/, /CPU\s*- Memory\s*-/); + assert.equal( + await plot.locator('path').count(), + 2, + 'lone disk sample must not become a path', + ); + await hover(utilization, 120_000); + assert.match(await text(), /CPU\s*43\.0% Memory\s*55\.0% Disk\s*-/); + assert.equal(await plot.locator('circle[r="3"]').count(), 2); + assert.equal(await utilization.getByTestId('guest-history-hover-time').innerText(), '22:15'); + const network = fixture.locator('[data-history-group="network"]'); + await hover(network, 0); + assert.match((await network.innerText()).replace(/\s+/g, ' '), /In\s*0 B\/s Out\s*-/); + await hover(network, 30_000); + assert.match((await network.innerText()).replace(/\s+/g, ' '), /In\s*- Out\s*4\.00 KB\/s/); + await page.mouse.move(1, 1); + assert.match(await text(), /CPU\s*43\.0% Memory\s*55\.0% Disk\s*0\.0%/); + assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0); + assert.equal(requests.length, readsBefore, 'pointer inspection must not request data'); + // Native keyboard selection remains authoritative even with the pointer on another time. + await slider.focus(); + await slider.press('Home'); + await hover(utilization, 30_000); + assert.match(await text(), /CPU\s*11\.0% Memory\s*- Disk\s*-/); + await slider.press('ArrowRight'); + assert.match( + await slider.getAttribute('aria-valuetext'), + /CPU no observation\. Memory 30\.0%\. Disk no observation/, + ); + await slider.press('Tab'); + await hover(utilization, 45_000); + assert.match(await text(), /CPU\s*- Memory\s*- Disk\s*0\.0%/); + if (width === 390) { + const box = await slider.boundingBox(); + assert.ok(box.height >= 44); + await page.touchscreen.tap(box.x + 3, box.y + box.height / 2); + assert.match( + await slider.getAttribute('aria-valuetext'), + /CPU 11\.0%\. Memory no observation/, + ); + await page.touchscreen.tap(box.x + box.width - 3, box.y + box.height / 2); + assert.match(await slider.getAttribute('aria-valuetext'), /CPU 43\.0%\. Memory 55\.0%/); + await slider.press('Tab'); + } + await page.mouse.move(1, 1); + kind = 'failure'; + await fixture.getByRole('button', { name: 'Refresh history' }).evaluate((el) => el.click()); + await fixture + .getByText('History refresh failed. Showing previously loaded history.') + .waitFor(); + await sparseTime(30_000, /Memory\s*30\.0%/, /CPU\s*-/); + await page.screenshot({ + path: path.join(artifacts, `retained-sparse-pointer-${theme}.png`), + }); + assert.equal((await fixture.innerText()).includes('Private fixture detail'), false); + kind = 'updated'; + await fixture.getByRole('button', { name: 'Retry history' }).evaluate((el) => el.click()); + await page.waitForFunction(() => + [...document.querySelectorAll('[data-history-group="utilization"] span')].some( + (el) => el.textContent === '31.0%', + ), + ); + assert.match(await text(), /CPU\s*- Memory\s*31\.0% Disk\s*-/); + kind = 'hold'; + await page + .getByRole('button', { name: 'Switch to service target' }) + .evaluate((el) => el.click()); + await page.waitForFunction( + () => + [...document.querySelectorAll('[data-history-group="utilization"] path')].length === 0, + ); + assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0); + assert.equal(await plot.locator('circle[r="3"]').count(), 0); + assert.match(await text(), /CPU\s*42\.0% Memory\s*53\.0% Disk\s*-/); + assert.ok(held); + kind = 'service'; + await held.fulfill({ json: response('pbs-three', '24h', 'service') }); + await slider.waitFor(); + await hover(utilization, 0); + assert.match(await text(), /CPU\s*77\.0% Memory\s*- Disk\s*-/); + const dimensions = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + inner: innerWidth, + })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + assert.deepEqual(errors, []); + observations.push({ theme, requests, dimensions, sourceHash }); + await page.close(); + } + const result = { + engine, + width, + browserVersion: browser.version(), + playwrightVersion, + sourceHash, + observations, + result: 'passed', + }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result)); + } finally { + if (browser) 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 03385b3481..3454045d2f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,16 @@ { "version": 1, - "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", - "verified_at": "2026-09-30T23:28:26Z", + "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": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" }, "routes": [ - "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)" + "/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": [ { @@ -23,57 +23,133 @@ } ], "states": [ - "light/dark ordered and sparse stored series", - "native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface", - "missing series at a selected timestamp stay missing, not nearby or live values", - "same-source refresh adds samples without changing the selected timestamp or keyboard focus", - "failed refresh retains inspectable matching observations and honest warning", - "single stored observation has a dated description but no fabricated trend or pointless slider", - "empty store/current readings expose no inspection control", - "held replacement target has no former-host points or selection; new target starts at its own latest observation", - "licence-locked range has no reads or inspection action" + "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": [ - "Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads", - "refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp", - "move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works", - "Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values", - "fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d" + "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-inspection.cjs --engine=chromium; the same command 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-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-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log" + "/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 native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten.", + "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.", "prior_verifications": [ + { + "version": 1, + "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", + "verified_at": "2026-09-30T23:28:26Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" + }, + "routes": [ + "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "light/dark ordered and sparse stored series", + "native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface", + "missing series at a selected timestamp stay missing, not nearby or live values", + "same-source refresh adds samples without changing the selected timestamp or keyboard focus", + "failed refresh retains inspectable matching observations and honest warning", + "single stored observation has a dated description but no fabricated trend or pointless slider", + "empty store/current readings expose no inspection control", + "held replacement target has no former-host points or selection; new target starts at its own latest observation", + "licence-locked range has no reads or inspection action" + ], + "interactions": [ + "Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads", + "refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp", + "move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works", + "Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values", + "fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command 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-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log" + ], + "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten." + }, { "version": 1, "base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c", diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 2d6a711ebf..9e70e0dc82 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -187,18 +187,18 @@ const getGuestDrawerHistoryY = ( return GUEST_DRAWER_HISTORY_PLOT_TOP + (1 - (bounded - scale.minValue) / valueSpan) * plotHeight; }; -const findClosestGuestDrawerHistoryPoint = ( - points: readonly AggregatedMetricPoint[], +const findClosestGuestDrawerHistoryTimestamp = ( + timestamps: readonly number[], timestamp: number, -): AggregatedMetricPoint | null => { - if (points.length === 0) return null; +): number | null => { + if (timestamps.length === 0) return null; - let closest = points[0]; - let closestDistance = Math.abs(points[0].timestamp - timestamp); - for (const point of points.slice(1)) { - const distance = Math.abs(point.timestamp - timestamp); + let closest = timestamps[0]; + let closestDistance = Math.abs(closest - timestamp); + for (const candidate of timestamps.slice(1)) { + const distance = Math.abs(candidate - timestamp); if (distance < closestDistance) { - closest = point; + closest = candidate; closestDistance = distance; } } @@ -235,26 +235,38 @@ const GuestDrawerHistoryGroupChart: Component const times = observationTimes(); const selected = selectedTimestamp(); if (selected === null) return Math.max(0, times.length - 1); - let index = 0; - for (let i = 1; i < times.length; i++) { - if (Math.abs(times[i] - selected) < Math.abs(times[index] - selected)) index = i; - } - return index; + const closest = findClosestGuestDrawerHistoryTimestamp(times, selected); + return closest === null ? 0 : times.indexOf(closest); }); const inspectionTimestamp = createMemo(() => selectedTimestamp() === null ? null : (observationTimes()[selectedIndex()] ?? null), ); - const inspectionValueText = createMemo(() => { - const timestamp = observationTimes()[selectedIndex()]; + // Resolve the pointer to one real time across the whole group. Independent + // nearest-series reads would attribute values from different times to the + // first metric's timestamp, and can even borrow a live fallback reading. + const activeTimestamp = createMemo(() => { + const inspected = inspectionTimestamp(); + if (inspected !== null) return inspected; + const hovered = hoverTimestamp(); + return hovered === null + ? null + : findClosestGuestDrawerHistoryTimestamp(observationTimes(), hovered); + }); + const observationValueText = (timestamp: number | undefined): string => { if (timestamp === undefined) return 'No stored history observations.'; const values = series().map((item) => { const point = item.points.find((point) => point.timestamp === timestamp); return `${item.label} ${point ? formatHistoryChartTooltipValue(point.value, item.unit) : 'no observation'}`; }); return `${new Date(timestamp).toLocaleString()}. ${values.join('. ')}.`; - }); + }; + const inspectionValueText = createMemo(() => + observationValueText(observationTimes()[selectedIndex()]), + ); const chartDescription = createMemo(() => { if (props.loading) return 'Loading history.'; + const timestamp = activeTimestamp(); + if (timestamp !== null) return observationValueText(timestamp); const count = observationTimes().length; if (count === 0) return inspectionValueText(); return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`; @@ -264,16 +276,13 @@ const GuestDrawerHistoryGroupChart: Component const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series())); const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null); const hoveredSeries = createMemo(() => { - const timestamp = inspectionTimestamp() ?? hoverTimestamp(); + const timestamp = activeTimestamp(); const rangeBounds = bounds(); if (timestamp === null || !rangeBounds) return []; - return (inspectionTimestamp() === null ? drawableSeries() : series()) + return series() .map((item) => { - const point = - inspectionTimestamp() === null - ? findClosestGuestDrawerHistoryPoint(item.points, timestamp) - : item.points.find((point) => point.timestamp === timestamp); + const point = item.points.find((point) => point.timestamp === timestamp); if (!point) return null; return { ...item, @@ -300,7 +309,7 @@ const GuestDrawerHistoryGroupChart: Component ...item, valueLabel: hovered ? getGuestDrawerHistoryValueLabel([hovered.point], item.unit) - : inspectionTimestamp() !== null + : activeTimestamp() !== null ? '-' : item.points.length > 0 ? getGuestDrawerHistoryValueLabel(item.points, item.unit) @@ -311,8 +320,8 @@ const GuestDrawerHistoryGroupChart: Component }), ); const hoverTimeLabel = createMemo(() => { - const point = hoveredSeries()[0]?.point; - return point ? formatHistoryChartTimeLabel(point.timestamp, props.range) : ''; + const timestamp = activeTimestamp(); + return timestamp === null ? '' : formatHistoryChartTimeLabel(timestamp, props.range); }); const handleHoverMove = (event: MouseEvent & { currentTarget: SVGSVGElement }) => { diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx new file mode 100644 index 0000000000..ef5cc1c7e9 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx @@ -0,0 +1,204 @@ +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 { formatHistoryChartTimeLabel } from '@/components/shared/historyChartModel'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { GuestDrawerHistory } from '../GuestDrawerHistory'; +import type { GuestDrawerHistoryTarget } 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 time = 1_700_000_000_000; +const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' }; +const point = (offset: number, value: number) => ({ + timestamp: time + offset, + value, + min: value, + max: value, +}); +const response = ( + metrics: AllMetricsHistoryResponse['metrics'] = { + cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)], + memory: [point(30_000, 30), point(120_000, 55)], + disk: [point(45_000, 0)], + }, +): AllMetricsHistoryResponse => ({ + ...target, + range: '24h', + start: time, + end: time + 120_000, + source: 'store', + metrics, +}); +const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0]; +const hover = (chart: HTMLElement, offset: number, duration = 120_000) => { + const plot = within(chart).getByTestId('guest-history-plot'); + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 360 } as DOMRect); + fireEvent.mouseMove(plot, { clientX: 34 + (offset / duration) * 318 }); + return plot; +}; + +describe('GuestDrawerHistory common-time pointer inspection', () => { + it('chooses the nearest actual observation across all series, not the first metric', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + hover(utilization(), 30_000); + expect(within(utilization()).getByTestId('guest-history-hover-time')).toHaveTextContent( + formatHistoryChartTimeLabel(time + 30_000, '24h'), + ); + expect(utilization()).toHaveTextContent('CPU-'); + expect(utilization()).toHaveTextContent('Memory30.0%'); + expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not borrow neighbouring or current readings for a missing metric at the chosen time', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ( + + )); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const plot = hover(utilization(), 0); + expect(utilization()).toHaveTextContent('CPU11.0%'); + expect(utilization()).toHaveTextContent('Memory-'); + expect(utilization()).toHaveTextContent('Disk-'); + expect(utilization()).not.toHaveTextContent('98.0%'); + expect( + within(utilization()).getByRole('img', { name: 'Utilization history' }), + ).toHaveAccessibleDescription( + `${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`, + ); + fireEvent.pointerLeave(plot); + expect(utilization()).toHaveTextContent('CPU43.0%'); + expect(utilization()).toHaveTextContent('Memory55.0%'); + expect(within(utilization()).queryByTestId('guest-history-hover-time')).toBeNull(); + }); + + it('can inspect a lone zero observation without drawing a synthetic trend', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + hover(utilization(), 45_000); + expect(utilization()).toHaveTextContent('CPU-'); + expect(utilization()).toHaveTextContent('Memory-'); + expect(utilization()).toHaveTextContent('Disk0.0%'); + expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1); + expect(utilization().querySelectorAll('path')).toHaveLength(2); + }); + + it('renders all metrics that really share a timestamp at the same cursor position', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + hover(utilization(), 120_000); + expect(utilization()).toHaveTextContent('CPU43.0%'); + expect(utilization()).toHaveTextContent('Memory55.0%'); + expect(utilization()).toHaveTextContent('Disk-'); + const markers = [...utilization().querySelectorAll('circle[r="3"]')]; + expect(markers).toHaveLength(2); + expect(markers[0].getAttribute('cx')).toBe(markers[1].getAttribute('cx')); + }); + + it('snaps between observations and resolves a tie to the earlier actual time', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + hover(utilization(), 37_500); + expect(utilization()).toHaveTextContent('CPU-'); + expect(utilization()).toHaveTextContent('Memory30.0%'); + expect(utilization()).toHaveTextContent('Disk-'); + }); + + it('keeps keyboard selection authoritative and restores pointer inspection after blur', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ); + const slider = await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + slider.focus(); + fireEvent.input(slider, { target: { value: '0' } }); + hover(utilization(), 30_000); + expect(utilization()).toHaveTextContent('CPU11.0%'); + expect(utilization()).toHaveTextContent('Memory-'); + fireEvent.blur(slider); + hover(utilization(), 30_000); + expect(utilization()).toHaveTextContent('CPU-'); + expect(utilization()).toHaveTextContent('Memory30.0%'); + }); + + it('reconciles a hovered time with a refreshed series instead of substituting every latest metric', async () => { + vi.useFakeTimers(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(response({ memory: [point(30_000, 31)], cpu: [point(120_000, 88)] })); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + hover(utilization(), 30_000); + await vi.advanceTimersByTimeAsync(30_000); + expect(utilization()).toHaveTextContent('CPU-'); + expect(utilization()).toHaveTextContent('Memory31.0%'); + expect(utilization().querySelectorAll('path')).toHaveLength(0); + expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1); + }); + + it.each(['target', 'range'] as const)( + 'clears pointer state on %s replacement', + async (change) => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockImplementation(() => new Promise(() => {})); + const [current, setTarget] = createSignal(target); + const [range, setRange] = createSignal('24h'); + render(() => ); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + hover(utilization(), 30_000); + if (change === 'target') setTarget({ ...target, resourceId: 'replacement' }); + else setRange('6h'); + await waitFor(() => + expect(within(utilization()).queryByTestId('guest-history-hover-time')).toBeNull(), + ); + expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(0); + }, + ); + + it('inspects real zero rates and temperatures while leaving missing sibling directions unavailable', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + netin: [point(0, 0), point(120_000, 2048)], + netout: [point(30_000, 4096)], + temperature: [point(0, 42), point(120_000, 57)], + }), + ); + render(() => ( + + )); + await screen.findByRole('slider', { name: 'Inspect Network I/O history' }); + const groups = screen.getAllByTestId('guest-history-group-chart'); + const network = groups.find((group) => group.dataset.historyGroup === 'network')!; + hover(network, 0); + expect(network).toHaveTextContent('In0 B/s'); + expect(network).toHaveTextContent('Out-'); + hover(network, 30_000); + expect(network).toHaveTextContent('In-'); + expect(network).toHaveTextContent('Out4.00 KB/s'); + const thermals = groups.find((group) => group.dataset.historyGroup === 'thermals')!; + hover(thermals, 0); + expect(thermals).toHaveTextContent('CPU42°C'); + }); +});