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
+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 });
});