Gate Proxmox tabs on observed capabilities

Avoid showing unsupported Replication, Ceph and Mail Gateway sections while Proxmox resource counts are unknown, while preserving bookmarked-route hydration and legitimate PBS navigation. Cover count transitions in component tests and synthetic production-page desktop/phone browser verification; update the affected subsystem contracts and receipt.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-29 12:25:29 +01:00
parent c54d361acf
commit 3a061d66c4
10 changed files with 305 additions and 51 deletions

View file

@ -403,6 +403,14 @@ and thermal facts remain discoverable from the PBS surface.
`ProxmoxBackupServersTable` belongs to the Proxmox Backups tab. Proxmox
Overview must not duplicate that domain table between its node and guest
regions; the tab boundary owns PBS server, datastore, and artifact detail.
The shared Proxmox section rail renders only tabs backed by current capability
evidence. While resource counts are unknown it must not show every optional
tab as a loading fallback; an independently fetched positive replication-job
count may still expose Replication. A bookmarked section remains the active
hydration target until counts can distinguish unavailable from unsupported,
then unsupported sections fall back to Overview. This rule applies at desktop
and phone widths and must not rewrite the URL or discard a valid PBS-only
Backups tab when its count arrives.
When that surface receives the provider-owned PBS resource and its host Agent
as separate canonical resources, `ProxmoxBackupServersTable` may assemble a
presentation-only drawer resource only after one unique normalized host

View file

@ -795,6 +795,13 @@ state effects remain dormant and a forced Proxmox storage scope is not
serialized as redundant URL state. Backups reuses the source-scoped Overview
guest snapshot and adds only the PBS resource family, so it cannot repeat the
large VM/LXC enumeration under a second request owner.
Unknown resource aggregations must not advertise optional Proxmox workflows as
if their providers are present. The navigation rail may expose Replication
from its independent positive job count, while Storage, Backups, Ceph, and Mail
wait for positive resource counts. A direct link keeps its requested route
hydrating while counts are unknown; after counts arrive, an unsupported route
renders Overview instead. Hiding a tab is navigation presentation, not a
verdict about backup coverage, artifact recoverability, or stored history.
PBS server/datastore rows may display backup counts, but the
counts must come from the PBS backup API artifact identity, not from a
datastore-capacity approximation. The table owns which PBS artifact count is

View file

@ -0,0 +1,48 @@
// Browser-only resource-hook seam for #2321. The production page and shared
// tab rail stay real; counts/resources are synthetic and switch on command.
import { createSignal } from 'solid-js';
import type { Resource } from '../src/types/resource';
import type { UnifiedResourceAggregations } from '../src/hooks/useUnifiedResources';
export * from '../src/hooks/useUnifiedResources';
const pbs = {
id: 'pbs-only', type: 'pbs', name: 'pbs-only', displayName: 'pbs-only',
platformId: 'pbs-only', platformType: 'proxmox-pbs', sourceType: 'api',
sources: ['pbs'], status: 'online', lastSeen: Date.now(),
pbs: { instanceId: 'pbs-only', hostname: 'pbs-only', datastores: [] },
} as Resource;
const pmg = {
id: 'pmg-one', type: 'pmg', name: 'pmg-one', displayName: 'pmg-one',
platformId: 'pmg-one', platformType: 'proxmox-pmg', sourceType: 'api',
sources: ['pmg'], status: 'online', lastSeen: Date.now(),
} as Resource;
const [counts, setCounts] = createSignal<UnifiedResourceAggregations | null>(null);
(window as unknown as { __proxmoxTabProof: { setCounts: typeof setCounts } }).__proxmoxTabProof = {
setCounts,
};
export function useUnifiedResources(options?: { cacheKey?: string }) {
const resources = () => {
switch (options?.cacheKey) {
case 'proxmox-backups-shell':
return [pbs];
case 'proxmox-mail':
return [pmg];
default:
return [] as Resource[];
}
};
return {
resources,
aggregations: counts,
facets: () => null,
policyPosture: () => null,
resourceSnapshotChange: () => ({ version: 0, changedIds: null }),
loading: () => false,
error: () => null,
refetch: async () => resources(),
mutate: () => resources(),
};
}

View file

@ -0,0 +1 @@
{"width":1365}

View file

