From d20c556db3fbb2c204b7199194c44374718af5e3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 28 Jun 2026 17:46:00 +0100 Subject: [PATCH] Preserve platform tab route state Remember each primary platform tab's last route so URL-backed filters survive tab switches without leaking query state across tabs. --- .../v6/internal/subsystems/ai-runtime.md | 6 + .../v6/internal/subsystems/cloud-paid.md | 7 + frontend-modern/src/AppLayout.tsx | 64 +++++++- .../src/__tests__/App.architecture.test.ts | 6 + .../src/__tests__/AppLayout.test.tsx | 148 +++++++++++++----- 5 files changed, 183 insertions(+), 48 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 23384be50..41280b881 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3429,6 +3429,12 @@ Intelligence > Provider & Models route route remains a compatibility alias for old deep links, not a href emitted by new Assistant provider-repair actions. +Primary platform route memory in `frontend-modern/src/AppLayout.tsx` may +preserve platform-local query state such as workload filters, but that memory +remains navigation chrome only. It must not fork Assistant drawer state, Patrol +utility route state, prompt context, resource reads, commercial posture, or +cross-platform query parameters. + Rejected Patrol investigation-fix approvals are terminal governed-action decisions in the AI runtime. `/api/ai/approvals/{id}/deny` must persist the approval-store denial, record a rejected unified action-audit decision when the diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2cc75ec1a..412a1b1f0 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -826,6 +826,13 @@ or other self-hosted uncapped continuity plans. existing Patrol findings and live-approval read model after authentication; it must not read hosted billing state, trigger commercial-posture loading, affect organization visibility, or become an upgrade/acquisition cue. + The same primary platform navigation must remember the last in-tab route + state per platform, including query and hash, so route-owned filters such + as Proxmox workload status survive switching to another platform tab and + back. That memory is chrome route state only: it must validate the route + still belongs to the selected platform before reuse and must not become + hosted org bootstrap, entitlement, billing, acquisition, or cross-platform + query state. The same AppLayout shell may contextualize the closed Pulse Assistant launcher around the current monitoring, Patrol, Alerts, or Settings route, but that launcher must remain a local product affordance. It must not read diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index a6ed09917..8739edb15 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -31,6 +31,12 @@ import { logger } from '@/utils/logger'; import { getActiveTabForPath } from '@/routing/navigation'; import { preloadRouteModule } from '@/routing/routePreload'; import { + DOCKER_PATH, + KUBERNETES_PATH, + PROXMOX_PATH, + STANDALONE_PATH, + TRUENAS_PATH, + VMWARE_PATH, buildDockerPath, buildKubernetesPath, buildProxmoxPath, @@ -56,12 +62,54 @@ const ROOT_VMWARE_PATH = buildVmwarePath(); const ROOT_STANDALONE_PATH = buildStandalonePath(); const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath(); const ROOT_ALERTS_PATH = '/alerts'; +const PRIMARY_ROUTE_PREFIX_BY_ID: Record = { + proxmox: PROXMOX_PATH, + docker: DOCKER_PATH, + kubernetes: KUBERNETES_PATH, + truenas: TRUENAS_PATH, + vmware: VMWARE_PATH, + standalone: STANDALONE_PATH, +}; +type PrimaryRouteMemory = Partial>; +let primaryRouteMemory: PrimaryRouteMemory = {}; function resolveStandaloneSubTabTitle(pathname: string): string { const normalized = pathname.replace(/\/+$/, ''); if (normalized === buildStandalonePath('availability')) return 'Availability checks'; return 'Machines'; } + +function routeBelongsToPrimaryTab(route: string, tabId: PrimaryPlatformNavId): boolean { + const prefix = PRIMARY_ROUTE_PREFIX_BY_ID[tabId]; + const pathname = route.split(/[?#]/, 1)[0]?.replace(/\/+$/, '') || '/'; + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +function isPrimaryPlatformNavId(tabId: string | null | undefined): tabId is PrimaryPlatformNavId { + return Boolean(tabId && tabId in PRIMARY_ROUTE_PREFIX_BY_ID); +} + +function currentPrimaryRoute(pathname: string, search: string, hash: string): string { + return `${pathname}${search}${hash}`; +} + +function resolvePrimaryNavigationRoute( + tab: PrimaryTab, + routeMemory: PrimaryRouteMemory, +): string { + if (!tab.enabled) { + return tab.settingsRoute; + } + const remembered = routeMemory[tab.id as PrimaryPlatformNavId]; + if (remembered && routeBelongsToPrimaryTab(remembered, tab.id as PrimaryPlatformNavId)) { + return remembered; + } + return tab.route; +} + +export function resetPrimaryNavigationRouteMemory() { + primaryRouteMemory = {}; +} const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0'; const AI_CHAT_LAUNCHER_BUTTON_CLASS = 'fixed right-4 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface text-blue-600 shadow-lg transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300 lg:right-0 lg:top-1/2 lg:bottom-auto lg:h-auto lg:w-auto lg:min-h-9 lg:min-w-10 lg:-translate-y-1/2 lg:rounded-l-lg lg:rounded-r-none lg:border-r-0 lg:px-2.5 lg:py-2.5 lg:shadow-none'; @@ -258,6 +306,15 @@ export function AppLayout(props: AppLayoutProps) { return navId ? primaryInfrastructureRouteById[navId] : ROOT_ALERTS_PATH; }); + createEffect(() => { + const activeTab = getActiveTabForPath(location.pathname); + if (!isPrimaryPlatformNavId(activeTab)) return; + const route = currentPrimaryRoute(location.pathname, location.search, location.hash); + if (!routeBelongsToPrimaryTab(route, activeTab)) return; + if (primaryRouteMemory[activeTab] === route) return; + primaryRouteMemory = { ...primaryRouteMemory, [activeTab]: route }; + }); + createEffect(() => { if (kioskMode()) { setHeaderVisible(true); @@ -483,7 +540,7 @@ export function AppLayout(props: AppLayoutProps) { }); const handlePrimaryClick = (tab: PrimaryTab) => { - const targetRoute = tab.enabled ? tab.route : tab.settingsRoute; + const targetRoute = resolvePrimaryNavigationRoute(tab, primaryRouteMemory); void (async () => { try { await preloadRouteModule(targetRoute); @@ -521,10 +578,7 @@ export function AppLayout(props: AppLayoutProps) { }; const getPrimaryTargetRoute = (tab: PrimaryTab) => { - if (tab.enabled) { - return tab.route; - } - return tab.settingsRoute; + return resolvePrimaryNavigationRoute(tab, primaryRouteMemory); }; const renderPrimaryNavigationTab = (tab: PrimaryTab) => { diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 01a44d34d..be1091fe0 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -237,6 +237,12 @@ describe('App architecture', () => { expect(appLayoutSource).toContain('settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH'); expect(appLayoutSource).not.toContain("settingsRoute: '/settings/workloads"); expect(appLayoutSource).not.toContain("settingsRoute: '/settings/infrastructure/platforms"); + expect(appLayoutSource).toContain('type PrimaryRouteMemory = Partial'); + expect(appLayoutSource).toContain('let primaryRouteMemory: PrimaryRouteMemory = {};'); + expect(appLayoutSource).toContain('function resolvePrimaryNavigationRoute('); + expect(appLayoutSource).toContain('routeBelongsToPrimaryTab(remembered'); + expect(appLayoutSource).not.toContain('primaryRouteMemory[props.activeOrgID'); + expect(appLayoutSource).not.toContain('primaryRouteMemory[activeOrgID'); expect(appLayoutSource).toContain(' props.connectionStatus();'); expect(appLayoutSource).toContain( diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 4b33f8428..99134ec0b 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -3,10 +3,11 @@ import { Route, Router } from '@solidjs/router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { State } from '@/types/api'; import type { Resource } from '@/types/resource'; -import { AppLayout } from '@/AppLayout'; +import { AppLayout, resetPrimaryNavigationRouteMemory } from '@/AppLayout'; import { aiChatStore } from '@/stores/aiChat'; HTMLElement.prototype.scrollIntoView = vi.fn(); +window.scrollTo = vi.fn(); const aiIntelligenceMockState = vi.hoisted(() => ({ patrolOpenWorkCount: 0, @@ -20,9 +21,14 @@ vi.mock('@/stores/aiIntelligence', () => ({ }, })); +vi.mock('@/routing/routePreload', () => ({ + preloadRouteModule: vi.fn(() => Promise.resolve()), +})); + describe('AppLayout navigation icons', () => { beforeEach(() => { window.history.replaceState({}, '', '/settings/infrastructure'); + resetPrimaryNavigationRouteMemory(); aiIntelligenceMockState.patrolOpenWorkCount = 0; aiChatStore.close(); aiChatStore.setEnabled(true); @@ -48,51 +54,65 @@ describe('AppLayout navigation icons', () => { ...overrides, }) as Resource; - const renderLayout = (resources: Resource[] = []) => - render(() => ( + const renderLayout = (resources: Resource[] = [], initialPath = '/settings/infrastructure') => { + window.history.replaceState({}, '', initialPath); + const LayoutRoute = () => ( + ({ + kind: 'connected', + label: 'Connected', + detail: 'Backend and live data stream are connected.', + tone: 'healthy', + })} + lastUpdateText={() => ''} + versionInfo={() => + ({ + version: '6.0.0-rc.2', + channel: 'rc', + isDevelopment: false, + isDocker: false, + }) as never + } + hasAuth={() => true} + needsAuth={() => false} + proxyAuthInfo={() => null} + handleLogout={() => {}} + state={() => + ({ + activeAlerts: [{ id: 'alert-1', level: 'warning', acknowledged: false }], + resources, + }) as unknown as State + } + tokenScopes={() => ['settings:read']} + organizations={() => []} + activeOrgID={() => 'default'} + orgsLoading={() => false} + showOrgSwitcher={() => false} + onSwitchOrg={() => {}} + > +
Infrastructure body
+
+ ); + return render(() => ( - ( - ({ - kind: 'connected', - label: 'Connected', - detail: 'Backend and live data stream are connected.', - tone: 'healthy', - })} - lastUpdateText={() => ''} - versionInfo={() => - ({ - version: '6.0.0-rc.2', - channel: 'rc', - isDevelopment: false, - isDocker: false, - }) as never - } - hasAuth={() => true} - needsAuth={() => false} - proxyAuthInfo={() => null} - handleLogout={() => {}} - state={() => - ({ - activeAlerts: [{ id: 'alert-1', level: 'warning', acknowledged: false }], - resources, - }) as unknown as State - } - tokenScopes={() => ['settings:read']} - organizations={() => []} - activeOrgID={() => 'default'} - orgsLoading={() => false} - showOrgSwitcher={() => false} - onSwitchOrg={() => {}} - > -
Infrastructure body
-
- )} - /> + + +
)); + }; + + const getInfrastructureTab = (name: string) => { + const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' }); + const infrastructureGroup = desktopNav.querySelector('[aria-label="Infrastructure"]'); + expect(infrastructureGroup).toBeTruthy(); + return within(infrastructureGroup as HTMLElement).getByRole('tab', { name }); + }; + + const platformResources = () => [ + makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }), + makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' }), + ]; it('renders fresh utility icons for both desktop and mobile navigation trees', () => { const { container } = renderLayout(); @@ -193,6 +213,48 @@ describe('AppLayout navigation icons', () => { ).toBeTruthy(); }); + it('restores the previous Proxmox route state when returning from another platform tab', async () => { + renderLayout(platformResources(), '/proxmox/overview?status=running'); + + await fireEvent.click(getInfrastructureTab('Docker')); + await waitFor(() => { + expect(window.location.pathname).toBe('/docker/overview'); + expect(window.location.search).toBe(''); + }); + + await fireEvent.click(getInfrastructureTab('Proxmox')); + await waitFor(() => { + expect(window.location.pathname).toBe('/proxmox/overview'); + expect(window.location.search).toBe('?status=running'); + }); + }); + + it('keeps remembered route state scoped to the platform tab that owns it', async () => { + renderLayout(platformResources(), '/docker/overview?host=docker-1'); + + await fireEvent.click(getInfrastructureTab('Proxmox')); + await waitFor(() => { + expect(window.location.pathname).toBe('/proxmox/overview'); + expect(window.location.search).toBe(''); + }); + + await fireEvent.click(getInfrastructureTab('Docker')); + await waitFor(() => { + expect(window.location.pathname).toBe('/docker/overview'); + expect(window.location.search).toBe('?host=docker-1'); + }); + }); + + it('uses the canonical platform root route when there is no remembered route state', async () => { + renderLayout(platformResources(), '/settings/infrastructure'); + + await fireEvent.click(getInfrastructureTab('Proxmox')); + await waitFor(() => { + expect(window.location.pathname).toBe('/proxmox/overview'); + expect(window.location.search).toBe(''); + }); + }); + it('keeps connected brand motion on the logo while the wordmark stays static', () => { const { container } = renderLayout();