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
@@ -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,
]);
}