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.
This commit is contained in:
rcourtman
2026-06-28 17:46:00 +01:00
parent fe32dfffb8
commit d20c556db3
5 changed files with 183 additions and 48 deletions
@@ -3429,6 +3429,12 @@ Intelligence > Provider & Models route
route remains a compatibility alias for old deep links, not a href emitted by route remains a compatibility alias for old deep links, not a href emitted by
new Assistant provider-repair actions. 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 Rejected Patrol investigation-fix approvals are terminal governed-action
decisions in the AI runtime. `/api/ai/approvals/{id}/deny` must persist the 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 approval-store denial, record a rejected unified action-audit decision when the
@@ -826,6 +826,13 @@ or other self-hosted uncapped continuity plans.
existing Patrol findings and live-approval read model after authentication; existing Patrol findings and live-approval read model after authentication;
it must not read hosted billing state, trigger commercial-posture loading, it must not read hosted billing state, trigger commercial-posture loading,
affect organization visibility, or become an upgrade/acquisition cue. 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 The same AppLayout shell may contextualize the closed Pulse Assistant
launcher around the current monitoring, Patrol, Alerts, or Settings route, launcher around the current monitoring, Patrol, Alerts, or Settings route,
but that launcher must remain a local product affordance. It must not read but that launcher must remain a local product affordance. It must not read
+59 -5
View File
@@ -31,6 +31,12 @@ import { logger } from '@/utils/logger';
import { getActiveTabForPath } from '@/routing/navigation'; import { getActiveTabForPath } from '@/routing/navigation';
import { preloadRouteModule } from '@/routing/routePreload'; import { preloadRouteModule } from '@/routing/routePreload';
import { import {
DOCKER_PATH,
KUBERNETES_PATH,
PROXMOX_PATH,
STANDALONE_PATH,
TRUENAS_PATH,
VMWARE_PATH,
buildDockerPath, buildDockerPath,
buildKubernetesPath, buildKubernetesPath,
buildProxmoxPath, buildProxmoxPath,
@@ -56,12 +62,54 @@ const ROOT_VMWARE_PATH = buildVmwarePath();
const ROOT_STANDALONE_PATH = buildStandalonePath(); const ROOT_STANDALONE_PATH = buildStandalonePath();
const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath(); const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath();
const ROOT_ALERTS_PATH = '/alerts'; const ROOT_ALERTS_PATH = '/alerts';
const PRIMARY_ROUTE_PREFIX_BY_ID: Record<PrimaryPlatformNavId, string> = {
proxmox: PROXMOX_PATH,
docker: DOCKER_PATH,
kubernetes: KUBERNETES_PATH,
truenas: TRUENAS_PATH,
vmware: VMWARE_PATH,
standalone: STANDALONE_PATH,
};
type PrimaryRouteMemory = Partial<Record<PrimaryPlatformNavId, string>>;
let primaryRouteMemory: PrimaryRouteMemory = {};
function resolveStandaloneSubTabTitle(pathname: string): string { function resolveStandaloneSubTabTitle(pathname: string): string {
const normalized = pathname.replace(/\/+$/, ''); const normalized = pathname.replace(/\/+$/, '');
if (normalized === buildStandalonePath('availability')) return 'Availability checks'; if (normalized === buildStandalonePath('availability')) return 'Availability checks';
return 'Machines'; 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 NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
const AI_CHAT_LAUNCHER_BUTTON_CLASS = 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'; '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; 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(() => { createEffect(() => {
if (kioskMode()) { if (kioskMode()) {
setHeaderVisible(true); setHeaderVisible(true);
@@ -483,7 +540,7 @@ export function AppLayout(props: AppLayoutProps) {
}); });
const handlePrimaryClick = (tab: PrimaryTab) => { const handlePrimaryClick = (tab: PrimaryTab) => {
const targetRoute = tab.enabled ? tab.route : tab.settingsRoute; const targetRoute = resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
void (async () => { void (async () => {
try { try {
await preloadRouteModule(targetRoute); await preloadRouteModule(targetRoute);
@@ -521,10 +578,7 @@ export function AppLayout(props: AppLayoutProps) {
}; };
const getPrimaryTargetRoute = (tab: PrimaryTab) => { const getPrimaryTargetRoute = (tab: PrimaryTab) => {
if (tab.enabled) { return resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
return tab.route;
}
return tab.settingsRoute;
}; };
const renderPrimaryNavigationTab = (tab: PrimaryTab) => { const renderPrimaryNavigationTab = (tab: PrimaryTab) => {
@@ -237,6 +237,12 @@ describe('App architecture', () => {
expect(appLayoutSource).toContain('settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH'); expect(appLayoutSource).toContain('settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH');
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/workloads"); expect(appLayoutSource).not.toContain("settingsRoute: '/settings/workloads");
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/infrastructure/platforms"); 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('<OrgSwitcher'); expect(appLayoutSource).toContain('<OrgSwitcher');
expect(appLayoutSource).toContain('const status = () => props.connectionStatus();'); expect(appLayoutSource).toContain('const status = () => props.connectionStatus();');
expect(appLayoutSource).toContain( expect(appLayoutSource).toContain(
+105 -43
View File
@@ -3,10 +3,11 @@ import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { State } from '@/types/api'; import type { State } from '@/types/api';
import type { Resource } from '@/types/resource'; import type { Resource } from '@/types/resource';
import { AppLayout } from '@/AppLayout'; import { AppLayout, resetPrimaryNavigationRouteMemory } from '@/AppLayout';
import { aiChatStore } from '@/stores/aiChat'; import { aiChatStore } from '@/stores/aiChat';
HTMLElement.prototype.scrollIntoView = vi.fn(); HTMLElement.prototype.scrollIntoView = vi.fn();
window.scrollTo = vi.fn();
const aiIntelligenceMockState = vi.hoisted(() => ({ const aiIntelligenceMockState = vi.hoisted(() => ({
patrolOpenWorkCount: 0, patrolOpenWorkCount: 0,
@@ -20,9 +21,14 @@ vi.mock('@/stores/aiIntelligence', () => ({
}, },
})); }));
vi.mock('@/routing/routePreload', () => ({
preloadRouteModule: vi.fn(() => Promise.resolve()),
}));
describe('AppLayout navigation icons', () => { describe('AppLayout navigation icons', () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, '', '/settings/infrastructure'); window.history.replaceState({}, '', '/settings/infrastructure');
resetPrimaryNavigationRouteMemory();
aiIntelligenceMockState.patrolOpenWorkCount = 0; aiIntelligenceMockState.patrolOpenWorkCount = 0;
aiChatStore.close(); aiChatStore.close();
aiChatStore.setEnabled(true); aiChatStore.setEnabled(true);
@@ -48,51 +54,65 @@ describe('AppLayout navigation icons', () => {
...overrides, ...overrides,
}) as Resource; }) as Resource;
const renderLayout = (resources: Resource[] = []) => const renderLayout = (resources: Resource[] = [], initialPath = '/settings/infrastructure') => {
render(() => ( window.history.replaceState({}, '', initialPath);
const LayoutRoute = () => (
<AppLayout
connectionStatus={() => ({
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={() => {}}
>
<div>Infrastructure body</div>
</AppLayout>
);
return render(() => (
<Router> <Router>
<Route <Route path="/settings/infrastructure" component={LayoutRoute} />
path="/settings/infrastructure" <Route path="/proxmox/overview" component={LayoutRoute} />
component={() => ( <Route path="/docker/overview" component={LayoutRoute} />
<AppLayout
connectionStatus={() => ({
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={() => {}}
>
<div>Infrastructure body</div>
</AppLayout>
)}
/>
</Router> </Router>
)); ));
};
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', () => { it('renders fresh utility icons for both desktop and mobile navigation trees', () => {
const { container } = renderLayout(); const { container } = renderLayout();
@@ -193,6 +213,48 @@ describe('AppLayout navigation icons', () => {
).toBeTruthy(); ).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', () => { it('keeps connected brand motion on the logo while the wordmark stays static', () => {
const { container } = renderLayout(); const { container } = renderLayout();