mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user