Files
sencho/frontend/src/components/EditorLayout/ViewRouter.tsx
T
Anso ccad5c925b 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.
2026-05-08 22:54:58 -04:00

207 lines
7.5 KiB
TypeScript

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';
import { AppStoreView } from '../AppStoreView';
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
// the upsell or capability-missing card with blurred children rather than
// short-circuiting, so a tier-locked or capability-missing operator
// opening one of these tabs still triggers the chunk fetch to render the
// blurred preview. What this lazy split closes is the much larger
// initial-bundle leak: every Community user used to download the full
// FleetView, AuditLogView, etc. on first page load even if they never
// clicked those tabs. After this change, the chunks fetch only on tab
// open.
//
// GlobalObservabilityView is a free-tier feature with no internal gate;
// it is split here purely for the bundle-size win, not for IP protection.
const HostConsole = lazy(() => import('../HostConsole'));
const GlobalObservabilityView = lazy(() =>
import('../GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })),
);
const FleetView = lazy(() =>
import('../FleetView').then(m => ({ default: m.FleetView })),
);
const AuditLogView = lazy(() =>
import('../AuditLogView').then(m => ({ default: m.AuditLogView })),
);
const ScheduledOperationsView = lazy(() => import('../ScheduledOperationsView'));
const AutoUpdateReadinessView = lazy(() => import('../AutoUpdateReadinessView'));
// Sized for the main workspace area (flex-1 with p-6 padding). Visible
// only during the brief window between an unlocked view's chunk request
// and its first render.
function ViewSkeleton() {
return (
<div className="flex flex-col gap-6" aria-busy="true">
<Skeleton className="h-10 w-1/3 rounded-md" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
);
}
function LazyView({ children }: { children: ReactNode }) {
return (
<LazyBoundary>
<Suspense fallback={<ViewSkeleton />}>
{children}
</Suspense>
</LazyBoundary>
);
}
export type { ActiveView };
export interface ViewRouterProps {
activeView: ActiveView;
selectedFile: string | null;
isLoading: boolean;
settingsSection: SectionId;
onSettingsSectionChange: (section: SectionId) => void;
onTemplateDeploySuccess: (stackName: string) => void;
onHostConsoleClose: () => void;
onFleetNavigateToNode: (nodeId: number, stackName: string) => void;
filterNodeId: number | null;
onClearScheduledOpsFilter: () => void;
schedulePrefill: ScheduleTaskPrefill | null;
onPrefillConsumed: () => void;
notifications: NotificationItem[];
onNavigateToStack: (stackFile: string) => void;
onOpenSettingsSection: (section: SectionId) => void;
onClearNotifications: () => void;
// Render slot for the inline editor view. Kept as a callback so the
// (large) editor JSX is only allocated when activeView === 'editor',
// not on every parent render that lands on a different view.
renderEditor: () => ReactNode;
}
export function ViewRouter({
activeView,
selectedFile,
isLoading,
settingsSection,
onSettingsSectionChange,
onTemplateDeploySuccess,
onHostConsoleClose,
onFleetNavigateToNode,
filterNodeId,
onClearScheduledOpsFilter,
schedulePrefill,
onPrefillConsumed,
notifications,
onNavigateToStack,
onOpenSettingsSection,
onClearNotifications,
renderEditor,
}: ViewRouterProps): ReactNode {
if (activeView === 'settings') {
return (
<SettingsPage
currentSection={settingsSection}
onSectionChange={onSettingsSectionChange}
/>
);
}
if (activeView === 'templates') {
return <AppStoreView onDeploySuccess={onTemplateDeploySuccess} />;
}
if (activeView === 'resources') {
return <ResourcesView />;
}
if (activeView === 'host-console') {
return (
<AdmiralGate>
<CapabilityGate capability="host-console" featureName="Host Console">
<LazyView>
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
</LazyView>
</CapabilityGate>
</AdmiralGate>
);
}
// Fall-through: when activeView === 'editor' but selectedFile is
// null or the stack is still loading, drop through to the default
// HomeDashboard render below. This matches the pre-extraction
// behavior of the conditional ternary chain in EditorLayout.tsx.
if (!isLoading && selectedFile && activeView === 'editor') {
return renderEditor();
}
if (activeView === 'global-observability') {
return (
<HubOnlyGate>
<LazyView>
<GlobalObservabilityView />
</LazyView>
</HubOnlyGate>
);
}
if (activeView === 'fleet') {
return (
<HubOnlyGate>
<CapabilityGate capability="fleet" featureName="Fleet Management">
<LazyView>
<FleetView onNavigateToNode={onFleetNavigateToNode} />
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
if (activeView === 'audit-log') {
return (
<HubOnlyGate>
<CapabilityGate capability="audit-log" featureName="Audit Log">
<LazyView>
<AuditLogView />
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
if (activeView === 'auto-updates') {
return (
<HubOnlyGate>
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
<LazyView>
<AutoUpdateReadinessView />
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
if (activeView === 'scheduled-ops') {
return (
<HubOnlyGate>
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
<LazyView>
<ScheduledOperationsView
filterNodeId={filterNodeId}
onClearFilter={onClearScheduledOpsFilter}
prefill={schedulePrefill}
onPrefillConsumed={onPrefillConsumed}
/>
</LazyView>
</CapabilityGate>
</HubOnlyGate>
);
}
return (
<HomeDashboard
onNavigateToStack={onNavigateToStack}
onOpenSettingsSection={onOpenSettingsSection}
notifications={notifications}
onClearNotifications={onClearNotifications}
/>
);
}