@ -0,0 +1,119 @@
// #2321 browser proof: production Proxmox page and tab rail with a synthetic
// reactive resource-hook seam. No live API, credentials or reporter data.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'browser-tests');
const width = JSON.parse(fs.readFileSync(path.join(artifacts, 'proxmox-tabs-2321-run.json'), 'utf8')).width;
assert.ok(width === 1365 || width === 390);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const { default: solid } = await import(path.join(root, 'node_modules/vite-plugin-solid/dist/esm/index.mjs'));
const server = await createServer({
root,
configFile: false,
plugins: [solid()],
resolve: { alias: [
{ find: /^@\/hooks\/useUnifiedResources$/, replacement: path.join(artifacts, 'proxmox-tabs-2321-hook.ts') },
{ find: '@', replacement: path.join(root, 'src') },
] },
optimizeDeps: { noDiscovery: true, include: [], esbuildOptions: { target: 'esnext' } },
server: { host: '127.0.0.1', port: 5201, strictPort: true },
});
let browser;
try {
await server.listen();
browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width, height: width === 390 ? 844 : 900 } });
const pageErrors = [];
const apiRequests = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.routeWebSocket(/\/ws(?:\?|$)/, () => {});
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5201') return route.abort();
if (url.pathname.startsWith('/api/')) {
apiRequests.push(url.pathname);
if (url.pathname === '/api/replication/jobs') return route.fulfill({ json: { data: [] } });
return route.fulfill({ json: { data: [] } });
}
return route.continue();
});
await page.goto('http://127.0.0.1:5201/browser-tests/proxmox-tabs-2321.html?tab=mail', {
waitUntil: 'domcontentloaded', timeout: 120_000,
});
await page.getByTestId('proxmox-page').waitFor();
const nav = page.getByRole('navigation', { name: 'Proxmox sections' });
const labels = async () => (await nav.locator('a').allTextContents()).map((label) => label.trim());
const setCounts = async (byType) => page.evaluate((value) => window.__proxmoxTabProof.setCounts({ total: 1, byType: value }), byType);
const screenshot = async (state) => {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const rail = await page.evaluate(() => [...document.querySelectorAll('nav[aria-label="Proxmox sections"] a')].map((link) => ({
label: link.textContent?.trim(), current: link.getAttribute('aria-current'),
selectedClass: link.classList.contains('border-blue-500'),
})));
assert.ok(rail.every((link) => link.selectedClass === (link.current === 'page')), JSON.stringify({ state, rail }));
await page.screenshot({ path: path.join(artifacts, `proxmox-tabs-2321-${state}-${width}.png`), fullPage: true });
};
assert.equal(new URL(page.url()).pathname, '/proxmox/mail');
assert.equal(await nav.count(), 0, 'unknown counts must not show ghost tabs');
await screenshot('unknown-direct-mail');
await setCounts({ pbs: 1 });
await nav.waitFor();
assert.deepEqual(await labels(), ['Overview', 'Backups']);
assert.equal(await nav.getByRole('link', { name: 'Overview' }).getAttribute('aria-current'), 'page');
assert.equal(new URL(page.url()).pathname, '/proxmox/mail', 'fallback keeps the bookmarked URL');
await screenshot('pbs-only-fallback');
const backups = nav.getByRole('link', { name: 'Backups' });
if (width === 390) {
await backups.focus();
await page.keyboard.press('Enter');
} else {
await backups.click();
}
try {
await page.waitForURL('**/proxmox/backups/date', { timeout: 5_000 });
} catch (error) {
throw new Error(JSON.stringify({ href: await backups.getAttribute('href'), url: page.url(), body: await page.locator('body').innerText(), pageErrors }), { cause: error });
}
assert.equal(new URL(page.url()).pathname, '/proxmox/backups/date');
assert.equal(await backups.getAttribute('aria-current'), 'page');
await screenshot('pbs-only-backups');
await setCounts({ pbs: 1, storage: 1, ceph: 1, pmg: 1 });
assert.deepEqual(await labels(), ['Overview', 'Storage', 'Backups', 'Ceph', 'Mail Gateway']);
await nav.getByRole('link', { name: 'Mail Gateway' }).click();
await page.waitForURL('**/proxmox/mail');
assert.equal(new URL(page.url()).pathname, '/proxmox/mail');
assert.equal(await nav.getByRole('link', { name: 'Mail Gateway' }).getAttribute('aria-current'), 'page');
await screenshot('available-mail');
await setCounts({ pbs: 1 });
assert.deepEqual(await labels(), ['Overview', 'Backups']);
assert.equal(await nav.getByRole('link', { name: 'Overview' }).getAttribute('aria-current'), 'page');
await screenshot('removed-mail');
await setCounts({});
assert.equal(await nav.count(), 0);
await screenshot('no-optional-services');
const dimensions = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, innerWidth: window.innerWidth }));
assert.ok(dimensions.scrollWidth <= dimensions.innerWidth + 1, JSON.stringify(dimensions));
assert.deepEqual(pageErrors, []);
console.log(JSON.stringify({ result: 'passed', source: 'production ProxmoxPageSurface and PlatformSectionTabs; synthetic useUnifiedResources', width, browser: browser.version(), playwright: '1.56.1', apiRequests, pageErrors, dimensions }));
await page.close();
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Proxmox tabs browser fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/proxmox-tabs-2321.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,25 @@
// Mount the production page and router; the browser runner replaces only the
// resource and replication API responses for #2321's PBS-only estate.
import { Route, Router } from '@solidjs/router';
import { render } from 'solid-js/web';
import { ProxmoxPageSurface } from '../src/features/proxmox/ProxmoxPageSurface';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { getGlobalWebSocketStore } from '../src/stores/websocket-global';
import '../src/index.css';
const requested = new URLSearchParams(window.location.search).get('tab');
const tab = requested === 'mail' || requested === 'backups' ? requested : 'overview';
window.history.replaceState(null, '', `/proxmox/${tab}`);
render(
() => (
<WebSocketContext.Provider value={getGlobalWebSocketStore()}>
<DarkModeContext.Provider value={() => false}>
<Router>
<Route path="/proxmox/*" component={ProxmoxPageSurface} />
</Router>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
),
document.getElementById('root')!,
);

