mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
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:
@@ -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' }],
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user