diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bb5a9a7429..a881bc9206 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 5abcc7c6d4..3c43ab72a7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/browser-tests/proxmox-tabs-2321-hook.ts b/frontend-modern/browser-tests/proxmox-tabs-2321-hook.ts index 8fdff069ff..37f37fc1f1 100644 --- a/frontend-modern/browser-tests/proxmox-tabs-2321-hook.ts +++ b/frontend-modern/browser-tests/proxmox-tabs-2321-hook.ts @@ -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(null); +const [counts, setCounts] = createSignal(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, diff --git a/frontend-modern/browser-tests/proxmox-tabs-2321.cjs b/frontend-modern/browser-tests/proxmox-tabs-2321.cjs index 85eef131f1..dd931c441a 100644 --- a/frontend-modern/browser-tests/proxmox-tabs-2321.cjs +++ b/frontend-modern/browser-tests/proxmox-tabs-2321.cjs @@ -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) => ({ diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c9580fa5df..e87de2b11e 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." } diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index aebcd84fae..2b54c5336b 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -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['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) => diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index 52364765a7..3c8e76bb57 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -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(null); + const [facets, setFacets] = createSignal(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');