Use source-filtered facets for Proxmox tab evidence

Prevent unrelated estate-wide VM and storage counts from advertising Proxmox sections, while retaining direct-link hydration and the PBS-only Backups path. Revalidate the compact facet request when an all-resources cache supplies rows without scoped counts; cover mixed-source counts in component and browser proof and update both subsystem contracts.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-29 14:57:59 +01:00
parent efd04b11c9
commit b129b5a40c
7 changed files with 89 additions and 53 deletions
@@ -404,7 +404,15 @@ and thermal facts remain discoverable from the PBS surface.
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
evidence. It reads the source-filtered `facets.byType` from one compact
`type=pmg&source=proxmox,pbs,pmg,agent` request, not estate-wide
`aggregations.byType`: unrelated VMware VMs and TrueNAS storage must not
advertise Proxmox workflows. Agent is included for Proxmox-owned physical
disks, whose fact source may remain `agent`; because that source is shared, a
generic agent disk can still expose Storage until the route filters its rows.
A fresh all-resources cache does not carry source-filtered facets, so the
compact query must revalidate even when that cache can paint rows. While the
facets are unknown the rail 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,
@@ -795,9 +795,11 @@ 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
Unknown source-scoped resource facets must not advertise optional Proxmox
workflows as if their providers are present. Estate-wide aggregations are not
tab evidence: unrelated provider rows can share VM or storage types. 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
@@ -1,8 +1,9 @@
// Browser-only resource-hook seam for #2321. The production page and shared
// tab rail stay real; counts/resources are synthetic and switch on command.
// tab rail stay real; source-scoped facets/resources are synthetic and switch
// on command. Global counts deliberately contain unrelated provider rows.
import { createSignal } from 'solid-js';
import type { Resource } from '../src/types/resource';
import type { UnifiedResourceAggregations } from '../src/hooks/useUnifiedResources';
import type { UnifiedResourceFacets } from '../src/hooks/useUnifiedResources';
export * from '../src/hooks/useUnifiedResources';
@@ -18,7 +19,7 @@ const pmg = {
sources: ['pmg'], status: 'online', lastSeen: Date.now(),
} as Resource;
const [counts, setCounts] = createSignal<UnifiedResourceAggregations | null>(null);
const [counts, setCounts] = createSignal<UnifiedResourceFacets | null>(null);
(window as unknown as { __proxmoxTabProof: { setCounts: typeof setCounts } }).__proxmoxTabProof = {
setCounts,
};
@@ -36,8 +37,8 @@ export function useUnifiedResources(options?: { cacheKey?: string }) {
};
return {
resources,
aggregations: counts,
facets: () => null,
aggregations: () => ({ total: 5, byType: { storage: 1, vm: 1, ceph: 1, pmg: 1 } }),
facets: options?.cacheKey === 'proxmox-tab-evidence' ? counts : () => null,
policyPosture: () => null,
resourceSnapshotChange: () => ({ version: 0, changedIds: null }),
loading: () => false,
@@ -49,7 +49,7 @@ const { chromium } = require('playwright');
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 setCounts = async (byType) => page.evaluate((value) => window.__proxmoxTabProof.setCounts({ incidentCount: 0, 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) => ({
+27 -21
View File
@@ -1,47 +1,53 @@
{
"version": 1,
"base_sha": "c54d361acf0d38d3f90767022513f718e05e1b69",
"verified_at": "2026-09-29T11:23:17Z",
"base_sha": "efd04b11c944ffccd4ec556f0b35cf2bf80bbbd1",
"verified_at": "2026-09-29T13:54:15Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "37ce3942c563770c3bdd494a692ac9fe22cf367479b4f788136ad5d8faaaf625"
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "67da1ba826ad85f30e8b1ab61c3477badf52629ba4d063537bada4003650d5d7"
},
"routes": [
"/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": [
"unknown resource counts on direct Mail Gateway link",
"PBS-only resource counts with Mail Gateway unsupported",
"unknown source-scoped facets despite positive unrelated global counts on direct Mail Gateway link",
"PBS-only scoped facets with Mail Gateway, Ceph and Storage unsupported despite unrelated global counts",
"PBS Backups tab active",
"positive Storage, Ceph and Mail Gateway counts",
"positive scoped Storage, Ceph and Mail Gateway facets",
"Mail Gateway tab active",
"Mail Gateway count removed after navigation",
"no optional service counts"
"Mail Gateway facet removed after navigation",
"no optional service facets"
],
"interactions": [
"hydrate bookmarked Mail Gateway route before counts settle",
"reveal PBS-only Backups while hiding unsupported Replication, Ceph and Mail Gateway tabs",
"hydrate bookmarked Mail Gateway route before source-scoped facets settle",
"reveal PBS-only Backups while ignoring unrelated estate-wide aggregation counts",
"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",
"activate Mail Gateway after positive scoped facets",
"fall back to Overview when Mail Gateway facet disappears without rewriting the URL",
"verify selected-tab semantics, desktop and phone rail layout, and no document overflow"
],
"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-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"
"/var/lib/pulse-maintainer/worker-outputs/web-product-ycx7l47_/browser-2321-desktop-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ycx7l47_/browser-2321-phone-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ycx7l47_/browser-images-final/proxmox-tabs-2321-unknown-direct-mail-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ycx7l47_/browser-images-final/proxmox-tabs-2321-pbs-only-backups-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ycx7l47_/browser-images-final/proxmox-tabs-2321-available-mail-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ycx7l47_/browser-images-final/proxmox-tabs-2321-removed-mail-1365.png"
],
"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."
"notes": "Production ProxmoxPageSurface and PlatformSectionTabs were mounted in a Vite/Chromium fixture. Only useUnifiedResources and API responses were synthetic; the fixture returns unrelated positive estate-wide aggregations and independent Proxmox-scoped facets. No installed server, agent, reporter topology or release was exercised. Both runs exited 0 with no page errors or horizontal overflow. Selected screenshots were inspected for rail state and narrow reachability."
}
@@ -104,8 +104,23 @@ export function ProxmoxPageSurface() {
const segment = location.pathname.split('/').filter(Boolean)[1] as ProxmoxPageTabId | undefined;
return segment && VALID_TABS.has(segment) ? segment : 'overview';
});
const [resourceAggregations, setResourceAggregations] =
createSignal<ReturnType<ReturnType<typeof useUnifiedResources>['aggregations']>>(null);
// List aggregations count the whole estate, not the query scope. The small
// PMG row query returns source-scoped type facets for every Proxmox workflow
// without hydrating each inactive section's inventory.
const tabEvidence = useUnifiedResources({
query: 'type=pmg&source=proxmox,pbs,pmg,agent',
cacheKey: 'proxmox-tab-evidence',
initialHydration: 'prefer-ws-then-rest',
});
createEffect(() => {
// A fresh all-resources cache can seed rows but not source-filtered facets.
// Force this compact query once instead of leaving the rail unresolved
// until a websocket event happens to arrive.
if (tabEvidence.facets?.() !== null) return;
void Promise.resolve()
.then(() => tabEvidence.refetch())
.catch(() => undefined);
});
// Replication jobs come straight from /api/replication/jobs (they bypass
// the unified-resource pipeline), so the surface owns the fetch: the job
// count gates the Replication tab and the same data feeds the table.
@@ -117,10 +132,10 @@ export function ProxmoxPageSurface() {
);
const visibleTabs = createMemo(() => {
// 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).
// present. Never use estate-wide aggregations here: unrelated VMware VMs
// or TrueNAS storage would otherwise advertise empty Proxmox sections.
return buildVisibleProxmoxTabSpecsFromCounts(
resourceAggregations()?.byType ?? {},
tabEvidence.facets?.()?.byType ?? {},
replicationJobCount(),
);
});
@@ -131,7 +146,7 @@ export function ProxmoxPageSurface() {
const requested = requestedTab();
// 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';
return !tabEvidence.facets?.() || visibleTabIds().has(requested) ? requested : 'overview';
});
const shouldHydrateTab = (tab: ProxmoxPageTabId) => activeTab() === tab;
const overviewResources = useUnifiedResources({
@@ -221,10 +236,6 @@ export function ProxmoxPageSurface() {
activeTab() === 'backups'
? Promise.all([overviewResources.refetch(), backupResources.refetch()])
: resourceState().refetch();
createEffect(() => {
const next = resourceState().aggregations?.();
if (next) setResourceAggregations(next);
});
const normalizeSnapshot = (snapshot: Resource[] | undefined) =>
Array.isArray(snapshot) ? snapshot : [];
const buildModel = (snapshot: Resource[] | undefined) =>
@@ -2,7 +2,7 @@ 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 { UnifiedResourceFacets } from '@/hooks/useUnifiedResources';
import type { Resource } from '@/types/resource';
import { ProxmoxPageSurface } from '../ProxmoxPageSurface';
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
@@ -443,10 +443,11 @@ describe('ProxmoxPageSurface contract', () => {
value as {
query: string;
cacheKey: string;
enabled: () => boolean;
enabled?: () => boolean;
},
);
expect(options.map((value) => value.cacheKey)).toEqual([
'proxmox-tab-evidence',
'proxmox-overview',
'proxmox-storage-shell',
'proxmox-replication-shell',
@@ -455,6 +456,7 @@ describe('ProxmoxPageSurface contract', () => {
'proxmox-mail',
]);
expect(options.map((value) => value.query)).toEqual([
'type=pmg&source=proxmox,pbs,pmg,agent',
'type=agent,vm,system-container,oci-container&source=proxmox',
'type=agent,pbs,storage,physical_disk,ceph&source=proxmox,pbs,agent',
'type=agent&source=proxmox',
@@ -462,8 +464,9 @@ describe('ProxmoxPageSurface contract', () => {
'type=ceph&source=proxmox',
'type=pmg&source=pmg',
]);
expect(options[0].enabled()).toBe(true);
expect(options.slice(1).every((value) => value.enabled() === false)).toBe(true);
expect(options[0].enabled).toBeUndefined();
expect(options[1].enabled?.()).toBe(true);
expect(options.slice(2).every((value) => value.enabled?.() === false)).toBe(true);
expect(proxmoxPageSurfaceSource).not.toContain('backgroundHydrationTabs');
expect(proxmoxPageSurfaceSource).not.toContain('requestIdleCallback');
expect(proxmoxPageSurfaceSource).toContain('resourceSource={storageResources}');
@@ -504,9 +507,10 @@ describe('ProxmoxPageSurface contract', () => {
renderSurface();
const options = mockUseUnifiedResources.mock.calls.map(
([value]) => value as { cacheKey: string; query: string; enabled: () => boolean },
([value]) => value as { cacheKey: string; query: string; enabled?: () => boolean },
);
expect(options.map((value) => value.enabled())).toEqual([
expect(options.map((value) => value.enabled?.() ?? true)).toEqual([
true,
true,
false,
false,
@@ -514,7 +518,7 @@ describe('ProxmoxPageSurface contract', () => {
false,
false,
]);
expect(options[3]).toMatchObject({
expect(options[4]).toMatchObject({
cacheKey: 'proxmox-backups-shell',
query: 'type=pbs,agent&source=pbs',
});
@@ -590,13 +594,16 @@ describe('ProxmoxPageSurface contract', () => {
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);
const [facets, setFacets] = createSignal<UnifiedResourceFacets | null>(null);
const refetch = vi.fn(async () => []);
mockUseUnifiedResources.mockReturnValue({
resources: () => [],
aggregations,
// Estate-wide counts include unrelated providers and must not unlock tabs.
aggregations: () => ({ total: 5, byType: { storage: 1, vm: 1, ceph: 1, pmg: 1 } }),
facets,
loading: () => false,
error: () => null,
refetch: vi.fn(),
refetch,
});
renderSurface();
@@ -608,14 +615,15 @@ describe('ProxmoxPageSurface contract', () => {
([value]) => value as { cacheKey: string; enabled: () => boolean },
);
expect(options.find((option) => option.cacheKey === 'proxmox-mail')?.enabled()).toBe(true);
await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1));
setAggregations({ total: 2, byType: { pbs: 1, pmg: 1 } });
setFacets({ incidentCount: 0, 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 } });
setFacets({ incidentCount: 0, byType: { pbs: 1 } });
await waitFor(() => {
expect(tabs).toHaveAttribute('data-tabs', 'overview,backups');
expect(tabs).toHaveAttribute('data-active', 'overview');