fix(alerts): keep grouped-list window estimate off the header (#2130)

The virtual window measured only the top spacer's first sibling. On grouped
surfaces that sibling is a short group header, so the estimate collapsed to
the header height and the mounted window advanced roughly one row per header
height of scroll. The Alert Thresholds card list then dropped a host's rows
while scrolling and its Ctrl-F match count oscillated.

Measure the tallest of the first few rendered siblings so a leading header
cannot collapse the estimate, while uniform tables still measure their real
row height. Add a regression test and offline browser proof.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-20 23:50:19 +01:00
parent 1c8fee0119
commit 6f0dc177c8
6 changed files with 326 additions and 19 deletions
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/thresholds-windowed-list.tsx"></script>
</body>
</html>
@@ -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<string, Resource[]> = {};
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(
() => (
<div
id="thresholds-scroll"
style={{
height: '600px',
overflow: 'auto',
border: '1px solid #cbd5e1',
padding: '12px',
}}
>
<AlertResourceTableMobile
table={table}
hasRows={() => true}
hasCustomGlobalDefaults={() => false}
setActiveMetricInput={noop}
/>
</div>
),
document.getElementById('root')!,
);
+18 -17
View File
@@ -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…."
}
@@ -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(() => (
<PlatformWindowedList
items={items}
estimatedItemHeight={200}
enableThreshold={4}
windowSize={8}
>
{(item) => (
<article data-item-kind={item.kind} data-height={item.kind === 'group' ? 30 : 200} />
)}
</PlatformWindowedList>
));
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');
}
}
});
});
@@ -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<Item> {
items: Accessor<readonly Item[]>;
estimatedItemHeight?: number;
@@ -68,8 +73,14 @@ export function usePlatformWindowedItems<Item>(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();
+131
View File
@@ -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;
});