View file

@ -1,62 +1,47 @@
{
"version": 1,
"base_sha": "8619ab2ec8f5d239d045dec1f1f719ff3c5d687e",
"verified_at": "2026-09-29T06:55:46Z",
"base_sha": "c54d361acf0d38d3f90767022513f718e05e1b69",
"verified_at": "2026-09-29T11:23:17Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/resourceActions.ts",
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx",
"frontend-modern/src/features/actions/ActionReviewDialog.tsx",
"frontend-modern/src/features/actions/actionPresentation.ts",
"frontend-modern/src/pages/Actions.tsx"
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/api/resourceActions.ts": "60e65faa74d6fffd43f97dfa6ab2eff946c666893b5201217bd3e886312ac47b",
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx": "2839a0be877b906bbc717bae7ef829aaffc2e46de8242413617d078087799cb1",
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "ff35ee1912d45b3a0546bd31ed498ecf94f01a493c0a86b93252b0e5d7a529d0",
"frontend-modern/src/features/actions/actionPresentation.ts": "723a40b5b41f8645c76f54e2c0ffac3ae60757b885ee0bee6b299dc97146ebc1",
"frontend-modern/src/pages/Actions.tsx": "c43ae74844fb8d31b904fa273d4a35c0344084ef7b6e227e55d8924dfa7b248c"
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "37ce3942c563770c3bdd494a692ac9fe22cf367479b4f788136ad5d8faaaf625"
},
"routes": [
"/action-recovery-proof.html (mock-backed shared /actions review component)"
"/proxmox/mail (direct link, synthetic capability fixture)",
"/proxmox/backups/date (tab navigation, synthetic capability fixture)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
{ "width": 1365, "height": 900 },
{ "width": 390, "height": 844 }
],
"states": [
"aged admin",
"terminal inconclusive",
"recent",
"read-only",
"settled",
"non-admin",
"stale re-read"
"unknown resource counts on direct Mail Gateway link",
"PBS-only resource counts with Mail Gateway unsupported",
"PBS Backups tab active",
"positive Storage, Ceph and Mail Gateway counts",
"Mail Gateway tab active",
"Mail Gateway count removed after navigation",
"no optional service counts"
],
"interactions": [
"open disclosure",
"reason and acknowledgement gates",
"force-fail post",
"refresh outcome",
"stale skip",
"phone layout"
"hydrate bookmarked Mail Gateway route before counts settle",
"reveal PBS-only Backups while hiding unsupported Replication, Ceph and Mail Gateway tabs",
"activate Backups by pointer on desktop and keyboard on phone",
"activate Mail Gateway after positive counts",
"fall back to Overview when Mail Gateway count disappears without rewriting the URL",
"verify selected-tab semantics, desktop and phone rail layout, and no document overflow"
],
"command": "pulse-worker-browser web-1891-proof.cjs from assigned workspace root; offline Vite API preview and Chromium 141.0.7390.37 / Playwright 1.56.1",
"command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-tabs-2321.cjs from assigned root, with browser-tests/proxmox-tabs-2321-run.json set to 1365 then 390; Chromium 141.0.7390.37 / Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-desktop-before.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-desktop-after.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-mobile.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-mobile-dialog.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/web-1891-proof.cjs",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/action-recovery-proof.html",
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/action-recovery-proof.tsx"
"/var/lib/pulse-maintainer/worker-outputs/web-product-18cny1xf/browser-2321-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-18cny1xf/browser-2321-phone.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-18cny1xf/browser-images/proxmox-tabs-2321-unknown-direct-mail-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-18cny1xf/browser-images/proxmox-tabs-2321-pbs-only-backups-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-18cny1xf/browser-images/proxmox-tabs-2321-available-mail-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-18cny1xf/browser-images/proxmox-tabs-2321-removed-mail-1365.png"
],
"notes": "Shared ActionReviewDialog exercised with mock-backed API/status at exact staged runtime content; no live backend, container outcome, installed release or reporter acceptance. Component tests cover Actions history state. Screenshots visually inspected after transition."
"notes": "Production ProxmoxPageSurface and PlatformSectionTabs were mounted in a Vite/Chromium fixture. Only useUnifiedResources and API responses were synthetic; no installed server, agent, reporter topology or release was exercised. Both runs exited 0 with no page errors or horizontal overflow. Final screenshots were inspected for selected rail, table placement and narrow reachability."
}

