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(
+ () => (
+
+ ),
+ 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;
+});