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
+11
View File
@@ -40,6 +40,7 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
import { toast } from '@/components/ui/toast-store';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { MobileTabBar } from './MobileTabBar';
@@ -179,11 +180,14 @@ export default function EditorLayout() {
fleetTab, setFleetTab,
filterNodeId, setFilterNodeId,
schedulePrefill,
muteRulePrefill,
mobileNavOpen, setMobileNavOpen,
handleOpenSettings,
handlePrefillConsumed,
handleMutePrefillConsumed,
handleNavigate,
navItems,
openMuteRulesWithPrefill,
} = navState;
const {
@@ -284,6 +288,8 @@ export default function EditorLayout() {
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
const stackName = selectedFile || '';
const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : '';
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
const { isDarkMode } = useTheme();
@@ -500,6 +506,7 @@ export default function EditorLayout() {
onMobileBack={goToMobileList}
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
hasUnsavedChanges={stackActions.hasUnsavedChanges}
stackMuteActions={selectedFile ? stackMuteActions : undefined}
/>
);
@@ -680,6 +687,7 @@ export default function EditorLayout() {
},
filterChip,
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
openMuteRulesWithPrefill,
}}
activitySummary={activitySummary}
onActivityAction={handleActivityAction}
@@ -755,9 +763,12 @@ export default function EditorLayout() {
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
schedulePrefill={schedulePrefill}
onPrefillConsumed={handlePrefillConsumed}
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={handleMutePrefillConsumed}
notifications={notifications}
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
onOpenSettingsSection={(section) => openSettings(section)}
onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill}
onClearNotifications={clearAllNotifications}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
@@ -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;
+4 -1
View File
@@ -32,11 +32,13 @@ import { DependencyMapTab } from './fleet/DependencyMapTab';
import { useNodeActions } from './nodes/useNodeActions';
import type { FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { MuteRuleDraft } from '@/lib/muteRules';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
onOpenSettingsSection?: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
@@ -44,7 +46,7 @@ interface FleetViewProps {
onFleetTabConsumed?: () => void;
}
export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
@@ -221,6 +223,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
onCordonChange={() => { void overview.fetchOverview(true); }}
onEditNode={isAdmin ? openEdit : undefined}
onDeleteNode={isAdmin ? openDelete : undefined}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
onCheckUpdates={updateStatus.checkUpdates}
checkingUpdates={updateStatus.checkingUpdates}
+12 -2
View File
@@ -14,6 +14,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { NodeMuteSubmenu } from '@/components/mute/MuteMenuItems';
import { useNodeMuteActions } from '@/hooks/useMuteRuleActions';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { formatBytes } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -42,6 +45,7 @@ export interface NodeCardProps {
onCordonChange?: () => void;
onEdit?: (node: Node) => void;
onDelete?: (node: Node) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
}
// --- Sub-Components ---
@@ -59,7 +63,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
// --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) {
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
@@ -77,7 +81,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
const showMenu = canEdit || canDelete || canCordon;
const nodeMuteActions = useNodeMuteActions(
node.id,
node.name,
onOpenMuteRulesWithPrefill ?? (() => {}),
);
const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill));
const isOnline = node.status === 'online';
const isLocal = node.type === 'local';
@@ -182,6 +191,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
</DropdownMenuItem>
)}
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -8,6 +8,7 @@ import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-
import type { Label as StackLabel } from '../label-types';
import type { Node } from '@/context/NodeContext';
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
import type { MuteRuleDraft } from '@/lib/muteRules';
interface OverviewTabProps {
loading: boolean;
@@ -35,6 +36,7 @@ interface OverviewTabProps {
onCordonChange?: () => void;
onEditNode?: (node: Node) => void;
onDeleteNode?: (node: Node) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
topologyMode: LayoutMode;
onTopologyModeChange: (mode: LayoutMode) => void;
topologyPositions: SavedPositions;
@@ -70,6 +72,7 @@ export function OverviewTab({
onCordonChange,
onEditNode,
onDeleteNode,
onOpenMuteRulesWithPrefill,
topologyMode,
onTopologyModeChange,
topologyPositions,
@@ -149,6 +152,7 @@ export function OverviewTab({
onCordonChange={onCordonChange}
onEdit={onEditNode}
onDelete={onDeleteNode}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
))}
</div>
@@ -34,7 +34,7 @@ function baseProps(node: FleetNode) {
}
beforeEach(() => {
useNodesMock.mockReturnValue({ nodes: [] });
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
useLicenseMock.mockReturnValue({ isPaid: false });
});
+64 -12
View File
@@ -9,6 +9,7 @@ import {
Trash2,
SlidersHorizontal,
CheckCheck,
MoreHorizontal,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -22,10 +23,18 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { NotificationCategory, NotificationItem } from './dashboard/types';
import type { Node } from '@/context/NodeContext';
import { createMuteFromNotification } from '@/lib/muteRules';
import { useAuth } from '@/context/AuthContext';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
import { countVisibleUnread, filterPanelVisible } from '@/lib/notificationVisibility';
import type { NotificationCategory, NotificationItem } from './dashboard/types';
import type { Node } from '@/context/NodeContext';
const NODE_FILTER_ALL = 'all' as const;
const CATEGORY_FILTER_ALL = 'all' as const;
@@ -128,6 +137,7 @@ export function NotificationPanel({
onNavigate,
onNavigateChangelog,
}: NotificationPanelProps) {
const { isAdmin } = useAuth();
const [filter, setFilter] = useState<NotifFilter>('all');
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
@@ -369,6 +379,7 @@ export function NotificationPanel({
onDelete={onDelete}
onNavigate={onNavigate ? handleNavigate : undefined}
onNavigateChangelog={onNavigateChangelog}
canMute={isAdmin}
/>
))}
</div>
@@ -386,9 +397,17 @@ interface NotificationRowProps {
onDelete: (notif: NotificationItem) => void;
onNavigate?: (notif: NotificationItem) => void;
onNavigateChangelog?: (notif: NotificationItem) => void;
canMute?: boolean;
}
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) {
async function createQuickSuppressionRule(
notif: NotificationItem,
mode: 'category' | 'similar' | 'stack',
): Promise<void> {
await createMuteFromNotification(notif, mode);
}
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog, canMute }: NotificationRowProps) {
const config = LEVEL_CONFIG[notif.level];
const Icon = config.icon;
const isUnread = !notif.is_read;
@@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate
) : (
<div className={surfaceClasses}>{content}</div>
)}
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-2 z-20 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => onDelete(notif)}
title="Dismiss"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</Button>
<div className="absolute right-2 top-2 z-20 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{canMute ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Mute options"
aria-label="Mute options"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{notif.category ? (
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'category')}>
Mute this category
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'similar')}>
Mute notifications like this
</DropdownMenuItem>
{notif.stack_name ? (
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'stack')}>
Mute this stack
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onDelete(notif)}
title="Dismiss"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
@@ -18,6 +18,8 @@ import EnvironmentPanel from './stack/EnvironmentPanel';
import StackNetworkingPanel from './stack/StackNetworkingPanel';
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from '@/components/dashboard/types';
import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
interface StackAnatomyPanelProps {
stackName: string;
@@ -32,6 +34,7 @@ interface StackAnatomyPanelProps {
canEdit: boolean;
applying?: boolean;
notifications?: NotificationItem[];
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
@@ -82,6 +85,7 @@ export default function StackAnatomyPanel({
canEdit,
applying = false,
notifications,
stackMuteActions,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
@@ -409,6 +413,7 @@ export default function StackAnatomyPanel({
edit
</button>
)}
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
</div>
</div>
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
@@ -54,6 +54,11 @@ export type NotificationCategory =
| 'autoheal_triggered'
| 'monitor_alert'
| 'scan_finding'
| 'drift_detected'
| 'drift_resolved'
| 'update_started'
| 'health_gate_passed'
| 'health_gate_failed'
| 'node_update_available'
| 'system';
@@ -0,0 +1,128 @@
import { BellOff, MoreVertical } from 'lucide-react';
import type { MouseEvent } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { useStackMuteActions, useLabelMuteActions, useNodeMuteActions } from '@/hooks/useMuteRuleActions';
type StackMuteActions = ReturnType<typeof useStackMuteActions>;
type LabelMuteActions = ReturnType<typeof useLabelMuteActions>;
type NodeMuteActions = ReturnType<typeof useNodeMuteActions>;
export function StackMuteSubmenu({ actions }: { actions: StackMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
Mute
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function LabelMuteSubmenu({ actions }: { actions: LabelMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
Mute
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function NodeMuteSubmenu({ actions }: { actions: NodeMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
Mute
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={actions.muteAll}>Mute node notifications</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteUpdates}>Mute update notifications for this node</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts on this node</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage node mute rules</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function LabelGroupMuteKebab({
actions,
}: {
actions: LabelMuteActions;
}) {
if (!actions.canMute) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Label mute actions"
className="inline-flex items-center justify-center w-5 h-5 rounded text-stat-icon hover:text-foreground hover:bg-glass-highlight/40 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="w-3 h-3" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" onClick={(e: MouseEvent) => e.stopPropagation()}>
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ActivityMuteKebab({ actions }: { actions: StackMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Mute stack notifications"
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<BellOff className="h-3 w-3" strokeWidth={1.5} />
mute
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { Plus, Pencil, Trash2, BellOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
@@ -13,13 +13,17 @@ import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { labelMuteAllDraft, type MuteRuleDraft } from '@/lib/muteRules';
import { useCanMuteNotifications } from '@/hooks/useMuteRuleActions';
interface LabelsSectionProps {
onLabelsChanged?: () => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
}
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
const { can } = useAuth();
export function LabelsSection({ onLabelsChanged, onOpenMuteRulesWithPrefill }: LabelsSectionProps = {}) {
const { can, isAdmin } = useAuth();
const canMute = useCanMuteNotifications();
// Mirrors the backend `requirePermission('stack:edit')` guard on
// POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from
// clicking through to a guaranteed 403.
@@ -161,6 +165,17 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
<span className="text-xs text-muted-foreground tabular-nums">
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
</span>
{canMute && onOpenMuteRulesWithPrefill && isAdmin && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
title="Mute notifications for this label"
onClick={() => onOpenMuteRulesWithPrefill(labelMuteAllDraft(label.id, label.name))}
>
<BellOff className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
{canMutate && (
<>
<Button
@@ -0,0 +1,573 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import type { ComboboxOption } from '@/components/ui/combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { CapabilityGate } from '@/components/CapabilityGate';
import type { NotificationCategory } from '@/components/dashboard/types';
import type { Label as StackLabel } from '@/components/label-types';
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
import { emitMuteRulesChanged, type MuteRuleDraft } from '@/lib/muteRules';
import { useMuteRulesRefresh } from '@/hooks/useMuteRulesRefresh';
import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
type NotificationLevel = 'info' | 'warning' | 'error';
type AppliesTo = 'bell' | 'external' | 'both';
type ExpirationPreset = 'forever' | '1h' | '24h' | 'custom';
interface NotificationSuppressionRule {
id: number;
name: string;
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: NotificationCategory[] | null;
levels: NotificationLevel[] | null;
applies_to: AppliesTo;
enabled: boolean;
expires_at: number | null;
created_at: number;
updated_at: number;
}
const LEVEL_LABELS: Record<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
error: 'Error',
};
const APPLIES_TO_LABELS: Record<AppliesTo, string> = {
bell: 'Bell only',
external: 'External only',
both: 'Bell and external',
};
function expirationFromPreset(preset: ExpirationPreset, customMs: number | null): number | null {
if (preset === 'forever') return null;
if (preset === '1h') return Date.now() + 3_600_000;
if (preset === '24h') return Date.now() + 86_400_000;
return customMs;
}
function presetFromExpiresAt(expires_at: number | null): { preset: ExpirationPreset; customMs: number | null } {
if (expires_at == null) return { preset: 'forever', customMs: null };
return { preset: 'custom', customMs: expires_at };
}
function formatExpiry(expires_at: number | null): string {
if (expires_at == null) return 'Never';
if (expires_at <= Date.now()) return 'Expired';
return new Date(expires_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function applyDraftToForm(
draft: MuteRuleDraft,
setters: {
setFormName: (v: string) => void;
setFormNodeId: (v: number | null) => void;
setFormStacks: (v: string[]) => void;
setFormLabelIds: (v: number[]) => void;
setFormCategories: (v: NotificationCategory[]) => void;
setFormLevels: (v: NotificationLevel[]) => void;
setFormAppliesTo: (v: AppliesTo) => void;
setFormEnabled: (v: boolean) => void;
},
) {
setters.setFormName(draft.name);
setters.setFormNodeId(draft.node_id ?? null);
setters.setFormStacks(draft.stack_patterns ?? []);
setters.setFormLabelIds(draft.label_ids ?? []);
setters.setFormCategories(draft.categories ?? []);
setters.setFormLevels(draft.levels ?? []);
setters.setFormAppliesTo(draft.applies_to ?? 'both');
setters.setFormEnabled(draft.enabled ?? true);
}
interface NotificationSuppressionSectionProps {
prefill?: MuteRuleDraft | null;
onPrefillConsumed?: () => void;
}
export function NotificationSuppressionSection({
prefill = null,
onPrefillConsumed,
}: NotificationSuppressionSectionProps) {
const { nodes } = useNodes();
const [rules, setRules] = useState<NotificationSuppressionRule[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [deleteRuleId, setDeleteRuleId] = useState<number | null>(null);
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
const [formName, setFormName] = useState('');
const [formNodeId, setFormNodeId] = useState<number | null>(null);
const [formStacks, setFormStacks] = useState<string[]>([]);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
const [formAppliesTo, setFormAppliesTo] = useState<AppliesTo>('both');
const [formEnabled, setFormEnabled] = useState(true);
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
const [formCustomExpiry, setFormCustomExpiry] = useState('');
const fetchRules = useCallback(async () => {
try {
const res = await apiFetch('/notification-suppression-rules');
if (res.ok) setRules(await res.json());
} catch {
toast.error('Failed to load mute rules.');
} finally {
setLoading(false);
}
}, []);
const fetchStacks = useCallback(async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) {
const data: string[] = await res.json();
setStackOptions(data.map((s) => ({ value: s, label: s })));
}
} catch { /* non-critical */ }
}, []);
const fetchLabels = useCallback(async () => {
try {
const res = await apiFetch('/labels');
if (res.ok) setLabelOptions(await res.json());
} catch { /* non-critical */ }
}, []);
useEffect(() => {
void Promise.all([fetchRules(), fetchStacks(), fetchLabels()]);
}, [fetchRules, fetchStacks, fetchLabels]);
useMuteRulesRefresh(fetchRules);
useEffect(() => {
if (!prefill) return;
applyDraftToForm(prefill, {
setFormName,
setFormNodeId,
setFormStacks,
setFormLabelIds,
setFormCategories,
setFormLevels,
setFormAppliesTo,
setFormEnabled,
});
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setEditingId(null);
setShowForm(true);
onPrefillConsumed?.();
}, [prefill, onPrefillConsumed]);
const resetForm = () => {
setFormName('');
setFormNodeId(null);
setFormStacks([]);
setFormLabelIds([]);
setFormCategories([]);
setFormLevels([]);
setFormAppliesTo('both');
setFormEnabled(true);
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setEditingId(null);
setShowForm(false);
};
const startEdit = (rule: NotificationSuppressionRule) => {
const { preset, customMs } = presetFromExpiresAt(rule.expires_at);
setEditingId(rule.id);
setFormName(rule.name);
setFormNodeId(rule.node_id);
setFormStacks([...rule.stack_patterns]);
setFormLabelIds(rule.label_ids ? [...rule.label_ids] : []);
setFormCategories(rule.categories ? [...rule.categories] : []);
setFormLevels(rule.levels ? [...rule.levels] : []);
setFormAppliesTo(rule.applies_to);
setFormEnabled(rule.enabled);
setFormExpirationPreset(preset);
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
setShowForm(true);
};
const handleSave = async () => {
if (!formName.trim()) { toast.error('Name is required.'); return; }
const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null;
if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) {
toast.error('Choose a valid custom expiration date.');
return;
}
setSaving(true);
try {
const body = {
name: formName.trim(),
node_id: formNodeId,
stack_patterns: formStacks,
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
categories: formCategories.length > 0 ? formCategories : null,
levels: formLevels.length > 0 ? formLevels : null,
applies_to: formAppliesTo,
enabled: formEnabled,
expires_at: expirationFromPreset(formExpirationPreset, customMs),
};
const url = editingId
? `/notification-suppression-rules/${editingId}`
: '/notification-suppression-rules';
const res = await apiFetch(url, {
method: editingId ? 'PUT' : 'POST',
body: JSON.stringify(body),
});
if (res.ok) {
toast.success(editingId ? 'Mute rule updated.' : 'Mute rule created.');
emitMuteRulesChanged();
resetForm();
fetchRules();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Something went wrong.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (deleteRuleId == null) return;
try {
const res = await apiFetch(`/notification-suppression-rules/${deleteRuleId}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Mute rule deleted.');
emitMuteRulesChanged();
fetchRules();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Something went wrong.');
}
} catch {
toast.error('Network error.');
} finally {
setDeleteRuleId(null);
}
};
const handleToggleEnabled = async (rule: NotificationSuppressionRule) => {
try {
const res = await apiFetch(`/notification-suppression-rules/${rule.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !rule.enabled }),
});
if (res.ok) {
emitMuteRulesChanged();
fetchRules();
}
else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Something went wrong.');
}
} catch {
toast.error('Network error.');
}
};
const addStack = (stackName: string) => {
if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]);
};
const removeStack = (stackName: string) => setFormStacks((prev) => prev.filter((s) => s !== stackName));
const addLabel = (idStr: string) => {
const id = Number(idStr);
if (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]);
};
const removeLabel = (id: number) => setFormLabelIds((prev) => prev.filter((l) => l !== id));
const addCategory = (cat: string) => {
const c = cat as NotificationCategory;
if (c && !formCategories.includes(c)) setFormCategories((prev) => [...prev, c]);
};
const removeCategory = (cat: NotificationCategory) => setFormCategories((prev) => prev.filter((c) => c !== cat));
const addLevel = (level: string) => {
const l = level as NotificationLevel;
if (l && !formLevels.includes(l)) setFormLevels((prev) => [...prev, l]);
};
const removeLevel = (level: NotificationLevel) => setFormLevels((prev) => prev.filter((x) => x !== level));
const enabledCount = rules.filter((r) => r.enabled && (r.expires_at == null || r.expires_at > Date.now())).length;
useMastheadStats(
loading ? null : [
{ label: 'RULES', value: `${rules.length}` },
{ label: 'ACTIVE', value: `${enabledCount}`, tone: enabledCount > 0 ? 'value' : 'subtitle' },
],
);
const availableStackOptions = stackOptions.filter((o) => !formStacks.includes(o.value));
const availableLabelOptions = useMemo<ComboboxOption[]>(
() => labelOptions.filter((l) => !formLabelIds.includes(l.id)).map((l) => ({ value: String(l.id), label: l.name })),
[labelOptions, formLabelIds],
);
const availableCategoryOptions = useMemo<ComboboxOption[]>(
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[])
.filter((c) => !formCategories.includes(c))
.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })),
[formCategories],
);
const availableLevelOptions = useMemo<ComboboxOption[]>(
() => (['info', 'warning', 'error'] as NotificationLevel[])
.filter((l) => !formLevels.includes(l))
.map((l) => ({ value: l, label: LEVEL_LABELS[l] })),
[formLevels],
);
const deleteTarget = deleteRuleId != null ? rules.find((r) => r.id === deleteRuleId) : null;
return (
<CapabilityGate capability="notification-suppression" featureName="Mute Rules">
<div className="space-y-6">
<SettingsCallout
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
title="Mute rules vs routing"
subtitle="Routing sends matching alerts to another channel. Mute rules hide or drop delivery to the bell, external channels, or both. Events still appear in stack activity history."
/>
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" /> Add mute rule
</SettingsPrimaryButton>
</div>
<Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
<ModalHeader
kicker={editingId ? 'MUTE RULES · EDIT RULE' : 'MUTE RULES · NEW RULE'}
title={editingId ? 'Edit mute rule' : 'New mute rule'}
description="Match alerts by node, stack, label, category, or severity, then choose where to mute delivery."
/>
<ModalBody>
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mute staging deploy noise" value={formName} onChange={(e) => setFormName(e.target.value)} maxLength={100} />
</div>
<div className="space-y-2">
<Label>Node scope</Label>
<Select
value={formNodeId === null ? 'any' : String(formNodeId)}
onValueChange={(v) => setFormNodeId(v === 'any' ? null : Number(v))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="any">Any node</SelectItem>
{nodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Add a stack..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
{formStacks.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formStacks.map((s) => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button type="button" onClick={() => removeStack(s)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableLabelOptions} value="" onValueChange={addLabel} placeholder="Add a label..." searchPlaceholder="Search labels..." emptyText="No labels found." />
{formLabelIds.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLabelIds.map((id) => {
const lbl = labelOptions.find((l) => l.id === id);
return (
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
{lbl?.name ?? `Label ${id}`}
<button type="button" onClick={() => removeLabel(id)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<Label>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableCategoryOptions} value="" onValueChange={addCategory} placeholder="Add a category..." searchPlaceholder="Search categories..." emptyText="No categories found." />
{formCategories.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formCategories.map((c) => (
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
{CATEGORY_LABELS[c]}
<button type="button" onClick={() => removeCategory(c)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableLevelOptions} value="" onValueChange={addLevel} placeholder="Add a severity..." searchPlaceholder="Search..." emptyText="No levels left." />
{formLevels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLevels.map((l) => (
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
{LEVEL_LABELS[l]}
<button type="button" onClick={() => removeLevel(l)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">Leave matchers blank to match any value. All non-empty filters must match (AND).</p>
</div>
<div className="space-y-2">
<Label>Apply to</Label>
<SegmentedControl
value={formAppliesTo}
options={[
{ value: 'bell', label: 'Bell' },
{ value: 'external', label: 'External' },
{ value: 'both', label: 'Both' },
]}
onChange={setFormAppliesTo}
ariaLabel="Suppression target"
fullWidth
/>
</div>
<div className="space-y-2">
<Label>Expiration</Label>
<Select value={formExpirationPreset} onValueChange={(v) => setFormExpirationPreset(v as ExpirationPreset)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="forever">Forever</SelectItem>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="24h">24 hours</SelectItem>
<SelectItem value="custom">Custom date</SelectItem>
</SelectContent>
</Select>
{formExpirationPreset === 'custom' && (
<Input type="datetime-local" value={formCustomExpiry} onChange={(e) => setFormCustomExpiry(e.target.value)} />
)}
</div>
<div className="flex items-center gap-2">
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
<span className="text-sm text-stat-value select-none">
{formEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</ModalBody>
<ModalFooter
secondary={<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>}
primary={
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
}
/>
</Modal>
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
)}
{!loading && rules.length === 0 && (
<SettingsCallout
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
title="No mute rules configured"
subtitle="Alerts follow your routing and global channels unless a mute rule matches."
/>
)}
{!loading && rules.map((rule) => (
<div key={rule.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<BellOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm truncate">{rule.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">{APPLIES_TO_LABELS[rule.applies_to]}</Badge>
{rule.node_id !== null && (
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
{nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`}
</Badge>
)}
{!rule.enabled && <Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Disabled</Badge>}
{rule.expires_at != null && rule.expires_at <= Date.now() && (
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
<Button variant="ghost" size="sm" onClick={() => startEdit(rule)} title="Edit"><Pencil className="w-4 h-4" strokeWidth={1.5} /></Button>
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" title="Delete" onClick={() => setDeleteRuleId(rule.id)}>
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{rule.stack_patterns.map((s) => <Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>)}
{rule.label_ids?.map((id) => {
const lbl = labelOptions.find((l) => l.id === id);
return <Badge key={id} variant="outline" className="text-[10px]">{lbl?.name ?? `label:${id}`}</Badge>;
})}
{rule.categories?.map((c) => <Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c as NotificationCategory] ?? c}</Badge>)}
{rule.levels?.map((l) => <Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>)}
{rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && (
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
</div>
</div>
))}
<ConfirmModal
open={deleteRuleId != null}
onOpenChange={(open) => { if (!open) setDeleteRuleId(null); }}
variant="destructive"
kicker="MUTE RULES · DELETE · IRREVERSIBLE"
title="Delete mute rule"
confirmLabel="Delete"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Deletes <span className="font-medium text-stat-value">{deleteTarget?.name ?? 'this rule'}</span>. Matching alerts will deliver normally again.
</p>
</ConfirmModal>
</div>
</CapabilityGate>
);
}
@@ -24,6 +24,7 @@ import {
scopeLabel,
} from './index';
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsSectionContent } from './SettingsSectionContent';
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
@@ -31,6 +32,9 @@ import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsCon
interface SettingsPageProps {
currentSection: SectionId;
onSectionChange: (section: SectionId) => void;
muteRulePrefill?: MuteRuleDraft | null;
onMutePrefillConsumed?: () => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
}
export function SettingsPage(props: SettingsPageProps) {
@@ -41,7 +45,13 @@ export function SettingsPage(props: SettingsPageProps) {
);
}
function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProps) {
function SettingsPageInner({
currentSection,
onSectionChange,
muteRulePrefill = null,
onMutePrefillConsumed,
onOpenMuteRulesWithPrefill,
}: SettingsPageProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
@@ -197,6 +207,9 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
sectionId={safeSection}
onDirtyChange={handleDirtyChange}
showDescription
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
</div>
</ScrollArea>
@@ -22,6 +22,7 @@ import {
getSettingsItem,
} from './index';
import type { SectionId } from './index';
import type { MuteRuleDraft } from '@/lib/muteRules';
import LazyBoundary from '../LazyBoundary';
import { SectionGate } from './SectionGate';
@@ -44,6 +45,9 @@ const LabelsSection = lazy(() =>
const NotificationRoutingSection = lazy(() =>
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
);
const NotificationSuppressionSection = lazy(() =>
import('./NotificationSuppressionSection').then(m => ({ default: m.NotificationSuppressionSection })),
);
const CloudBackupSection = lazy(() =>
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
);
@@ -72,6 +76,9 @@ function SectionSkeleton() {
function renderSection(
sectionId: SectionId,
onDirtyChange: (section: SectionId, dirty: boolean) => void,
muteRulePrefill: MuteRuleDraft | null | undefined,
onMutePrefillConsumed: (() => void) | undefined,
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
) {
switch (sectionId) {
case 'account': return <AccountSection />;
@@ -81,7 +88,7 @@ function renderSection(
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'labels': return <LabelsSection onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
@@ -89,6 +96,12 @@ function renderSection(
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'notification-suppression': return (
<NotificationSuppressionSection
prefill={muteRulePrefill}
onPrefillConsumed={onMutePrefillConsumed}
/>
);
case 'webhooks': return <WebhooksSection />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
@@ -109,6 +122,9 @@ interface SettingsSectionContentProps {
onDirtyChange: (section: SectionId, dirty: boolean) => void;
/** Render the section's lead description paragraph above the content. */
showDescription?: boolean;
muteRulePrefill?: MuteRuleDraft | null;
onMutePrefillConsumed?: () => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
}
/**
@@ -117,14 +133,18 @@ interface SettingsSectionContentProps {
* the desktop SettingsPage and the mobile settings screen so the section switch,
* lazy splitting, and gating live in exactly one place.
*/
export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) {
export function SettingsSectionContent({
sectionId,
onDirtyChange,
showDescription,
muteRulePrefill,
onMutePrefillConsumed,
onOpenMuteRulesWithPrefill,
}: SettingsSectionContentProps) {
const item = getSettingsItem(sectionId);
// Memoize the section element so unrelated re-renders of the host page (the
// command palette opening, a dirty-flag toggle) do not re-render the active
// section. onDirtyChange is stable from both call sites.
const element = useMemo(
() => renderSection(sectionId, onDirtyChange),
[sectionId, onDirtyChange],
() => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill),
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill],
);
return (
<>
@@ -82,6 +82,7 @@ describe('settings registry', () => {
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
expect(byId.get('notifications')?.label).toBe('Channels');
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
expect(byId.get('notification-suppression')?.label).toBe('Mute Rules');
});
it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => {
@@ -220,6 +220,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'notification-suppression',
group: 'notifications',
label: 'Mute Rules',
description: 'Mute or drop matching alerts from the bell, external channels, or both.',
keywords: ['mute', 'suppress', 'silence', 'hide', 'bell', 'rules'],
tier: null,
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
// Automation
{
id: 'image-updates',
@@ -69,6 +69,7 @@ export type SectionId =
| 'app-store'
| 'stacks'
| 'notification-routing'
| 'notification-suppression'
| 'recovery'
| 'support'
| 'about';
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { Check, Plus } from 'lucide-react';
import { Check, Plus, BellOff } from 'lucide-react';
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
<span className="text-xs">Mute label</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[200px]">
{ctx.labels.map(label => (
<ContextMenuSub key={label.id}>
<ContextMenuSubTrigger>
<LabelDot color={label.color} />
<span className="font-mono text-[12px] ml-2">{label.name}</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => ctx.muteLabelAll(label.id, label.name)}>
<span className="text-xs">Mute notifications for this label</span>
</ContextMenuItem>
<ContextMenuItem onClick={() => ctx.muteLabelExternal(label.id, label.name)}>
<span className="text-xs">Mute external alerts for this label</span>
</ContextMenuItem>
<ContextMenuItem onClick={() => ctx.muteLabelLowPriority(label.id, label.name)}>
<span className="text-xs">Mute low-priority stack alerts</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => ctx.openLabelMuteRules(label.id, label.name)}>
<span className="text-xs">Manage label mute rules</span>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
))}
</ContextMenuSubContent>
</ContextMenuSub>
</>
)}
</>
)}
</ContextMenuSubContent>
@@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
);
}
function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: {
item: MenuItem;
MenuSub: typeof ContextMenuSub;
MenuSubTrigger: typeof ContextMenuSubTrigger;
MenuSubContent: typeof ContextMenuSubContent;
MenuItem: typeof ContextMenuItem;
}) {
return (
<MenuSub>
<MenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</MenuSubTrigger>
<MenuSubContent className="min-w-[220px]">
{item.subItems?.map((sub) => (
<MenuItem key={sub.id} onClick={() => sub.onSelect()}>
<span className="text-xs">{sub.label}</span>
</MenuItem>
))}
</MenuSubContent>
</MenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
if (item.subItems?.length) {
return (
<SimpleSub
key={item.id}
item={item}
MenuSub={ContextMenuSub}
MenuSubTrigger={ContextMenuSubTrigger}
MenuSubContent={ContextMenuSubContent}
MenuItem={ContextMenuItem}
/>
);
}
return (
<ContextMenuItem
key={item.id}
@@ -10,10 +10,11 @@ interface StackGroupProps {
collapsed: boolean;
onToggle: () => void;
variant?: 'default' | 'pinned';
headerActions?: ReactNode;
children: ReactNode;
}
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) {
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', headerActions, children }: StackGroupProps) {
const isPinned = variant === 'pinned';
const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle';
return (
@@ -32,6 +33,7 @@ export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'd
{isPinned ? '★ ' : ''}{label}
</span>
<span className="flex items-center gap-1.5">
{headerActions}
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
{collapsed
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { MoreVertical, Check, Plus } from 'lucide-react';
import { MoreVertical, Check, Plus, BellOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
<span className="text-xs">Mute label</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[200px]">
{ctx.labels.map(label => (
<DropdownMenuSub key={label.id}>
<DropdownMenuSubTrigger>
<LabelDot color={label.color} />
<span className="font-mono text-[12px] ml-2">{label.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => ctx.muteLabelAll(label.id, label.name)}>
<span className="text-xs">Mute notifications for this label</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => ctx.muteLabelExternal(label.id, label.name)}>
<span className="text-xs">Mute external alerts for this label</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => ctx.muteLabelLowPriority(label.id, label.name)}>
<span className="text-xs">Mute low-priority stack alerts</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => ctx.openLabelMuteRules(label.id, label.name)}>
<span className="text-xs">Manage label mute rules</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</>
)}
</DropdownMenuSubContent>
@@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
);
}
function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: {
item: MenuItem;
MenuSub: typeof DropdownMenuSub;
MenuSubTrigger: typeof DropdownMenuSubTrigger;
MenuSubContent: typeof DropdownMenuSubContent;
MenuItem: typeof DropdownMenuItem;
}) {
return (
<MenuSub>
<MenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</MenuSubTrigger>
<MenuSubContent className="min-w-[220px]">
{item.subItems?.map((sub) => (
<MenuItem key={sub.id} onSelect={() => sub.onSelect()}>
<span className="text-xs">{sub.label}</span>
</MenuItem>
))}
</MenuSubContent>
</MenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
if (item.subItems?.length) {
return (
<SimpleSub
key={item.id}
item={item}
MenuSub={DropdownMenuSub}
MenuSubTrigger={DropdownMenuSubTrigger}
MenuSubContent={DropdownMenuSubContent}
MenuItem={DropdownMenuItem}
/>
);
}
return (
<DropdownMenuItem
key={item.id}
@@ -13,6 +13,9 @@ import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
import { EmptyStackState } from './EmptyStackState';
import type { StackMenuCtx, FilterChip } from './sidebar-types';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
interface RemoteNodeResult {
nodeId: number;
@@ -54,6 +57,7 @@ export interface StackListProps {
// Open the create dialog on a starting mode. Present only when the user can
// create stacks; drives the zero-stacks empty state.
onOpenCreate?: (mode: 'import' | 'empty') => void;
openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
}
interface BuiltGroup {
@@ -63,6 +67,8 @@ interface BuiltGroup {
count: number;
files: string[];
variant?: 'default' | 'pinned';
labelId?: number;
labelName?: string;
}
function buildGroups(
@@ -101,6 +107,8 @@ function buildGroups(
result.push({
kind: 'labeled', id: `label:${label.id}`,
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
labelId: label.id,
labelName: label.name,
});
}
@@ -111,6 +119,19 @@ function buildGroups(
return result;
}
function LabelGroupMuteActions({
labelId,
labelName,
openMuteRulesWithPrefill,
}: {
labelId: number;
labelName: string;
openMuteRulesWithPrefill: (draft: MuteRuleDraft) => void;
}) {
const actions = useLabelMuteActions(labelId, labelName, openMuteRulesWithPrefill);
return <LabelGroupMuteKebab actions={actions} />;
}
interface StackListBulkProps {
bulkMode: boolean;
selectedFiles: Set<string>;
@@ -125,6 +146,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
bulkMode, selectedFiles, onToggleSelect,
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
filterChip, onOpenCreate,
openMuteRulesWithPrefill,
} = props;
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
@@ -164,6 +186,17 @@ export function StackList(props: StackListProps & StackListBulkProps) {
collapsed={isCollapsed(g.id)}
onToggle={() => toggleCollapse(g.id)}
variant={g.variant}
headerActions={
g.kind === 'labeled' && g.labelId != null && g.labelName && openMuteRulesWithPrefill
? (
<LabelGroupMuteActions
labelId={g.labelId}
labelName={g.labelName}
openMuteRulesWithPrefill={openMuteRulesWithPrefill}
/>
)
: undefined
}
>
{g.files.map(file => {
const ctx = buildMenuCtx(file);
@@ -46,6 +46,15 @@ export interface StackMenuCtx {
unpin: () => void;
toggleLabel: (labelId: number) => void;
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
canMuteNotifications: boolean;
muteStackAll: () => void;
muteStackDeploySuccess: () => void;
muteStackMonitor: () => void;
openStackMuteRules: () => void;
muteLabelAll: (labelId: number, labelName: string) => void;
muteLabelExternal: (labelId: number, labelName: string) => void;
muteLabelLowPriority: (labelId: number, labelName: string) => void;
openLabelMuteRules: (labelId: number, labelName: string) => void;
openLabelManager: () => void;
openScheduleTask: () => void;
}
@@ -5,6 +5,8 @@ import {
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
@@ -13,6 +15,12 @@ import type { NotificationItem } from '@/components/dashboard/types';
type ActivityLevel = 'info' | 'warning' | 'error';
const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error'];
interface SuppressionMatchSnapshot {
rules: { id: number; name: string }[];
bellSuppressed: boolean;
externalSuppressed: boolean;
}
interface ActivityEvent {
id: number;
level: ActivityLevel;
@@ -21,6 +29,7 @@ interface ActivityEvent {
timestamp: number;
stack_name?: string;
actor_username?: string | null;
suppression_match?: string | SuppressionMatchSnapshot | null;
}
interface StackActivityTimelineProps {
@@ -61,6 +70,25 @@ function formatActor(actor: string): { label: string; isSystem: boolean } {
return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem };
}
function parseSuppressionMatch(raw: ActivityEvent['suppression_match']): SuppressionMatchSnapshot | null {
if (!raw) return null;
if (typeof raw === 'object') return raw;
try {
const parsed = JSON.parse(raw) as SuppressionMatchSnapshot;
if (!parsed || typeof parsed !== 'object') return null;
if (!Array.isArray(parsed.rules)) return null;
return parsed;
} catch {
return null;
}
}
function suppressionTooltip(match: SuppressionMatchSnapshot): string {
const names = match.rules.map((r) => r.name).filter(Boolean);
const ruleText = names.length > 0 ? names.join(', ') : 'Unnamed rule';
return `Matched rule: ${ruleText}`;
}
function isActivityEvent(value: unknown): value is ActivityEvent {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
@@ -261,6 +289,10 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
{g.events.map(e => {
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
const actor = e.actor_username ? formatActor(e.actor_username) : null;
const suppression = parseSuppressionMatch(e.suppression_match);
const showSuppressed = Boolean(
suppression && (suppression.bellSuppressed || suppression.externalSuppressed),
);
return (
<div
key={e.id}
@@ -275,6 +307,18 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
</span>
)}
{showSuppressed && suppression && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="ml-1.5 align-middle text-[9px] font-mono uppercase tracking-wide px-1 py-0 h-4">
Suppressed
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="font-mono text-[10px] max-w-xs">
{suppressionTooltip(suppression)}
</TooltipContent>
</Tooltip>
)}
</div>
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
</div>
@@ -32,6 +32,15 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
createAndAssignLabel: vi.fn(),
openLabelManager: vi.fn(),
openScheduleTask: vi.fn(),
canMuteNotifications: false,
muteStackAll: vi.fn(),
muteStackDeploySuccess: vi.fn(),
muteStackMonitor: vi.fn(),
openStackMuteRules: vi.fn(),
muteLabelAll: vi.fn(),
muteLabelExternal: vi.fn(),
muteLabelLowPriority: vi.fn(),
openLabelMuteRules: vi.fn(),
...overrides,
};
}
@@ -111,6 +120,25 @@ describe('useStackMenuItems', () => {
expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy();
});
it('includes Mute submenu in Inspect when canMuteNotifications', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: true })));
const inspect = result.current.find(g => g.id === 'inspect')!;
const muteItem = inspect.items.find(i => i.id === 'mute');
expect(muteItem).toBeDefined();
expect(muteItem?.subItems?.map(s => s.id)).toEqual([
'mute-stack-all',
'mute-stack-deploy',
'mute-stack-monitor',
'mute-stack-manage',
]);
});
it('hides Mute submenu when canMuteNotifications is false', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: false })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'mute')).toBeUndefined();
});
it('keeps label assignment available for any tier', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }],
+107
View File
@@ -0,0 +1,107 @@
import { useCallback, useMemo } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import {
createMuteRuleWithToast,
stackMuteAllDraft,
stackMuteDeploySuccessDraft,
stackMuteMonitorDraft,
labelMuteAllDraft,
labelMuteExternalDraft,
labelMuteLowPriorityDraft,
nodeMuteAllDraft,
nodeMuteUpdatesDraft,
nodeMuteMonitorDraft,
type MuteRuleDraft,
} from '@/lib/muteRules';
export type OpenMuteRulesPrefill = (draft: MuteRuleDraft) => void;
export function useCanMuteNotifications(): boolean {
const { isAdmin } = useAuth();
const { hasCapability } = useNodes();
return isAdmin && hasCapability('notification-suppression');
}
export function useStackMuteActions(stackName: string, openMuteRulesWithPrefill: OpenMuteRulesPrefill) {
const { activeNode } = useNodes();
const canMute = useCanMuteNotifications();
const nodeId = activeNode?.id ?? null;
const muteAll = useCallback(() => {
void createMuteRuleWithToast(stackMuteAllDraft(stackName, nodeId));
}, [stackName, nodeId]);
const muteDeploySuccess = useCallback(() => {
void createMuteRuleWithToast(stackMuteDeploySuccessDraft(stackName, nodeId));
}, [stackName, nodeId]);
const muteMonitor = useCallback(() => {
void createMuteRuleWithToast(stackMuteMonitorDraft(stackName, nodeId));
}, [stackName, nodeId]);
const manage = useCallback(() => {
openMuteRulesWithPrefill(stackMuteAllDraft(stackName, nodeId));
}, [stackName, nodeId, openMuteRulesWithPrefill]);
return useMemo(
() => ({ canMute, muteAll, muteDeploySuccess, muteMonitor, manage }),
[canMute, muteAll, muteDeploySuccess, muteMonitor, manage],
);
}
export function useLabelMuteActions(
labelId: number,
labelName: string,
openMuteRulesWithPrefill: OpenMuteRulesPrefill,
) {
const { activeNode } = useNodes();
const canMute = useCanMuteNotifications();
const nodeId = activeNode?.id ?? null;
const muteAll = useCallback(() => {
void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId));
}, [labelId, labelName, nodeId]);
const muteExternal = useCallback(() => {
void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId));
}, [labelId, labelName, nodeId]);
const muteLowPriority = useCallback(() => {
void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId));
}, [labelId, labelName, nodeId]);
const manage = useCallback(() => {
openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId));
}, [labelId, labelName, nodeId, openMuteRulesWithPrefill]);
return useMemo(
() => ({ canMute, muteAll, muteExternal, muteLowPriority, manage }),
[canMute, muteAll, muteExternal, muteLowPriority, manage],
);
}
export function useNodeMuteActions(nodeId: number, nodeName: string, openMuteRulesWithPrefill: OpenMuteRulesPrefill) {
const canMute = useCanMuteNotifications();
const muteAll = useCallback(() => {
void createMuteRuleWithToast(nodeMuteAllDraft(nodeId, nodeName));
}, [nodeId, nodeName]);
const muteUpdates = useCallback(() => {
void createMuteRuleWithToast(nodeMuteUpdatesDraft(nodeId, nodeName));
}, [nodeId, nodeName]);
const muteMonitor = useCallback(() => {
void createMuteRuleWithToast(nodeMuteMonitorDraft(nodeId, nodeName));
}, [nodeId, nodeName]);
const manage = useCallback(() => {
openMuteRulesWithPrefill(nodeMuteAllDraft(nodeId, nodeName));
}, [nodeId, nodeName, openMuteRulesWithPrefill]);
return useMemo(
() => ({ canMute, muteAll, muteUpdates, muteMonitor, manage }),
[canMute, muteAll, muteUpdates, muteMonitor, manage],
);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect } from 'react';
import { MUTE_RULES_CHANGED_EVENT } from '@/lib/muteRules';
/** Refetch mute rules when another surface creates or updates a rule. */
export function useMuteRulesRefresh(onRefresh: () => void): void {
useEffect(() => {
const handler = () => { onRefresh(); };
window.addEventListener(MUTE_RULES_CHANGED_EVENT, handler);
return () => window.removeEventListener(MUTE_RULES_CHANGED_EVENT, handler);
}, [onRefresh]);
}
+17
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import {
Activity,
ArrowUpRight,
BellOff,
BellRing,
CalendarClock,
Download,
@@ -22,6 +23,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
@@ -36,6 +38,20 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
if (stackStatus === 'running' && canOpenApp) {
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
}
if (canMuteNotifications) {
inspect.push({
id: 'mute',
label: 'Mute',
icon: BellOff,
onSelect: () => {},
subItems: [
{ id: 'mute-stack-all', label: 'Mute notifications for this stack', icon: BellOff, onSelect: muteStackAll },
{ id: 'mute-stack-deploy', label: 'Mute deploy success noise', icon: BellOff, onSelect: muteStackDeploySuccess },
{ id: 'mute-stack-monitor', label: 'Mute monitor alerts for this stack', icon: BellOff, onSelect: muteStackMonitor },
{ id: 'mute-stack-manage', label: 'Manage stack mute rules', icon: BellOff, onSelect: openStackMuteRules },
],
});
}
groups.push({ id: 'inspect', items: inspect });
const organize: MenuItem[] = [];
@@ -82,5 +98,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
showDeploy, showStop, showRestart, showUpdate,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
]);
}
+1
View File
@@ -13,6 +13,7 @@ export const CAPABILITIES = [
'network-topology',
'notifications',
'notification-routing',
'notification-suppression',
'host-console',
'container-exec',
'audit-log',
+188
View File
@@ -0,0 +1,188 @@
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
import type { NotificationCategory, NotificationItem } from '@/components/dashboard/types';
export type MuteRuleLevel = 'info' | 'warning' | 'error';
export type MuteRuleAppliesTo = 'bell' | 'external' | 'both';
export type MuteRuleDraft = {
name: string;
node_id?: number | null;
stack_patterns?: string[];
label_ids?: number[] | null;
categories?: NotificationCategory[] | null;
levels?: MuteRuleLevel[] | null;
applies_to?: MuteRuleAppliesTo;
enabled?: boolean;
expires_at?: number | null;
};
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
export function emitMuteRulesChanged(): void {
window.dispatchEvent(new CustomEvent(MUTE_RULES_CHANGED_EVENT));
}
const DEFAULT_BODY = {
applies_to: 'both' as MuteRuleAppliesTo,
enabled: true,
expires_at: null as number | null,
node_id: null as number | null,
stack_patterns: [] as string[],
label_ids: null as number[] | null,
categories: null as NotificationCategory[] | null,
levels: null as MuteRuleLevel[] | null,
};
export async function createMuteRule(draft: MuteRuleDraft): Promise<{ ok: boolean; error?: string }> {
try {
const res = await apiFetch('/notification-suppression-rules', {
method: 'POST',
body: JSON.stringify({ ...DEFAULT_BODY, ...draft }),
});
if (res.ok) {
emitMuteRulesChanged();
return { ok: true };
}
const err = await res.json().catch(() => ({}));
return { ok: false, error: (err as { error?: string })?.error || 'Failed to create mute rule.' };
} catch {
return { ok: false, error: 'Network error.' };
}
}
export async function createMuteRuleWithToast(
draft: MuteRuleDraft,
successMessage = 'Mute rule created. Open Settings · Mute Rules to edit it.',
): Promise<boolean> {
const result = await createMuteRule(draft);
if (result.ok) {
toast.success(successMessage);
return true;
}
toast.error(result.error || 'Failed to create mute rule.');
return false;
}
export function stackMuteAllDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute ${stackName}`,
node_id: nodeId ?? null,
stack_patterns: [stackName],
};
}
export function stackMuteDeploySuccessDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute ${stackName} deploy success`,
node_id: nodeId ?? null,
stack_patterns: [stackName],
categories: ['deploy_success'],
};
}
export function stackMuteMonitorDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute ${stackName} monitor alerts`,
node_id: nodeId ?? null,
stack_patterns: [stackName],
categories: ['monitor_alert'],
};
}
export function nodeMuteAllDraft(nodeId: number, nodeName: string): MuteRuleDraft {
return {
name: `Mute ${nodeName}`,
node_id: nodeId,
};
}
export function nodeMuteUpdatesDraft(nodeId: number, nodeName: string): MuteRuleDraft {
return {
name: `Mute ${nodeName} update notifications`,
node_id: nodeId,
categories: ['image_update_available', 'node_update_available', 'update_started'],
};
}
export function nodeMuteMonitorDraft(nodeId: number, nodeName: string): MuteRuleDraft {
return {
name: `Mute ${nodeName} monitor alerts`,
node_id: nodeId,
categories: ['monitor_alert'],
};
}
export function labelMuteAllDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute label: ${labelName}`,
node_id: nodeId ?? null,
label_ids: [labelId],
applies_to: 'both',
};
}
export function labelMuteExternalDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute external for label: ${labelName}`,
node_id: nodeId ?? null,
label_ids: [labelId],
applies_to: 'external',
};
}
export function labelMuteLowPriorityDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute low-priority alerts for label: ${labelName}`,
node_id: nodeId ?? null,
label_ids: [labelId],
levels: ['info', 'warning'],
};
}
export type BellMuteMode = 'category' | 'similar' | 'stack';
export function muteDraftFromNotification(
notif: NotificationItem,
mode: BellMuteMode,
): MuteRuleDraft | null {
const categoryLabel = notif.category ? CATEGORY_LABELS[notif.category as NotificationCategory] : 'alert';
if (mode === 'category' && notif.category) {
return {
name: `Mute ${categoryLabel}`,
node_id: notif.nodeId ?? null,
categories: [notif.category as NotificationCategory],
};
}
if (mode === 'stack' && notif.stack_name) {
return {
name: `Mute ${notif.stack_name}`,
node_id: notif.nodeId ?? null,
stack_patterns: [notif.stack_name],
};
}
if (mode === 'similar') {
return {
name: 'Mute similar alerts',
node_id: notif.nodeId ?? null,
categories: notif.category ? [notif.category as NotificationCategory] : null,
levels: [notif.level],
stack_patterns: notif.stack_name ? [notif.stack_name] : [],
};
}
return null;
}
export async function createMuteFromNotification(
notif: NotificationItem,
mode: BellMuteMode,
): Promise<boolean> {
const draft = muteDraftFromNotification(notif, mode);
if (!draft) {
toast.error('This notification cannot be muted with that shortcut.');
return false;
}
return createMuteRuleWithToast(draft);
}
@@ -11,6 +11,11 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
autoheal_triggered: 'Auto-heal',
monitor_alert: 'Monitor alert',
scan_finding: 'Scan finding',
drift_detected: 'Drift detected',
drift_resolved: 'Drift resolved',
update_started: 'Update started',
health_gate_passed: 'Health gate passed',
health_gate_failed: 'Health gate failed',
node_update_available: 'Node update',
system: 'System',
};