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

View file

@ -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

View file

@ -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

View file

@ -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<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 {
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) => {

View file

@ -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('<OrgSwitcher');
expect(appLayoutSource).toContain('const status = () => props.connectionStatus();');
expect(appLayoutSource).toContain(

View file

@ -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 = () => (
<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>
<Route
path="/settings/infrastructure"
component={() => (
<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>
)}
/>
<Route path="/settings/infrastructure" component={LayoutRoute} />
<Route path="/proxmox/overview" component={LayoutRoute} />
<Route path="/docker/overview" component={LayoutRoute} />
</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', () => {
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();