Fix Proxmox settings route selection

Refs #1423
This commit is contained in:
rcourtman 2026-04-16 12:03:31 +01:00
parent d0a60e1a92
commit 59d320d1d4
6 changed files with 112 additions and 13 deletions

View file

@ -215,13 +215,14 @@ work extends shared components instead of creating new local variants.
reconstructing raw `current / limit` slash math or `0 remaining` copy in
the banner shell, state owner, or shared model.
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
6. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
7. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest.
8. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
9. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
10. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
11. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
12. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. That same shared table-focus owner now also owns reversible clearing: pinned scope may clear only from governed neutral interaction-surface space or the shared `Escape` path, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed and supplying one page-level reset callback for filters plus summary-linked selections. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed.
6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state.
7. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest.
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
10. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
11. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
12. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
13. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. That same shared table-focus owner now also owns reversible clearing: pinned scope may clear only from governed neutral interaction-surface space or the shared `Escape` path, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed and supplying one page-level reset callback for filters plus summary-linked selections. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed.
Shared summary-linked rows and group headers must also route their preview
semantics through
`frontend-modern/src/components/shared/summaryInteractionA11y.ts`.
@ -1571,6 +1572,13 @@ including the route-owned billing focus contract where
`/settings/system/billing/plan` is the canonical settings-tab destination,
`/settings/system/billing/usage` is a same-tab child state, and legacy billing
base/hash links are compatibility inputs rather than primary runtime routes.
That same route-sync owner must also preserve Proxmox platform-selection truth
across canonical deep links such as
`/settings/infrastructure/platforms/proxmox/pbs` and
`/settings/infrastructure/platforms/proxmox/pmg`: even though those routes
collapse into the shared `infrastructure-operations` tab, the selected
platform state must still be derived from the path instead of silently
falling back to `pve` on reload or remount.
`useSettingsShellState.ts` owns shell-local sidebar/search/password-modal
state, and `settingsTabSaveBehavior.ts` owns settings tab save-behavior lookup,
`frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx` owns

View file

@ -516,10 +516,12 @@ describe('Settings architecture guardrails', () => {
"const INFRASTRUCTURE_INSTALL_PREFIX = '/settings/infrastructure/install';",
);
expect(settingsNavigationModelSource).toContain('export function resolveCanonicalSettingsPath');
expect(settingsNavigationModelSource).toContain('export function isProxmoxSettingsPath');
expect(settingsNavigationModelSource).toContain('export function settingsTabPath');
expect(settingsNavigationModelSource).toContain('return INFRASTRUCTURE_INSTALL_PREFIX;');
expect(settingsNavigationModelSource).toContain('SELF_HOSTED_PRO_BILLING_PLAN_ROUTE');
expect(settingsNavigationHookSource).toContain('deriveTabFromPath');
expect(settingsNavigationHookSource).toContain('isProxmoxSettingsPath');
expect(settingsNavigationHookSource).toContain('resolveCanonicalSettingsPath');
expect(settingsNavigationHookSource).toContain('resolveCanonicalSelfHostedBillingHref');
expect(settingsNavigationHookSource).toContain('settingsTabPath');

View file

@ -21,9 +21,12 @@ function renderHarness(pathname = '/settings', search = '', hash = '') {
});
return (
<>
<button type="button" onClick={() => navigation.setActiveTab('infrastructure-operations')}>
open infrastructure settings
</button>
<div data-testid="selected-agent">{navigation.selectedAgent()}</div>
</>
);
});
}
@ -58,4 +61,20 @@ describe('useSettingsNavigation', () => {
scroll: false,
});
});
it('syncs the selected proxmox agent from canonical deep links on initial load', async () => {
renderHarness('/settings/infrastructure/platforms/proxmox/pbs');
await waitFor(() => {
expect(screen.getByTestId('selected-agent')).toHaveTextContent('pbs');
});
});
it('defaults the selected proxmox agent to pve on the base proxmox route', async () => {
renderHarness('/settings/infrastructure/platforms/proxmox');
await waitFor(() => {
expect(screen.getByTestId('selected-agent')).toHaveTextContent('pve');
});
});
});

