mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
b65daf6845
* 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
257 lines
9.6 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|