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
This commit is contained in:
Anso
2026-07-02 15:26:48 -04:00
committed by GitHub
parent bc111d28f3
commit b65daf6845
52 changed files with 2794 additions and 51 deletions
@@ -46,6 +46,7 @@ import { retryHandlerFor } from './recovery-retry';
import type { NotificationItem } from '../dashboard/types';
import type { Node } from '@/context/NodeContext';
import type { useAuth } from '@/context/AuthContext';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
export interface ContainerInfo {
Id: string;
@@ -214,6 +215,8 @@ export interface EditorViewProps {
// Mobile-only: notifications + more-menu cluster for the detail header right
// slot (the global TopBar is dropped on the full-screen detail surface).
headerActions?: React.ReactNode;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
export function EditorView(props: EditorViewProps) {
@@ -270,6 +273,7 @@ export function EditorView(props: EditorViewProps) {
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
@@ -376,6 +380,7 @@ export function EditorView(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
{recoveryResult && loadingAction == null && (
@@ -616,6 +621,7 @@ export function EditorView(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
stackMuteActions={stackMuteActions}
/>
)}
</div>
@@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) {
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
@@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={canEditStack}
notifications={notifications}
stackMuteActions={stackMuteActions}
/>
</div>
)}
@@ -12,6 +12,7 @@ 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';
@@ -81,9 +82,12 @@ export interface ViewRouterProps {
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;
@@ -110,9 +114,12 @@ export function ViewRouter({
onClearScheduledOpsFilter,
schedulePrefill,
onPrefillConsumed,
muteRulePrefill,
onMutePrefillConsumed,
notifications,
onNavigateToStack,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
onClearNotifications,
securityTab,
onSecurityTabChange,
@@ -128,6 +135,9 @@ export function ViewRouter({
<SettingsPage
currentSection={settingsSection}
onSectionChange={onSettingsSectionChange}
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
);
}
@@ -186,6 +196,7 @@ export function ViewRouter({
<FleetView
onNavigateToNode={onFleetNavigateToNode}
onOpenSettingsSection={onOpenSettingsSection}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
@@ -27,6 +27,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { StackMuteSubmenu } from '@/components/mute/MuteMenuItems';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
import { Sparkline } from '../ui/sparkline';
import { ImageSourceMenu } from '../ImageSourceMenu';
import { cn } from '@/lib/utils';
@@ -129,6 +131,7 @@ export interface StackIdentityHeaderProps {
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
requestDeleteStack: () => void;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
// Breadcrumb + serif title + state pill + image ref + action bar. The action
@@ -154,6 +157,7 @@ export function StackIdentityHeader({
rollbackStack,
scanStackConfig,
requestDeleteStack,
stackMuteActions,
}: StackIdentityHeaderProps) {
return (
<div className="flex flex-col gap-3">
@@ -228,8 +232,9 @@ export function StackIdentityHeader({
const canDelete = can('stack:delete', 'stack', stackName);
const canRollback = canDeploy && backupInfo.exists;
const canScan = trivy.available && isAdmin;
const canMute = stackMuteActions?.canMute ?? false;
const hasOverflowExtras = canRollback || canScan;
const hasOverflow = hasOverflowExtras || canDelete;
const hasOverflow = hasOverflowExtras || canDelete || canMute;
if (!canDeploy && !hasOverflow) return null;
return (
<div className="flex items-center gap-2 flex-wrap">
@@ -287,7 +292,8 @@ export function StackIdentityHeader({
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
{(canRollback || canScan || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
@@ -3,6 +3,10 @@ import { renderHook } from '@testing-library/react';
import { useSidebarContextMenu } from './useSidebarContextMenu';
import type { Node } from '@/context/NodeContext';
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ hasCapability: () => false }),
}));
// buildMenuCtx derives canOpenApp from the active node plus the stack's
// published port; only the fields it reads need to be real, the handler
// closures are never invoked here.
@@ -2,6 +2,15 @@ import { useCallback } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { buildServiceUrl } from '@/lib/serviceUrl';
import {
createMuteRuleWithToast,
stackMuteAllDraft,
stackMuteDeploySuccessDraft,
stackMuteMonitorDraft,
labelMuteAllDraft,
labelMuteExternalDraft,
labelMuteLowPriorityDraft,
} from '@/lib/muteRules';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
import type { Label as StackLabel, LabelColor } from '../../label-types';
import type { OverlayState } from './useOverlayState';
@@ -9,6 +18,7 @@ import type { StackActionsHook } from './useStackActions';
import type { useStackListState } from './useStackListState';
import type { useViewNavigationState } from './useViewNavigationState';
import type { Node } from '@/context/NodeContext';
import { useNodes } from '@/context/NodeContext';
import type { PermissionAction } from '@/context/AuthContext';
type StackListState = ReturnType<typeof useStackListState>;
@@ -33,6 +43,7 @@ export function useSidebarContextMenu({
isAdmin,
can,
}: UseSidebarContextMenuOptions) {
const { hasCapability } = useNodes();
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
const sName = file.replace(/\.(yml|yaml)$/, '');
const mainPort = stackListState.stackPorts[file];
@@ -40,6 +51,8 @@ export function useSidebarContextMenu({
// lifecycle affordances; the menu's status union stays three-state.
const rawStatus = stackListState.stackStatuses[file] ?? 'unknown';
const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus;
const nodeId = activeNode?.id ?? null;
const canMuteNotifications = isAdmin && hasCapability('notification-suppression');
return {
stackStatus,
// Only offer "Open App" when a browser-reachable URL can actually be built
@@ -125,6 +138,31 @@ export function useSidebarContextMenu({
navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null });
navState.setActiveView('scheduled-ops');
},
canMuteNotifications,
muteStackAll: () => {
void createMuteRuleWithToast(stackMuteAllDraft(sName, nodeId));
},
muteStackDeploySuccess: () => {
void createMuteRuleWithToast(stackMuteDeploySuccessDraft(sName, nodeId));
},
muteStackMonitor: () => {
void createMuteRuleWithToast(stackMuteMonitorDraft(sName, nodeId));
},
openStackMuteRules: () => {
navState.openMuteRulesWithPrefill(stackMuteAllDraft(sName, nodeId));
},
muteLabelAll: (labelId: number, labelName: string) => {
void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId));
},
muteLabelExternal: (labelId: number, labelName: string) => {
void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId));
},
muteLabelLowPriority: (labelId: number, labelName: string) => {
void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId));
},
openLabelMuteRules: (labelId: number, labelName: string) => {
navState.openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId));
},
};
// Handlers from useStackActions, useOverlayState, useViewNavigationState are
// useCallback-stabilized at their owner hooks, so listing the menu surface
@@ -134,7 +172,8 @@ export function useSidebarContextMenu({
}, [
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id,
hasCapability, navState.openMuteRulesWithPrefill,
]);
return buildMenuCtx;
@@ -12,6 +12,7 @@ import type { SenchoNavigateDetail } from '@/components/NodeManager';
import type { SecurityTab, FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
import type { MuteRuleDraft } from '@/lib/muteRules';
export type ActiveView =
| 'dashboard'
@@ -64,6 +65,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
const [muteRulePrefill, setMuteRulePrefill] = useState<MuteRuleDraft | null>(null);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const handleOpenSettings = useCallback((section?: SectionId) => {
@@ -73,6 +75,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
}, []);
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
const handleMutePrefillConsumed = useCallback(() => setMuteRulePrefill(null), []);
const openMuteRulesWithPrefill = useCallback((draft: MuteRuleDraft) => {
setMuteRulePrefill(draft);
setSettingsSection('notification-suppression');
setActiveView('settings');
setFilterNodeId(null);
}, []);
const handleNavigate = useCallback((value: string) => {
if (value === activeView) return;
@@ -166,9 +176,12 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
fleetTab, setFleetTab,
filterNodeId, setFilterNodeId,
schedulePrefill, setSchedulePrefill,
muteRulePrefill, setMuteRulePrefill,
mobileNavOpen, setMobileNavOpen,
handleOpenSettings,
handlePrefillConsumed,
handleMutePrefillConsumed,
openMuteRulesWithPrefill,
handleNavigate,
navItems,
} as const;