View file

@ -116,18 +116,22 @@ export function ProxmoxPageSurface() {
replicationJobs.error ? 0 : (replicationJobs() ?? []).length,
);
const visibleTabs = createMemo(() => {
const aggregations = resourceAggregations();
if (aggregations) {
return buildVisibleProxmoxTabSpecsFromCounts(aggregations.byType, replicationJobCount());
}
return PROXMOX_TAB_SPECS;
// An unknown snapshot is not evidence that every optional integration is
// present. Keep only sections backed by an independent signal until the
// resource counts arrive (or if an older response omits them).
return buildVisibleProxmoxTabSpecsFromCounts(
resourceAggregations()?.byType ?? {},
replicationJobCount(),
);
});
const visibleTabIds = createMemo(
() => new Set<ProxmoxPageTabId>(visibleTabs().map((tab) => tab.id)),
);
const activeTab = createMemo<ProxmoxPageTabId>(() => {
const requested = requestedTab();
return visibleTabIds().has(requested) ? requested : 'overview';
// Do not discard a direct link while counts are still unknown: its own
// resource query must be allowed to hydrate before deciding it is absent.
return !resourceAggregations() || visibleTabIds().has(requested) ? requested : 'overview';
});
const shouldHydrateTab = (tab: ProxmoxPageTabId) => activeTab() === tab;
const overviewResources = useUnifiedResources({

View file

@ -1,6 +1,8 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { UnifiedResourceAggregations } from '@/hooks/useUnifiedResources';
import type { Resource } from '@/types/resource';
import { ProxmoxPageSurface } from '../ProxmoxPageSurface';
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
@ -577,6 +579,49 @@ describe('ProxmoxPageSurface contract', () => {
expect(screen.queryByRole('list', { name: 'Proxmox Patrol coverage' })).not.toBeInTheDocument();
});
it('does not advertise optional sections before resource counts arrive', () => {
setResourcesSnapshot(undefined, true);
renderSurface();
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-tabs', 'overview');
expect(screen.getByTestId('platform-table-loading-state')).toBeInTheDocument();
});
it('hydrates a direct link while counts are unknown, then gates it on actual capabilities', async () => {
mockPathname.mockReturnValue('/proxmox/mail');
const [aggregations, setAggregations] = createSignal<UnifiedResourceAggregations | null>(null);
mockUseUnifiedResources.mockReturnValue({
resources: () => [],
aggregations,
loading: () => false,
error: () => null,
refetch: vi.fn(),
});
renderSurface();
const tabs = screen.getByTestId('platform-section-tabs');
expect(tabs).toHaveAttribute('data-tabs', 'overview');
expect(tabs).toHaveAttribute('data-active', 'mail');
const options = mockUseUnifiedResources.mock.calls.map(
([value]) => value as { cacheKey: string; enabled: () => boolean },
);
expect(options.find((option) => option.cacheKey === 'proxmox-mail')?.enabled()).toBe(true);
setAggregations({ total: 2, byType: { pbs: 1, pmg: 1 } });
await waitFor(() => {
expect(tabs).toHaveAttribute('data-tabs', 'overview,backups,mail');
expect(tabs).toHaveAttribute('data-active', 'mail');
});
setAggregations({ total: 1, byType: { pbs: 1 } });
await waitFor(() => {
expect(tabs).toHaveAttribute('data-tabs', 'overview,backups');
expect(tabs).toHaveAttribute('data-active', 'overview');
});
});
it('does not surface stale-agent notices for development builds without an agent target', () => {
mockVersionInfo.mockReturnValue({
version: '6.0.0-rc.6+git.172.g2c360f779.dirty',