@@ -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 (
@@ -287,7 +292,8 @@ export function StackIdentityHeader({
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
)}
- {hasOverflowExtras && canDelete && }
+ {stackMuteActions && }
+ {(canRollback || canScan || stackMuteActions?.canMute) && canDelete && }
{canDelete && (
({
+ 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.
diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts
index 0c8dda6f..1e02f8c0 100644
--- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts
+++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts
@@ -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;
@@ -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;
diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts
index 2e31133e..41c195ff 100644
--- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts
+++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts
@@ -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(null);
const [filterNodeId, setFilterNodeId] = useState(null);
const [schedulePrefill, setSchedulePrefill] = useState(null);
+ const [muteRulePrefill, setMuteRulePrefill] = useState(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;
diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx
index c15855f4..8661767e 100644
--- a/frontend/src/components/FleetView.tsx
+++ b/frontend/src/components/FleetView.tsx
@@ -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}
diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx
index 587a933d..8c95177f 100644
--- a/frontend/src/components/FleetView/NodeCard.tsx
+++ b/frontend/src/components/FleetView/NodeCard.tsx
@@ -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(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'}
)}
+ {onOpenMuteRulesWithPrefill && }
diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx
index 50cbaade..d4c9bce1 100644
--- a/frontend/src/components/FleetView/OverviewTab.tsx
+++ b/frontend/src/components/FleetView/OverviewTab.tsx
@@ -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}
/>
))}
diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx
index 8c526b7f..c0d1b726 100644
--- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx
+++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx
@@ -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 });
});
diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx
index dc1187c3..5a7b4e82 100644
--- a/frontend/src/components/NotificationPanel.tsx
+++ b/frontend/src/components/NotificationPanel.tsx
@@ -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