View file

@ -250,6 +250,16 @@ export function deriveAgentFromPath(path: string): AgentKey | null {
return null;
}
export function isProxmoxSettingsPath(path: string): boolean {
const canonicalPath = resolveCanonicalSettingsPath(path) ?? normalizeSettingsPath(path);
return (
canonicalPath === PLATFORM_CONNECTIONS_PREFIX ||
canonicalPath.startsWith(PROXMOX_PREFIX) ||
canonicalPath.startsWith(LEGACY_PROXMOX_PREFIX) ||
canonicalPath.startsWith(LEGACY_PROXMOX_API_PREFIX)
);
}
export function settingsAgentPath(agent: AgentKey): string {
return PROXMOX_AGENT_META[agent].path;
}

View file

@ -7,6 +7,7 @@ import {
deriveAgentFromPath,
deriveTabFromPath,
deriveTabFromQuery,
isProxmoxSettingsPath,
resolveCanonicalSettingsPath,
settingsAgentPath,
settingsTabPath,
@ -139,9 +140,11 @@ export function useSettingsNavigation({ navigate, location }: UseSettingsNavigat
setCurrentTab(resolved);
}
if (resolved === 'proxmox') {
const agentFromPath = deriveAgentFromPath(effectivePath);
setSelectedAgent(agentFromPath ?? 'pve');
if (isProxmoxSettingsPath(effectivePath)) {
const agentFromPath = deriveAgentFromPath(effectivePath) ?? 'pve';
if (selectedAgent() !== agentFromPath) {
setSelectedAgent(agentFromPath);
}
}
},
),

View file

@ -127,6 +127,16 @@ async function expectFirstTableContains(
}
}
async function expectFirstTableExcludes(
page: import('@playwright/test').Page,
texts: string[],
): Promise<void> {
const table = page.locator('table').first();
for (const text of texts) {
await expect(table).not.toContainText(text);
}
}
test.describe.serial('Platform mock runtime', () => {
test.setTimeout(180_000);
@ -219,4 +229,51 @@ test.describe.serial('Platform mock runtime', () => {
await expectInfrastructureSource(page, source, resourceName);
}
});
test('keeps canonical Proxmox platform routes aligned with the selected platform table', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof');
await ensureMockModeEnabled(page);
const resourceNames = await fetchMockResourceNames(page);
await page.goto('/settings/infrastructure/platforms/proxmox/pve', {
waitUntil: 'domcontentloaded',
});
await page.waitForURL(/\/settings\/infrastructure\/platforms\/proxmox\/pve/, {
timeout: 15_000,
});
await expect(page.getByRole('button', { name: 'Virtual Environment', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expectFirstTableContains(page, [resourceNames['proxmox-pve']]);
await page.goto('/settings/infrastructure/platforms/proxmox/pbs', {
waitUntil: 'domcontentloaded',
});
await page.waitForURL(/\/settings\/infrastructure\/platforms\/proxmox\/pbs/, {
timeout: 15_000,
});
await expect(page.getByRole('button', { name: 'Backup Server', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expectFirstTableContains(page, [resourceNames['proxmox-pbs']]);
await expectFirstTableExcludes(page, [resourceNames['proxmox-pve']]);
await page.goto('/settings/infrastructure/platforms/proxmox/pmg', {
waitUntil: 'domcontentloaded',
});
await page.waitForURL(/\/settings\/infrastructure\/platforms\/proxmox\/pmg/, {
timeout: 15_000,
});
await expect(page.getByRole('button', { name: 'Mail Gateway', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expectFirstTableContains(page, [resourceNames['proxmox-pmg']]);
await expectFirstTableExcludes(page, [resourceNames['proxmox-pve']]);
});
});