diff --git a/frontend-modern/browser-tests/thresholds-windowed-list.html b/frontend-modern/browser-tests/thresholds-windowed-list.html new file mode 100644 index 0000000000..0995dc4dba --- /dev/null +++ b/frontend-modern/browser-tests/thresholds-windowed-list.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/frontend-modern/browser-tests/thresholds-windowed-list.tsx b/frontend-modern/browser-tests/thresholds-windowed-list.tsx new file mode 100644 index 0000000000..5927f00b2d --- /dev/null +++ b/frontend-modern/browser-tests/thresholds-windowed-list.tsx @@ -0,0 +1,94 @@ +// Browser fixture: the production Alert Thresholds card list (the narrow-layout +// renderer) with two Proxmox hosts whose guests share display names. It exists +// to reproduce #2130: a short leading group header corrupting the virtual +// window's item-height estimate so a host's rows vanish while scrolling. +// Synthetic props only; no backend, API or WebSocket path. +import { render } from 'solid-js/web'; + +import { AlertResourceTableMobile } from '../src/components/Alerts/AlertResourceTableMobile'; +import type { ResourceTableProps } from '../src/components/Alerts/ResourceTable'; +import type { Resource } from '../src/features/alerts/thresholds/tableTypes'; +import '../src/index.css'; + +const HOSTS = ['pve01.staging.tld', 'pve01.production.tld']; +const COLUMNS = [ + 'CPU %', + 'Memory %', + 'Disk %', + 'Backup', + 'Snapshot', + 'Disk R MB/s', + 'Disk W MB/s', + 'Net In MB/s', + 'Net Out MB/s', +]; + +const grouped: Record = {}; +for (const [hostIndex, host] of HOSTS.entries()) { + grouped[host] = Array.from({ length: 15 }, (_, index) => { + // Both environments expose a guest named gateway01, exactly as reported. + const name = + index === 0 + ? 'gateway01' + : `${hostIndex === 0 ? 'staging' : 'production'}-vm-${String(index).padStart(2, '0')}`; + return { + id: `${host}-${index}`, + name, + displayName: name, + rawName: name, + type: 'guest', + resourceType: index % 3 === 0 ? 'VM' : 'Container', + vmid: 100 + index, + node: host, + instance: host, + status: 'running', + hasOverride: false, + disabled: false, + disableConnectivity: false, + thresholds: {}, + defaults: { cpu: 80, memory: 85, disk: 90 }, + backup: { enabled: true }, + snapshot: { enabled: true }, + } satisfies Resource; + }); +} + +const noop = () => undefined; +const table: ResourceTableProps = { + title: '', + columns: COLUMNS, + groupedResources: grouped, + onEdit: noop, + onSaveEdit: noop, + onCancelEdit: noop, + onRemoveOverride: noop, + editingId: () => null, + editingThresholds: () => ({}), + setEditingThresholds: noop, + formatMetricValue: (_metric, value) => (typeof value === 'number' ? `${value}%` : '—'), + hasActiveAlert: () => false, + editingNote: () => '', + setEditingNote: noop, +}; + +render( + () => ( +
+ true} + hasCustomGlobalDefaults={() => false} + setActiveMetricInput={noop} + /> +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 7f7f476d26..a38e82ee6e 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,37 +1,38 @@ { "version": 1, - "base_sha": "22fd851e72ec22d4ff10e20ae22d4a44ff02b03a", - "verified_at": "2026-09-19T20:31:21Z", + "base_sha": "1c8fee0119772d57ace10cbdfd5a2746a45af202", + "verified_at": "2026-09-20T22:48:14Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx" + "frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "bcfebc202c33466d321297bb32ad75a923cc35a7d76c0c376a65a5c172d68955" + "frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "e1cc0cb0f4087c5c66014d254421aeea6b02842795c41978e1b9847dcfcb5c77" }, "routes": [ - "/browser-tests/guest-drawer-overview.html" + "/browser-tests/thresholds-windowed-list.html" ], "viewports": [ { - "width": 2010, - "height": 1072 + "width": 1280, + "height": 900 }, { - "width": 390, - "height": 844 + "width": 480, + "height": 800 } ], "states": [ - "Guest overview with a VM carrying three filesystem mount points, three tags, memory, and no network-interface section", - "Filesystems section present with per-mount usage text and threshold progress bars", - "Tags section present with the short tag value list" + "Alert Thresholds card list with two Proxmox hosts and 30 guests, both hosts exposing a guest named gateway01", + "The virtual window mounts 24 of 32 items with the leading item a 36px group header and each guest card 315px tall", + "After a 70px scroll the first group header is still within the mounted window; the final guest is reachable at the bottom" ], "interactions": [ - "Render the production GuestDrawerOverview component with synthetic props at 2010x1072 and measure every section panel bounding width", - "Confirm Filesystems renders 1305.33px wide and Tags renders 648.66px wide, i.e. the filesystem list takes the two-tile column and the tag list the one-tile column", - "Resize to 390x844 and confirm every section stacks full width (340px) with document scrollWidth 390 equal to clientWidth 390" + "Mount the production AlertResourceTableMobile renderer with synthetic grouped resources in a 600px native scroll container", + "Confirm the bottom spacer is 2520px, i.e. eight unmounted rows at the measured 315px card height rather than the 36px group-header height (which previously produced 288px)", + "Scroll the container 70px and confirm the top spacer stays 0, so the window does not jump past the first group", + "Scroll to the bottom and confirm the last guest renders; inspect desktop and narrow screenshots" ], - "command": "pulse-worker-browser guest-drawer-visual.cjs", - "notes": "Offline Playwright Chromium 141.0.7390.37 run of the production GuestDrawerOverview component with a synthetic guest and the project CSS; no live backend, API or WebSocket path. Desktop 2010x1072 panel widths: Operator context 648.66, System 648.66, Memory 648.66, Filesystems 1305.33, Tags 648.66 (Filesystems spans the 66.667% column, Tags the 33.333% column). Narrow 390x844: all five panels 340px, no horizontal overflow. No page errors and no failed responses. Desktop and narrow screenshots inspected. Build toolchain: vite 6.4.3, vite-plugin-solid 2.11.12, solid-js 1.9.14, tailwindcss 3.4.19, esbuild 0.28.1, matching frontend-modern/package-lock.json; the dependency tree was copied from a readable offline checkout because the lane's canonical tree under /srv/pulse is ACL-restricted from this identity, and the render-relevant versions match exactly. This receipt is content-addressed to GuestDrawerOverview.tsx sha256 bcfebc20…, verified at 20:31Z; the commit carrying it was recomposed this turn onto a later base so the per-commit browser guard accepts the source change and its proof in one commit." + "command": "pulse-worker-browser scripts/check-thresholds-windowed-list.cjs", + "notes": "Offline Playwright Chromium run of the production Alert Thresholds card list (the narrow-layout renderer that virtualises via PlatformWindowedList) with synthetic props and the project CSS; no backend, API or WebSocket path. Before the fix the same run failed with bottom spacer 288px (8 x the 36px leading group header), so the item-height estimate collapsed to the header and the mounted window desynchronised from the scroll position, dropping a host's rows (#2130). After the fix the estimate follows the 315px card, the window holds through a 70px scroll, and the last guest is reachable. No page errors and no failed responses. Chromium 141.0.7390.37, matching the offline runtime. This receipt is content-addressed to usePlatformWindowedItems.ts sha256 e1cc0cb0…, verified at 22:48Z on base 1c8fee01…." } diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx index 7c466a95ae..70b8e5cfa2 100644 --- a/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx +++ b/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx @@ -128,4 +128,64 @@ describe('PlatformWindowedRows', () => { expect(container.querySelectorAll('[data-card-id]')).toHaveLength(32); expect(container.querySelectorAll('[data-platform-window-spacer]')).toHaveLength(2); }); + + it('keeps the caller estimate when the leading row is a short group header', () => { + // Regression: a grouped card list renders a short group header first. The + // viewport measurement used to sample only that leading sibling, collapse + // the estimate to the header height and desynchronise the window from the + // real scroll position, so a host's rows vanished while scrolling (#2130). + const items = () => [ + { kind: 'group' as const }, + ...Array.from({ length: 40 }, (_, index) => ({ kind: 'resource' as const, index })), + ]; + const originalRect = Element.prototype.getBoundingClientRect; + const originalVisibility = (Element.prototype as Element & { checkVisibility?: () => boolean }) + .checkVisibility; + Element.prototype.getBoundingClientRect = function (this: Element) { + const raw = (this as HTMLElement).getAttribute?.('data-height'); + const height = raw ? Number(raw) : 0; + return { + height, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + }; + (Element.prototype as Element & { checkVisibility?: () => boolean }).checkVisibility = () => + true; + + try { + const { container } = render(() => ( + + {(item) => ( +
+ )} + + )); + + const bottomSpacer = container.querySelector( + '[data-platform-window-spacer="bottom"]', + ) as HTMLElement; + // 41 items, 8 mounted: 33 unmounted rows at the caller's 200px estimate. + expect(bottomSpacer.style.height).toBe('6600px'); + } finally { + Element.prototype.getBoundingClientRect = originalRect; + if (originalVisibility) { + (Element.prototype as Element & { checkVisibility?: () => boolean }).checkVisibility = + originalVisibility; + } else { + Reflect.deleteProperty(Element.prototype, 'checkVisibility'); + } + } + }); }); diff --git a/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts b/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts index 9905953510..673794ef0a 100644 --- a/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts +++ b/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts @@ -18,6 +18,11 @@ import { const DEFAULT_ESTIMATED_ITEM_HEIGHT = 40; const DESKTOP_WINDOW_SIZE = 140; const PHONE_WINDOW_SIZE = 36; +// Grouped surfaces render a short group header before their first content row. +// Sampling a few siblings and keeping the tallest keeps that header from +// collapsing the estimate and desynchronising the window from the scroll +// position, while uniform tables still measure their real row height. +const MEASUREMENT_SAMPLE_SIZE = 3; export interface PlatformWindowedItemsOptions { items: Accessor; estimatedItemHeight?: number; @@ -68,8 +73,14 @@ export function usePlatformWindowedItems(options: PlatformWindowedItemsOpt if (!anchor || isWindowedSurfaceHidden(anchor)) return; if (measureItems) { - const measuredHeight = anchor.nextElementSibling?.getBoundingClientRect().height; - if (measuredHeight && measuredHeight > 0) setEstimatedItemHeight(measuredHeight); + let measuredHeight = 0; + let sibling = anchor.nextElementSibling; + for (let sampled = 0; sampled < MEASUREMENT_SAMPLE_SIZE && sibling; sampled += 1) { + const height = sibling.getBoundingClientRect().height; + if (height > measuredHeight) measuredHeight = height; + sibling = sibling.nextElementSibling; + } + if (measuredHeight > 0) setEstimatedItemHeight(measuredHeight); } const segmentRect = anchor.getBoundingClientRect(); diff --git a/scripts/check-thresholds-windowed-list.cjs b/scripts/check-thresholds-windowed-list.cjs new file mode 100644 index 0000000000..26775c20bc --- /dev/null +++ b/scripts/check-thresholds-windowed-list.cjs @@ -0,0 +1,131 @@ +// Offline real-browser regression for #2130. +// +// Mounts the production Alert Thresholds card list (two Proxmox hosts, 30 +// guests, the leading item being a short group header) in a fixed-height +// native scroll container. It records the virtual window's spacer heights +// relative to a real card height, then scrolls a little and checks the window +// did not over-advance. Run with: +// pulse-worker-browser scripts/check-thresholds-windowed-list.cjs +const path = require('node:path'); +const { chromium } = require('playwright'); + +// pulse-worker-browser copies this file to /request.cjs and runs it with the +// assigned workspace mounted at /workspace, so resolve from the process cwd. +const ROOT = path.resolve(process.cwd(), 'frontend-modern'); + +const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbox'] }; + +const readWindow = () => + (() => { + const anchor = document.querySelector('[data-platform-window-spacer="top"]'); + if (!anchor) return null; + const header = anchor.nextElementSibling; + const firstCard = header ? header.nextElementSibling : null; + const bottom = document.querySelector('[data-platform-window-spacer="bottom"]'); + return { + topSpacer: parseFloat(anchor.style.height || '0'), + bottomSpacer: bottom ? parseFloat(bottom.style.height || '0') : -1, + headerHeight: header ? header.getBoundingClientRect().height : -1, + cardHeight: firstCard ? firstCard.getBoundingClientRect().height : -1, + scrollHeight: document.getElementById('thresholds-scroll').scrollHeight, + scrollTop: document.getElementById('thresholds-scroll').scrollTop, + }; + })(); + +(async () => { + process.chdir(ROOT); + const { createServer } = await import( + path.join(ROOT, 'node_modules', 'vite', 'dist', 'node', 'index.js') + ); + const server = await createServer({ + root: ROOT, + configFile: path.join(ROOT, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5199, strictPort: true }, + }); + let browser; + const errors = []; + const httpErrors = []; + try { + await server.listen(); + browser = await chromium.launch(launchOptions); + const page = await browser.newPage({ viewport: { width: 480, height: 800 } }); + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() !== 'error') return; + // A missing static asset is recorded through the response listener; the + // generic browser console line carries no URL and is not a render fault. + if (message.text().includes('Failed to load resource')) return; + errors.push(`console: ${message.text()}`); + }); + page.on('response', (response) => { + if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`); + }); + + await page.goto('http://127.0.0.1:5199/browser-tests/thresholds-windowed-list.html'); + await page.getByText('gateway01', { exact: true }).first().waitFor({ timeout: 20000 }); + await page.waitForSelector('[data-platform-window-spacer="bottom"]'); + + const initial = await page.evaluate(readWindow); + if (!initial) throw new Error('window spacers not rendered'); + if (!(initial.cardHeight > 80)) { + throw new Error(`card height not measured (${initial.cardHeight})`); + } + + // The estimate must track the real card, not the short group header. + const expectedBottom = 8 * initial.cardHeight; + if (!(initial.bottomSpacer > expectedBottom * 0.6)) { + throw new Error( + `bottom spacer ${initial.bottomSpacer} collapsed below card-scaled ${expectedBottom} ` + + `(header ${initial.headerHeight}, card ${initial.cardHeight})`, + ); + } + + // Scrolling 70px must not advance the window past the first group header. + await page.evaluate(() => { + document.getElementById('thresholds-scroll').scrollTop = 70; + }); + await page.waitForTimeout(250); + const afterScroll = await page.evaluate(readWindow); + if (!(afterScroll.topSpacer < 1)) { + throw new Error( + `window over-advanced after a 70px scroll: top spacer ${afterScroll.topSpacer}px`, + ); + } + + // The last guest must be reachable at the bottom of the list. + await page.evaluate(() => { + const el = document.getElementById('thresholds-scroll'); + el.scrollTop = el.scrollHeight; + }); + await page.waitForTimeout(250); + await page.getByText('production-vm-14', { exact: true }).first().waitFor({ timeout: 5000 }); + + await page.screenshot({ path: path.join(ROOT, 'browser-tests', 'thresholds-narrow.png') }); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.waitForTimeout(150); + await page.screenshot({ path: path.join(ROOT, 'browser-tests', 'thresholds-wide.png') }); + + if (errors.length > 0) throw new Error(`page errors: ${errors.join(' | ')}`); + + console.log( + JSON.stringify( + { + result: 'passed', + initial, + afterScroll, + expectedBottom, + scrollHeight: afterScroll.scrollHeight, + httpErrors, + }, + null, + 2, + ), + ); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error('FAILED:', error && error.stack ? error.stack : error); + process.exitCode = 1; +});