diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index db477cc97..1193511ad 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -2549,6 +2549,12 @@ default` instead of fusing provider and badge text such as
## Completion Obligations
+Coverage-table polling must preserve the DOM identity of an unchanged logical
+row, including its focused expansion control, rather than keying rendering by
+replacement snapshot object identity. The isolated Chromium polling check in
+`scripts/check-backup-browser-polling.mjs` exercises this boundary with the
+production table, router and styles; it does not qualify full-app scrolling.
+
1. Update guardrail tests when new shared primitives are added, including
new Settings controls that drive backend verification surfaces (for
example the Verify Patrol button in
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 16e4fbbff..c4432a71c 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -2094,6 +2094,12 @@ take a correctness dependency on its contents.
## Completion Obligations
+Coverage rows reconcile replacement recovery-model snapshots by their logical
+`key` before windowing. Refreshed names must render without discarding expanded
+restore evidence or keyboard focus on a surviving row. Verify with
+`scripts/check-backup-browser-polling.mjs`; full provider/WebSocket polling and
+the reported backup-page scroll jump remain outside that isolated fixture.
+
Agent-backed lifecycle recovery must namespace every in-memory pending request,
typed result, operation query, and deployment progress subscription by the
admitted organization session. That transport namespace must not alter the
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index dbe212996..a80ad64d1 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -1571,6 +1571,12 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and
## Completion Obligations
+Replacement workload snapshots must update backup coverage content without
+remounting surviving logical coverage rows. Preserve the focused expansion
+control and expanded evidence across those updates. The isolated browser
+polling regression verifies replacement HTTP fixture snapshots, not the full
+application resource-provider or WebSocket lifecycle.
+
1. Update this contract when canonical resource identity or type rules change
2. Update contract and guardrail tests when a new resource type is added
3. Route runtime changes through the explicit unified-resource proof policies in `registry.json`; default fallback proof routing is not allowed
diff --git a/frontend-modern/browser-tests/README.md b/frontend-modern/browser-tests/README.md
new file mode 100644
index 000000000..17e8e503b
--- /dev/null
+++ b/frontend-modern/browser-tests/README.md
@@ -0,0 +1,23 @@
+# Backup polling browser regression
+
+From the repository root, after `npm ci` at the root and in `frontend-modern`:
+
+```sh
+pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs
+pulse-heavy-run -- env PULSE_BROWSER_WIDTH=390 node scripts/check-backup-browser-polling.mjs
+```
+
+Requires Playwright Chromium (`npx playwright install chromium`). The runner
+starts and closes a loopback-only Vite server on port 5198 and a headless browser;
+it neither needs nor contacts a live Pulse deployment. API responses are synthetic.
+
+The fixture mounts the production backup table, router and CSS in a native
+scroll container, with 40 workloads replaced by HTTP polling once per second.
+It expands a mid-table workload and focuses its toggle, then verifies three
+changed snapshot names render while focus, restore evidence, scroll offset and
+the coverage route survive. Non-zero initial scroll prevents a vacuous pass.
+
+This covers #1869's refresh stability at the coverage-table boundary. It does
+**not** qualify the full application resource provider/WebSocket path, PBS drawer,
+By date view, virtualised large tables, Settings editing, or Brave. A passing
+result is not grounds to close #1869 or claim the reported top-of-page jump fixed.
diff --git a/frontend-modern/browser-tests/backups.html b/frontend-modern/browser-tests/backups.html
new file mode 100644
index 000000000..f22750f1c
--- /dev/null
+++ b/frontend-modern/browser-tests/backups.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/backups.tsx b/frontend-modern/browser-tests/backups.tsx
new file mode 100644
index 000000000..d2835d51b
--- /dev/null
+++ b/frontend-modern/browser-tests/backups.tsx
@@ -0,0 +1,31 @@
+// Browser fixture: real backup component and router; synthetic HTTP polling.
+// Deliberately not a substitute for the full application/WebSocket path.
+import { render } from 'solid-js/web';
+import { createSignal, onMount, onCleanup } from 'solid-js';
+import { Router, Route } from '@solidjs/router';
+import { ProxmoxBackupsTable } from '../src/features/proxmox/ProxmoxBackupsTable';
+import type { Resource } from '../src/types/resource';
+import '../src/index.css';
+
+function Fixture() {
+ const [workloads, setWorkloads] = createSignal([]);
+ onMount(() => {
+ const poll = async () => setWorkloads(await (await fetch('/fixture/workloads')).json());
+ void poll();
+ const timer = setInterval(() => void poll(), 1000);
+ onCleanup(() => clearInterval(timer));
+ });
+ return (
+
+ } workloads={workloads()} />
+
+ );
+}
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 2144f59ee..2497ac4b0 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,41 +1,35 @@
{
"version": 1,
- "base_sha": "485e19600f8bb8b9126fd4f2ab7e9fa2f525d808",
- "verified_at": "2026-09-04T14:39:20Z",
+ "base_sha": "3c5d4265439d92495f35f2460c3f666330a239e5",
+ "verified_at": "2026-09-05T00:33:47Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx"
+ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx"
],
"content_sha256": {
- "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx": "24ea681203713d0ddeeb75f49509876c38bfec73fddaaabe730f9b0ad69e0cf4"
+ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "0abd4bcd970e5836e8903d4140b638e7637a74b14dd1f2f6b53b338ae1e31fb3"
},
"routes": [
- "/settings/infrastructure (managed local backend with a routed two-node Proxmox cluster fixture)"
+ "/proxmox/backups/coverage (isolated production component/router/CSS fixture; not full application)"
],
"viewports": [
{
- "width": 1280,
- "height": 720
- },
- {
- "width": 393,
- "height": 727
+ "width": 1440,
+ "height": 900
},
{
"width": 390,
- "height": 664
+ "height": 900
}
],
"states": [
- "Manage dialog with unsaved node name, Host Telemetry Agent strategy and SSL verification retained after a 15-second connection-ledger poll",
- "Expanded two-node Proxmox cluster editor open at desktop, Pixel and iPhone widths",
- "Manage dialog dismissed with the infrastructure workspace scroll position and deliberate onward focus retained",
- "Manage dialog reopened with immutable cluster-member display-name save targeting"
+ "40 synthetic workloads with mid-table restore evidence expanded",
+ "Three replacement HTTP polling snapshots render updated workload names with focused toggle and expanded evidence retained",
+ "Native scroll remains 532px desktop and 503px narrow; route unchanged and no page errors"
],
"interactions": [
- "Edited the node name, changed setup strategy, enabled certificate verification and advanced the production polling clock while checking retained state and refreshed dialog context",
- "Inspected desktop, Pixel and iPhone captures for placement, clipping, wrapping, scrolling, backdrop containment and close-control reachability",
- "Closed Manage, moved focus to Add infrastructure without scrolling and waited beyond the delayed row-focus fallback",
- "Reopened Manage, changed the second member display label and verified the PUT remained scoped to its immutable identity"
+ "Focus expansion toggle and press Enter, then retain focus across three replacement snapshots",
+ "Run pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs",
+ "Run pulse-heavy-run -- env PULSE_BROWSER_WIDTH=390 node scripts/check-backup-browser-polling.mjs; keyboard interaction only, not touch qualification"
]
}
diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
index 1e861196b..b3e34b1b2 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
@@ -1,4 +1,6 @@
-import { For, Show, createMemo, type Accessor, type JSX } from 'solid-js';
+import { For, Show, createEffect, createMemo, type Accessor, type JSX } from 'solid-js';
+
+import { createStore, reconcile } from 'solid-js/store';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
@@ -155,7 +157,11 @@ export function ProxmoxCoverageTable(props: {
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
const archiveSource = getProxmoxBackupSourcePresentation('archive');
const snapshotSource = getProxmoxBackupSourcePresentation('snapshot');
- const tableWindow = useProxmoxBackupTableWindowing(() => props.rows);
+ // Polling rebuilds recovery-model objects. Preserve logical row identity so
+ // Solid does not replace the focused toggle (or the browser's scroll anchor).
+ const [stableRows, setStableRows] = createStore([]);
+ createEffect(() => setStableRows(reconcile([...props.rows], { key: 'key' })));
+ const tableWindow = useProxmoxBackupTableWindowing(() => [...stableRows]);
return (
{
it('keeps coverage evidence expanded across repeated workload snapshots', async () => {
mockBackupAPIs();
const [workloads, setWorkloads] = createSignal([workloadResource]);
- renderInRouter(() => (
- } workloads={workloads()} />
- ));
+ renderInRouter(() => } workloads={workloads()} />);
await screen.findAllByText('pbs-docker');
await fireEvent.click(screen.getByRole('link', { name: /coverage/i }));
await fireEvent.click(screen.getByRole('button', { name: /expand details for pbs-docker/i }));
+ const toggle = screen.getByRole('button', { name: /collapse details for pbs-docker/i });
+ toggle.focus();
+
for (let snapshot = 1; snapshot <= 3; snapshot += 1) {
const name = `pbs-docker-snapshot-${snapshot}`;
setWorkloads([{ ...workloadResource, name, displayName: name }]);
@@ -513,6 +514,8 @@ describe('ProxmoxBackupsTable', () => {
// Assert the new snapshot reached the rendered table, rather than merely
// checking that a stale expanded row survived.
await screen.findAllByText(name);
+ expect(screen.getByRole('button', { name: /collapse details for pbs-docker/i })).toBe(toggle);
+ expect(toggle).toHaveFocus();
expect(screen.getByRole('columnheader', { name: /posture/i })).toBeInTheDocument();
expect(screen.getByText('Restore evidence')).toBeInTheDocument();
expect(screen.getAllByText('PVE file').length).toBeGreaterThan(0);
diff --git a/scripts/check-backup-browser-polling.mjs b/scripts/check-backup-browser-polling.mjs
new file mode 100644
index 000000000..48f64c9f2
--- /dev/null
+++ b/scripts/check-backup-browser-polling.mjs
@@ -0,0 +1,142 @@
+// Run with pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs
+// Isolated real-browser component regression; no running Pulse instance needed.
+import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
+import { chromium, expect } from "@playwright/test";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../frontend-modern", import.meta.url));
+process.chdir(root); // PostCSS/Tailwind resolve their config from the frontend root.
+const server = await createServer({
+ root,
+ configFile: `${root}/vite.config.ts`,
+ server: { host: "127.0.0.1", port: 5198, strictPort: true },
+ plugins: [
+ {
+ name: "backup-browser-fixture",
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ if (req.url?.startsWith("/proxmox/backups/"))
+ req.url = "/browser-tests/backups.html";
+ next();
+ });
+ },
+ },
+ ],
+});
+let browser;
+try {
+ await server.listen();
+ browser = await chromium.launch({ headless: true });
+ const page = await browser.newPage({
+ viewport: { width: Number(process.env.PULSE_BROWSER_WIDTH || 1440), height: 900 },
+ });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") console.error(message.text());
+ });
+ page.on("requestfailed", (request) =>
+ console.error(request.url(), request.failure()),
+ );
+ let generation = 0;
+ const workloads = () =>
+ Array.from({ length: 40 }, (_, index) => ({
+ id: `ct-${112 + index}`,
+ type: "system-container",
+ name: `guest-${String(index).padStart(2, "0")}-snapshot-${generation}`,
+ displayName: `guest-${String(index).padStart(2, "0")}-snapshot-${generation}`,
+ platformId: "pve-a",
+ platformType: "proxmox-pve",
+ sourceType: "api",
+ status: "running",
+ lastSeen: Date.now(),
+ proxmox: { vmid: 112 + index, node: "pve-a", instance: "pve-a" },
+ }));
+ await page.route("**/fixture/workloads", (route) =>
+ route.fulfill({ json: workloads() }),
+ );
+ await page.route(
+ (url) => url.pathname.startsWith("/api/"),
+ (route) => {
+ const path = new URL(route.request().url()).pathname;
+ if (path === "/api/backups/pve")
+ return route.fulfill({
+ json: {
+ data: {
+ guestSnapshots: workloads().map((w) => ({
+ id: `snap-${w.id}`,
+ name: "pre-upgrade",
+ node: "pve-a",
+ instance: "pve-a",
+ type: "ct",
+ vmid: w.proxmox.vmid,
+ time: new Date().toISOString(),
+ vmstate: false,
+ })),
+ storageBackups: [],
+ backupTasks: [],
+ },
+ },
+ });
+ if (path === "/api/backups/pbs")
+ return route.fulfill({ json: { data: { backups: [] } } });
+ return route.fulfill({ json: { data: [], policy: {} } });
+ },
+ );
+ await page.goto("http://127.0.0.1:5198/proxmox/backups/coverage");
+ const toggle = page.getByRole("button", {
+ name: /expand details for guest-20-/i,
+ });
+ // The narrow layout exposes this control on keyboard focus, not pointer hover.
+ await toggle.waitFor({ timeout: 20_000 });
+ await toggle.focus();
+ await toggle.press("Enter").catch(async (error) => {
+ console.error(errors, await page.locator("body").innerText());
+ throw error;
+ });
+ const expanded = page.getByRole("button", {
+ name: /collapse details for guest-20-/i,
+ });
+ await expanded.focus();
+ await expect(
+ page.getByText("Restore evidence", { exact: true }),
+ ).toBeVisible();
+ const baseline = await page.evaluate(() => ({
+ y: document.querySelector("main").scrollTop,
+ url: location.href,
+ }));
+ expect(baseline.y).toBeGreaterThan(100);
+ const samples = [];
+ for (generation = 1; generation <= 3; generation++) {
+ await expect(
+ page
+ .getByText(`guest-20-snapshot-${generation}`, { exact: true })
+ .first(),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Restore evidence", { exact: true }),
+ ).toBeVisible();
+ const sample = await page.evaluate(() => ({
+ y: document.querySelector("main").scrollTop,
+ url: location.href,
+ }));
+ console.log(
+ JSON.stringify({
+ generation,
+ ...sample,
+ focused: await expanded.evaluate((el) => el === document.activeElement),
+ }),
+ );
+ await expect(expanded).toBeFocused();
+ expect(sample.url).toBe(baseline.url);
+ expect(Math.abs(sample.y - baseline.y)).toBeLessThanOrEqual(1);
+ samples.push({ generation, ...sample });
+ }
+ expect(errors).toEqual([]);
+ console.log(
+ JSON.stringify({ baseline, samples, pageErrors: errors }, null, 2),
+ );
+} finally {
+ await browser?.close();
+ await server.close();
+}