feat(nodes): hide hub-only views when active node is remote (#1007)

* feat(nodes): hide hub-only views when active node is remote

Fleet, Schedules, Audit, Logs, and Auto-Update operate on hub-owned state
(node registry, fleet schedules, centralized audit, fleet-wide log
aggregation, fleet-wide update preview). When the active node is remote,
proxying those surfaces would show that remote's own disconnected state
instead of the hub's. Hide them from the nav strip and force-redirect to
Home if one was open during the node switch.

Backend hubOnlyGuard middleware sits between nodeContextMiddleware and the
remote proxy and rejects /api/scheduled-tasks, /api/audit-log, and
/api/notification-routes with 403 + HUB_ONLY_ENDPOINT when nodeId resolves
to a remote, closing the script-bypass path the UI gating cannot reach.

Settings sub-sections were already gated via the hiddenOnRemote registry;
this extends the same model to top-level views.

* docs(nodes): note hub-only visibility on Fleet, Schedules, Audit, Logs, Auto-Update

Each of the five hub-only feature pages now points readers to the
canonical "What top-level views show when a remote node is active"
section in multi-node.mdx, so users landing directly on a feature page
understand why the nav item disappears when they switch to a remote node.
This commit is contained in:
Anso
2026-05-08 22:54:58 -04:00
committed by GitHub
parent 7ad9381ede
commit ccad5c925b
16 changed files with 448 additions and 49 deletions
@@ -2,6 +2,7 @@ import { Suspense, lazy, type ReactNode } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { AdmiralGate } from '../AdmiralGate';
import { CapabilityGate } from '../CapabilityGate';
import { HubOnlyGate } from '../HubOnlyGate';
import LazyBoundary from '../LazyBoundary';
import { SettingsPage } from '../settings/SettingsPage';
import type { SectionId } from '../settings/types';
@@ -10,6 +11,7 @@ import ResourcesView from '../ResourcesView';
import HomeDashboard from '../HomeDashboard';
import type { NotificationItem } from '../dashboard/types';
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
import type { ActiveView } from './hooks/useViewNavigationState';
// Paid-tier views and the security-history overlay are loaded on demand.
// Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render
@@ -59,18 +61,7 @@ function LazyView({ children }: { children: ReactNode }) {
);
}
export type ActiveView =
| 'dashboard'
| 'editor'
| 'host-console'
| 'resources'
| 'templates'
| 'global-observability'
| 'fleet'
| 'audit-log'
| 'scheduled-ops'
| 'auto-updates'
| 'settings';
export type { ActiveView };
export interface ViewRouterProps {
activeView: ActiveView;
@@ -148,50 +139,60 @@ export function ViewRouter({
}
if (activeView === 'global-observability') {
return (
<LazyView>
<GlobalObservabilityView />
</LazyView>
<HubOnlyGate>
<LazyView>
<GlobalObservabilityView />
</LazyView>
</HubOnlyGate>
);
}
if (activeView === 'fleet') {
return (
<CapabilityGate capability="fleet" featureName="Fleet Management">
<LazyView>
<FleetView onNavigateToNode={onFleetNavigateToNode} />
</LazyView>
</CapabilityGate>
<HubOnlyGate>
<CapabilityGate capability="fleet" featureName="Fleet Management">
<LazyView>
<FleetView onNavigateToNode={onFleetNavigateToNode} />
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
if (activeView === 'audit-log') {
return (
<CapabilityGate capability="audit-log" featureName="Audit Log">
<LazyView>
<AuditLogView />
</LazyView>
</CapabilityGate>
<HubOnlyGate>
<CapabilityGate capability="audit-log" featureName="Audit Log">
<LazyView>
<AuditLogView />
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
if (activeView === 'auto-updates') {
return (
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
<LazyView>
<AutoUpdateReadinessView />
</LazyView>
</CapabilityGate>
<HubOnlyGate>
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
<LazyView>
<AutoUpdateReadinessView />
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
if (activeView === 'scheduled-ops') {
return (
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
<LazyView>
<ScheduledOperationsView
filterNodeId={filterNodeId}
onClearFilter={onClearScheduledOpsFilter}
prefill={schedulePrefill}
onPrefillConsumed={onPrefillConsumed}
/>
</LazyView>
</CapabilityGate>
<HubOnlyGate>
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
<LazyView>
<ScheduledOperationsView
filterNodeId={filterNodeId}
onClearFilter={onClearScheduledOpsFilter}
prefill={schedulePrefill}
onPrefillConsumed={onPrefillConsumed}
/>
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
return (
@@ -2,11 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import * as AuthContext from '@/context/AuthContext';
import * as LicenseContext from '@/context/LicenseContext';
import * as NodeContext from '@/context/NodeContext';
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
import { useViewNavigationState } from '../hooks/useViewNavigationState';
vi.mock('@/context/AuthContext');
vi.mock('@/context/LicenseContext');
vi.mock('@/context/NodeContext');
function mockActiveNode(type: 'local' | 'remote' | null) {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: type === null ? null : { type, id: 1, name: 'n' },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
}
function mockCommunityUser() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
@@ -44,6 +52,7 @@ function mockSkipperAdmin() {
describe('useViewNavigationState', () => {
beforeEach(() => {
mockCommunityUser();
mockActiveNode('local');
});
// ── initial state ──────────────────────────────────────────────────────────
@@ -227,4 +236,85 @@ describe('useViewNavigationState', () => {
expect(values).not.toContain('audit-log');
expect(values).not.toContain('scheduled-ops');
});
// ── navItems: hub-only gating on remote node ───────────────────────────────
it('hides hub-only views from the nav strip when active node is remote', () => {
mockAdmiralAdmin();
mockActiveNode('remote');
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).not.toContain('fleet');
expect(values).not.toContain('scheduled-ops');
expect(values).not.toContain('audit-log');
expect(values).not.toContain('global-observability');
expect(values).not.toContain('auto-updates');
// Node-level views remain visible.
expect(values).toContain('dashboard');
expect(values).toContain('resources');
expect(values).toContain('templates');
expect(values).toContain('host-console');
});
it('shows hub-only views again when active node switches back to local', () => {
mockAdmiralAdmin();
mockActiveNode('remote');
const { result, rerender } = renderHook(() => useViewNavigationState());
expect(result.current.navItems.map(i => i.value)).not.toContain('fleet');
mockActiveNode('local');
rerender();
const values = result.current.navItems.map(i => i.value);
expect(values).toContain('fleet');
expect(values).toContain('scheduled-ops');
expect(values).toContain('audit-log');
});
// ── auto-redirect when on a hub-only view and node switches to remote ──────
it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => {
const onNavigateToDashboard = vi.fn();
mockAdmiralAdmin();
mockActiveNode('local');
const { result, rerender } = renderHook(() =>
useViewNavigationState({ onNavigateToDashboard }),
);
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 7 } }),
);
});
expect(result.current.activeView).toBe('fleet');
expect(result.current.filterNodeId).toBe(7);
mockActiveNode('remote');
rerender();
expect(result.current.activeView).toBe('dashboard');
expect(result.current.filterNodeId).toBeNull();
expect(onNavigateToDashboard).toHaveBeenCalledOnce();
});
it('does not redirect when a non-hub-only view is active and node becomes remote', () => {
const onNavigateToDashboard = vi.fn();
mockAdmiralAdmin();
mockActiveNode('local');
const { result, rerender } = renderHook(() =>
useViewNavigationState({ onNavigateToDashboard }),
);
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'resources' } }),
);
});
expect(result.current.activeView).toBe('resources');
mockActiveNode('remote');
rerender();
expect(result.current.activeView).toBe('resources');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,7 @@ import {
import type { LucideIcon } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
import type { SenchoNavigateDetail } from '@/components/NodeManager';
import type { SectionId } from '@/components/settings/types';
@@ -24,8 +25,22 @@ export type ActiveView =
| 'auto-updates'
| 'settings';
// Views that operate on hub-owned state (node registry, fleet schedules,
// centralized audit, fleet-wide log aggregation, fleet-wide update preview).
// Hidden from the nav strip and force-redirect to dashboard when the active
// node is remote, since proxying them would surface that remote's own
// disconnected state instead of the hub's. Settings sub-sections use the
// parallel `hiddenOnRemote` registry (see settings/registry.ts).
export const HUB_ONLY_VIEWS: ReadonlySet<ActiveView> = new Set([
'fleet',
'scheduled-ops',
'audit-log',
'global-observability',
'auto-updates',
]);
export interface NavItem {
value: string;
value: ActiveView;
label: string;
icon: LucideIcon;
}
@@ -38,6 +53,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const { onNavigateToDashboard } = options ?? {};
const { isAdmin, can } = useAuth();
const { isPaid, license } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
@@ -97,8 +114,18 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
return items;
}, [isAdmin, isPaid, license?.variant, can]);
return isRemote
? items.filter(i => !HUB_ONLY_VIEWS.has(i.value))
: items;
}, [isAdmin, isPaid, license?.variant, can, isRemote]);
useEffect(() => {
if (isRemote && HUB_ONLY_VIEWS.has(activeView)) {
onNavigateToDashboard?.();
setActiveView('dashboard');
setFilterNodeId(null);
}
}, [isRemote, activeView, onNavigateToDashboard]);
return {
activeView, setActiveView,