Files
sencho/frontend/src/components/EditorLayout/ViewRouter.tsx
T
Anso b65daf6845 feat: add notification suppression rules (#1525)
* feat: add notification suppression rules

* fix: restore label routing and routing test mocks for suppression

* fix: allow bell mute shortcuts for history-only notification categories

Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates.

* feat: expand Mute Rules UX with compose-first entry points and activity badges

* fix: add missing NodeContext mocks for notification suppression tests
2026-07-02 15:26:48 -04:00

257 lines
9.6 KiB
TypeScript

import { Suspense, lazy, type ReactNode } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { PaidGate } from '../PaidGate';
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 { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { SecurityTab, FleetTab } from '@/lib/events';
// Paid-tier views are loaded on demand. Their internal PaidGate /
// 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'));
const SecurityView = lazy(() =>
import('../SecurityView').then(m => ({ default: m.SecurityView })),
);
// 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;
muteRulePrefill: MuteRuleDraft | null;
onMutePrefillConsumed: () => void;
notifications: NotificationItem[];
onNavigateToStack: (stackFile: string) => void;
onOpenSettingsSection: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
onClearNotifications: () => void;
securityTab: SecurityTab;
onSecurityTabChange: (tab: SecurityTab) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
fleetTab?: FleetTab | null;
onFleetTabConsumed?: () => 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,
muteRulePrefill,
onMutePrefillConsumed,
notifications,
onNavigateToStack,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
onClearNotifications,
securityTab,
onSecurityTabChange,
fleetUpdatesIntent,
onFleetUpdatesIntentConsumed,
fleetTab,
onFleetTabConsumed,
renderEditor,
}: ViewRouterProps): ReactNode {
const { can } = useAuth();
if (activeView === 'settings') {
return (
<SettingsPage
currentSection={settingsSection}
onSectionChange={onSettingsSectionChange}
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
);
}
if (activeView === 'templates') {
return <AppStoreView onDeploySuccess={onTemplateDeploySuccess} />;
}
if (activeView === 'resources') {
return <ResourcesView />;
}
if (activeView === 'security') {
// Node-scoped (not hub-only): scan/scanner data follows the active node
// like Resources. The page itself is Community; per-tab gates handle
// capability-missing nodes and the local-control governance tabs.
return (
<LazyView>
<SecurityView activeTab={securityTab} onTabChange={onSecurityTabChange} />
</LazyView>
);
}
if (activeView === 'host-console') {
// Mirror the backend RBAC gate (system:console, admin-only). The nav
// item is already admin-gated; this stops a non-admin who reaches the
// view another way from mounting a console that the server will 403.
if (!can('system:console')) return null;
return (
<PaidGate>
<CapabilityGate capability="host-console" featureName="Host Console">
<LazyView>
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
</LazyView>
</CapabilityGate>
</PaidGate>
);
}
// 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}
onOpenSettingsSection={onOpenSettingsSection}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
onFleetTabConsumed={onFleetTabConsumed}
/>
</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}
/>
);
}