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:
@@ -40,6 +40,7 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
@@ -179,11 +180,14 @@ export default function EditorLayout() {
|
||||
fleetTab, setFleetTab,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill,
|
||||
muteRulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
handleOpenSettings,
|
||||
handlePrefillConsumed,
|
||||
handleMutePrefillConsumed,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
openMuteRulesWithPrefill,
|
||||
} = navState;
|
||||
|
||||
const {
|
||||
@@ -284,6 +288,8 @@ export default function EditorLayout() {
|
||||
|
||||
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
||||
const stackName = selectedFile || '';
|
||||
const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : '';
|
||||
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
@@ -500,6 +506,7 @@ export default function EditorLayout() {
|
||||
onMobileBack={goToMobileList}
|
||||
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
|
||||
hasUnsavedChanges={stackActions.hasUnsavedChanges}
|
||||
stackMuteActions={selectedFile ? stackMuteActions : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -680,6 +687,7 @@ export default function EditorLayout() {
|
||||
},
|
||||
filterChip,
|
||||
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
|
||||
openMuteRulesWithPrefill,
|
||||
}}
|
||||
activitySummary={activitySummary}
|
||||
onActivityAction={handleActivityAction}
|
||||
@@ -755,9 +763,12 @@ export default function EditorLayout() {
|
||||
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
||||
schedulePrefill={schedulePrefill}
|
||||
onPrefillConsumed={handlePrefillConsumed}
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={handleMutePrefillConsumed}
|
||||
notifications={notifications}
|
||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => openSettings(section)}
|
||||
onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<NotifFilter>('all');
|
||||
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
|
||||
@@ -369,6 +379,7 @@ export function NotificationPanel({
|
||||
onDelete={onDelete}
|
||||
onNavigate={onNavigate ? handleNavigate : undefined}
|
||||
onNavigateChangelog={onNavigateChangelog}
|
||||
canMute={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -386,9 +397,17 @@ interface NotificationRowProps {
|
||||
onDelete: (notif: NotificationItem) => void;
|
||||
onNavigate?: (notif: NotificationItem) => void;
|
||||
onNavigateChangelog?: (notif: NotificationItem) => void;
|
||||
canMute?: boolean;
|
||||
}
|
||||
|
||||
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) {
|
||||
async function createQuickSuppressionRule(
|
||||
notif: NotificationItem,
|
||||
mode: 'category' | 'similar' | 'stack',
|
||||
): Promise<void> {
|
||||
await createMuteFromNotification(notif, mode);
|
||||
}
|
||||
|
||||
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog, canMute }: NotificationRowProps) {
|
||||
const config = LEVEL_CONFIG[notif.level];
|
||||
const Icon = config.icon;
|
||||
const isUnread = !notif.is_read;
|
||||
@@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate
|
||||
) : (
|
||||
<div className={surfaceClasses}>{content}</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 z-20 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onDelete(notif)}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<div className="absolute right-2 top-2 z-20 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{canMute ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="Mute options"
|
||||
aria-label="Mute options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
{notif.category ? (
|
||||
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'category')}>
|
||||
Mute this category
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'similar')}>
|
||||
Mute notifications like this
|
||||
</DropdownMenuItem>
|
||||
{notif.stack_name ? (
|
||||
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'stack')}>
|
||||
Mute this stack
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => onDelete(notif)}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import EnvironmentPanel from './stack/EnvironmentPanel';
|
||||
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
stackName: string;
|
||||
@@ -32,6 +34,7 @@ interface StackAnatomyPanelProps {
|
||||
canEdit: boolean;
|
||||
applying?: boolean;
|
||||
notifications?: NotificationItem[];
|
||||
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
@@ -82,6 +85,7 @@ export default function StackAnatomyPanel({
|
||||
canEdit,
|
||||
applying = false,
|
||||
notifications,
|
||||
stackMuteActions,
|
||||
}: StackAnatomyPanelProps) {
|
||||
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
||||
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
||||
@@ -409,6 +413,7 @@ export default function StackAnatomyPanel({
|
||||
edit
|
||||
</button>
|
||||
)}
|
||||
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
|
||||
|
||||
@@ -54,6 +54,11 @@ export type NotificationCategory =
|
||||
| 'autoheal_triggered'
|
||||
| 'monitor_alert'
|
||||
| 'scan_finding'
|
||||
| 'drift_detected'
|
||||
| 'drift_resolved'
|
||||
| 'update_started'
|
||||
| 'health_gate_passed'
|
||||
| 'health_gate_failed'
|
||||
| 'node_update_available'
|
||||
| 'system';
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BellOff, MoreVertical } from 'lucide-react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { useStackMuteActions, useLabelMuteActions, useNodeMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
type StackMuteActions = ReturnType<typeof useStackMuteActions>;
|
||||
type LabelMuteActions = ReturnType<typeof useLabelMuteActions>;
|
||||
type NodeMuteActions = ReturnType<typeof useNodeMuteActions>;
|
||||
|
||||
export function StackMuteSubmenu({ actions }: { actions: StackMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Mute
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelMuteSubmenu({ actions }: { actions: LabelMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Mute
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeMuteSubmenu({ actions }: { actions: NodeMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Mute
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute node notifications</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteUpdates}>Mute update notifications for this node</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts on this node</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage node mute rules</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelGroupMuteKebab({
|
||||
actions,
|
||||
}: {
|
||||
actions: LabelMuteActions;
|
||||
}) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Label mute actions"
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded text-stat-icon hover:text-foreground hover:bg-glass-highlight/40 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityMuteKebab({ actions }: { actions: StackMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mute stack notifications"
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<BellOff className="h-3 w-3" strokeWidth={1.5} />
|
||||
mute
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, BellOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
@@ -13,13 +13,17 @@ import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { labelMuteAllDraft, type MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { useCanMuteNotifications } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface LabelsSectionProps {
|
||||
onLabelsChanged?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
const { can } = useAuth();
|
||||
export function LabelsSection({ onLabelsChanged, onOpenMuteRulesWithPrefill }: LabelsSectionProps = {}) {
|
||||
const { can, isAdmin } = useAuth();
|
||||
const canMute = useCanMuteNotifications();
|
||||
// Mirrors the backend `requirePermission('stack:edit')` guard on
|
||||
// POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from
|
||||
// clicking through to a guaranteed 403.
|
||||
@@ -161,6 +165,17 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{canMute && onOpenMuteRulesWithPrefill && isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Mute notifications for this label"
|
||||
onClick={() => onOpenMuteRulesWithPrefill(labelMuteAllDraft(label.id, label.name))}
|
||||
>
|
||||
<BellOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{canMutate && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import type { ComboboxOption } from '@/components/ui/combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import type { NotificationCategory } from '@/components/dashboard/types';
|
||||
import type { Label as StackLabel } from '@/components/label-types';
|
||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||
import { emitMuteRulesChanged, type MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { useMuteRulesRefresh } from '@/hooks/useMuteRulesRefresh';
|
||||
import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
|
||||
type NotificationLevel = 'info' | 'warning' | 'error';
|
||||
type AppliesTo = 'bell' | 'external' | 'both';
|
||||
type ExpirationPreset = 'forever' | '1h' | '24h' | 'custom';
|
||||
|
||||
interface NotificationSuppressionRule {
|
||||
id: number;
|
||||
name: string;
|
||||
node_id: number | null;
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: NotificationCategory[] | null;
|
||||
levels: NotificationLevel[] | null;
|
||||
applies_to: AppliesTo;
|
||||
enabled: boolean;
|
||||
expires_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<NotificationLevel, string> = {
|
||||
info: 'Info',
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const APPLIES_TO_LABELS: Record<AppliesTo, string> = {
|
||||
bell: 'Bell only',
|
||||
external: 'External only',
|
||||
both: 'Bell and external',
|
||||
};
|
||||
|
||||
function expirationFromPreset(preset: ExpirationPreset, customMs: number | null): number | null {
|
||||
if (preset === 'forever') return null;
|
||||
if (preset === '1h') return Date.now() + 3_600_000;
|
||||
if (preset === '24h') return Date.now() + 86_400_000;
|
||||
return customMs;
|
||||
}
|
||||
|
||||
function presetFromExpiresAt(expires_at: number | null): { preset: ExpirationPreset; customMs: number | null } {
|
||||
if (expires_at == null) return { preset: 'forever', customMs: null };
|
||||
return { preset: 'custom', customMs: expires_at };
|
||||
}
|
||||
|
||||
function formatExpiry(expires_at: number | null): string {
|
||||
if (expires_at == null) return 'Never';
|
||||
if (expires_at <= Date.now()) return 'Expired';
|
||||
return new Date(expires_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function applyDraftToForm(
|
||||
draft: MuteRuleDraft,
|
||||
setters: {
|
||||
setFormName: (v: string) => void;
|
||||
setFormNodeId: (v: number | null) => void;
|
||||
setFormStacks: (v: string[]) => void;
|
||||
setFormLabelIds: (v: number[]) => void;
|
||||
setFormCategories: (v: NotificationCategory[]) => void;
|
||||
setFormLevels: (v: NotificationLevel[]) => void;
|
||||
setFormAppliesTo: (v: AppliesTo) => void;
|
||||
setFormEnabled: (v: boolean) => void;
|
||||
},
|
||||
) {
|
||||
setters.setFormName(draft.name);
|
||||
setters.setFormNodeId(draft.node_id ?? null);
|
||||
setters.setFormStacks(draft.stack_patterns ?? []);
|
||||
setters.setFormLabelIds(draft.label_ids ?? []);
|
||||
setters.setFormCategories(draft.categories ?? []);
|
||||
setters.setFormLevels(draft.levels ?? []);
|
||||
setters.setFormAppliesTo(draft.applies_to ?? 'both');
|
||||
setters.setFormEnabled(draft.enabled ?? true);
|
||||
}
|
||||
|
||||
interface NotificationSuppressionSectionProps {
|
||||
prefill?: MuteRuleDraft | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function NotificationSuppressionSection({
|
||||
prefill = null,
|
||||
onPrefillConsumed,
|
||||
}: NotificationSuppressionSectionProps) {
|
||||
const { nodes } = useNodes();
|
||||
const [rules, setRules] = useState<NotificationSuppressionRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [deleteRuleId, setDeleteRuleId] = useState<number | null>(null);
|
||||
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
|
||||
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
|
||||
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formNodeId, setFormNodeId] = useState<number | null>(null);
|
||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
||||
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
||||
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
|
||||
const [formAppliesTo, setFormAppliesTo] = useState<AppliesTo>('both');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
|
||||
const [formCustomExpiry, setFormCustomExpiry] = useState('');
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/notification-suppression-rules');
|
||||
if (res.ok) setRules(await res.json());
|
||||
} catch {
|
||||
toast.error('Failed to load mute rules.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
if (res.ok) {
|
||||
const data: string[] = await res.json();
|
||||
setStackOptions(data.map((s) => ({ value: s, label: s })));
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}, []);
|
||||
|
||||
const fetchLabels = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/labels');
|
||||
if (res.ok) setLabelOptions(await res.json());
|
||||
} catch { /* non-critical */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([fetchRules(), fetchStacks(), fetchLabels()]);
|
||||
}, [fetchRules, fetchStacks, fetchLabels]);
|
||||
|
||||
useMuteRulesRefresh(fetchRules);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefill) return;
|
||||
applyDraftToForm(prefill, {
|
||||
setFormName,
|
||||
setFormNodeId,
|
||||
setFormStacks,
|
||||
setFormLabelIds,
|
||||
setFormCategories,
|
||||
setFormLevels,
|
||||
setFormAppliesTo,
|
||||
setFormEnabled,
|
||||
});
|
||||
setFormExpirationPreset('forever');
|
||||
setFormCustomExpiry('');
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
onPrefillConsumed?.();
|
||||
}, [prefill, onPrefillConsumed]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName('');
|
||||
setFormNodeId(null);
|
||||
setFormStacks([]);
|
||||
setFormLabelIds([]);
|
||||
setFormCategories([]);
|
||||
setFormLevels([]);
|
||||
setFormAppliesTo('both');
|
||||
setFormEnabled(true);
|
||||
setFormExpirationPreset('forever');
|
||||
setFormCustomExpiry('');
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const startEdit = (rule: NotificationSuppressionRule) => {
|
||||
const { preset, customMs } = presetFromExpiresAt(rule.expires_at);
|
||||
setEditingId(rule.id);
|
||||
setFormName(rule.name);
|
||||
setFormNodeId(rule.node_id);
|
||||
setFormStacks([...rule.stack_patterns]);
|
||||
setFormLabelIds(rule.label_ids ? [...rule.label_ids] : []);
|
||||
setFormCategories(rule.categories ? [...rule.categories] : []);
|
||||
setFormLevels(rule.levels ? [...rule.levels] : []);
|
||||
setFormAppliesTo(rule.applies_to);
|
||||
setFormEnabled(rule.enabled);
|
||||
setFormExpirationPreset(preset);
|
||||
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
||||
const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null;
|
||||
if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) {
|
||||
toast.error('Choose a valid custom expiration date.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: formName.trim(),
|
||||
node_id: formNodeId,
|
||||
stack_patterns: formStacks,
|
||||
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
|
||||
categories: formCategories.length > 0 ? formCategories : null,
|
||||
levels: formLevels.length > 0 ? formLevels : null,
|
||||
applies_to: formAppliesTo,
|
||||
enabled: formEnabled,
|
||||
expires_at: expirationFromPreset(formExpirationPreset, customMs),
|
||||
};
|
||||
|
||||
const url = editingId
|
||||
? `/notification-suppression-rules/${editingId}`
|
||||
: '/notification-suppression-rules';
|
||||
const res = await apiFetch(url, {
|
||||
method: editingId ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(editingId ? 'Mute rule updated.' : 'Mute rule created.');
|
||||
emitMuteRulesChanged();
|
||||
resetForm();
|
||||
fetchRules();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Something went wrong.');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteRuleId == null) return;
|
||||
try {
|
||||
const res = await apiFetch(`/notification-suppression-rules/${deleteRuleId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
toast.success('Mute rule deleted.');
|
||||
emitMuteRulesChanged();
|
||||
fetchRules();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Something went wrong.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setDeleteRuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (rule: NotificationSuppressionRule) => {
|
||||
try {
|
||||
const res = await apiFetch(`/notification-suppression-rules/${rule.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !rule.enabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
emitMuteRulesChanged();
|
||||
fetchRules();
|
||||
}
|
||||
else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Something went wrong.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error.');
|
||||
}
|
||||
};
|
||||
|
||||
const addStack = (stackName: string) => {
|
||||
if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]);
|
||||
};
|
||||
const removeStack = (stackName: string) => setFormStacks((prev) => prev.filter((s) => s !== stackName));
|
||||
const addLabel = (idStr: string) => {
|
||||
const id = Number(idStr);
|
||||
if (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]);
|
||||
};
|
||||
const removeLabel = (id: number) => setFormLabelIds((prev) => prev.filter((l) => l !== id));
|
||||
const addCategory = (cat: string) => {
|
||||
const c = cat as NotificationCategory;
|
||||
if (c && !formCategories.includes(c)) setFormCategories((prev) => [...prev, c]);
|
||||
};
|
||||
const removeCategory = (cat: NotificationCategory) => setFormCategories((prev) => prev.filter((c) => c !== cat));
|
||||
const addLevel = (level: string) => {
|
||||
const l = level as NotificationLevel;
|
||||
if (l && !formLevels.includes(l)) setFormLevels((prev) => [...prev, l]);
|
||||
};
|
||||
const removeLevel = (level: NotificationLevel) => setFormLevels((prev) => prev.filter((x) => x !== level));
|
||||
|
||||
const enabledCount = rules.filter((r) => r.enabled && (r.expires_at == null || r.expires_at > Date.now())).length;
|
||||
useMastheadStats(
|
||||
loading ? null : [
|
||||
{ label: 'RULES', value: `${rules.length}` },
|
||||
{ label: 'ACTIVE', value: `${enabledCount}`, tone: enabledCount > 0 ? 'value' : 'subtitle' },
|
||||
],
|
||||
);
|
||||
|
||||
const availableStackOptions = stackOptions.filter((o) => !formStacks.includes(o.value));
|
||||
const availableLabelOptions = useMemo<ComboboxOption[]>(
|
||||
() => labelOptions.filter((l) => !formLabelIds.includes(l.id)).map((l) => ({ value: String(l.id), label: l.name })),
|
||||
[labelOptions, formLabelIds],
|
||||
);
|
||||
const availableCategoryOptions = useMemo<ComboboxOption[]>(
|
||||
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[])
|
||||
.filter((c) => !formCategories.includes(c))
|
||||
.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })),
|
||||
[formCategories],
|
||||
);
|
||||
const availableLevelOptions = useMemo<ComboboxOption[]>(
|
||||
() => (['info', 'warning', 'error'] as NotificationLevel[])
|
||||
.filter((l) => !formLevels.includes(l))
|
||||
.map((l) => ({ value: l, label: LEVEL_LABELS[l] })),
|
||||
[formLevels],
|
||||
);
|
||||
|
||||
const deleteTarget = deleteRuleId != null ? rules.find((r) => r.id === deleteRuleId) : null;
|
||||
|
||||
return (
|
||||
<CapabilityGate capability="notification-suppression" featureName="Mute Rules">
|
||||
<div className="space-y-6">
|
||||
<SettingsCallout
|
||||
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
|
||||
title="Mute rules vs routing"
|
||||
subtitle="Routing sends matching alerts to another channel. Mute rules hide or drop delivery to the bell, external channels, or both. Events still appear in stack activity history."
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<Plus className="w-4 h-4" /> Add mute rule
|
||||
</SettingsPrimaryButton>
|
||||
</div>
|
||||
|
||||
<Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
|
||||
<ModalHeader
|
||||
kicker={editingId ? 'MUTE RULES · EDIT RULE' : 'MUTE RULES · NEW RULE'}
|
||||
title={editingId ? 'Edit mute rule' : 'New mute rule'}
|
||||
description="Match alerts by node, stack, label, category, or severity, then choose where to mute delivery."
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="e.g. Mute staging deploy noise" value={formName} onChange={(e) => setFormName(e.target.value)} maxLength={100} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node scope</Label>
|
||||
<Select
|
||||
value={formNodeId === null ? 'any' : String(formNodeId)}
|
||||
onValueChange={(v) => setFormNodeId(v === 'any' ? null : Number(v))}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any node</SelectItem>
|
||||
{nodes.map((n) => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Add a stack..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
|
||||
{formStacks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formStacks.map((s) => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
|
||||
{s}
|
||||
<button type="button" onClick={() => removeStack(s)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableLabelOptions} value="" onValueChange={addLabel} placeholder="Add a label..." searchPlaceholder="Search labels..." emptyText="No labels found." />
|
||||
{formLabelIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formLabelIds.map((id) => {
|
||||
const lbl = labelOptions.find((l) => l.id === id);
|
||||
return (
|
||||
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{lbl?.name ?? `Label ${id}`}
|
||||
<button type="button" onClick={() => removeLabel(id)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableCategoryOptions} value="" onValueChange={addCategory} placeholder="Add a category..." searchPlaceholder="Search categories..." emptyText="No categories found." />
|
||||
{formCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formCategories.map((c) => (
|
||||
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{CATEGORY_LABELS[c]}
|
||||
<button type="button" onClick={() => removeCategory(c)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableLevelOptions} value="" onValueChange={addLevel} placeholder="Add a severity..." searchPlaceholder="Search..." emptyText="No levels left." />
|
||||
{formLevels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formLevels.map((l) => (
|
||||
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
|
||||
{LEVEL_LABELS[l]}
|
||||
<button type="button" onClick={() => removeLevel(l)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Leave matchers blank to match any value. All non-empty filters must match (AND).</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Apply to</Label>
|
||||
<SegmentedControl
|
||||
value={formAppliesTo}
|
||||
options={[
|
||||
{ value: 'bell', label: 'Bell' },
|
||||
{ value: 'external', label: 'External' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
]}
|
||||
onChange={setFormAppliesTo}
|
||||
ariaLabel="Suppression target"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Expiration</Label>
|
||||
<Select value={formExpirationPreset} onValueChange={(v) => setFormExpirationPreset(v as ExpirationPreset)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="forever">Forever</SelectItem>
|
||||
<SelectItem value="1h">1 hour</SelectItem>
|
||||
<SelectItem value="24h">24 hours</SelectItem>
|
||||
<SelectItem value="custom">Custom date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formExpirationPreset === 'custom' && (
|
||||
<Input type="datetime-local" value={formCustomExpiry} onChange={(e) => setFormCustomExpiry(e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
|
||||
<span className="text-sm text-stat-value select-none">
|
||||
{formEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>}
|
||||
primary={
|
||||
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
|
||||
</SettingsPrimaryButton>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rules.length === 0 && (
|
||||
<SettingsCallout
|
||||
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
|
||||
title="No mute rules configured"
|
||||
subtitle="Alerts follow your routing and global channels unless a mute rule matches."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && rules.map((rule) => (
|
||||
<div key={rule.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<BellOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm truncate">{rule.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{APPLIES_TO_LABELS[rule.applies_to]}</Badge>
|
||||
{rule.node_id !== null && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
|
||||
{nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`}
|
||||
</Badge>
|
||||
)}
|
||||
{!rule.enabled && <Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Disabled</Badge>}
|
||||
{rule.expires_at != null && rule.expires_at <= Date.now() && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(rule)} title="Edit"><Pencil className="w-4 h-4" strokeWidth={1.5} /></Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" title="Delete" onClick={() => setDeleteRuleId(rule.id)}>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{rule.stack_patterns.map((s) => <Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>)}
|
||||
{rule.label_ids?.map((id) => {
|
||||
const lbl = labelOptions.find((l) => l.id === id);
|
||||
return <Badge key={id} variant="outline" className="text-[10px]">{lbl?.name ?? `label:${id}`}</Badge>;
|
||||
})}
|
||||
{rule.categories?.map((c) => <Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c as NotificationCategory] ?? c}</Badge>)}
|
||||
{rule.levels?.map((l) => <Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>)}
|
||||
{rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && (
|
||||
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ConfirmModal
|
||||
open={deleteRuleId != null}
|
||||
onOpenChange={(open) => { if (!open) setDeleteRuleId(null); }}
|
||||
variant="destructive"
|
||||
kicker="MUTE RULES · DELETE · IRREVERSIBLE"
|
||||
title="Delete mute rule"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Deletes <span className="font-medium text-stat-value">{deleteTarget?.name ?? 'this rule'}</span>. Matching alerts will deliver normally again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
scopeLabel,
|
||||
} from './index';
|
||||
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsSectionContent } from './SettingsSectionContent';
|
||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||
@@ -31,6 +32,9 @@ import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsCon
|
||||
interface SettingsPageProps {
|
||||
currentSection: SectionId;
|
||||
onSectionChange: (section: SectionId) => void;
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
export function SettingsPage(props: SettingsPageProps) {
|
||||
@@ -41,7 +45,13 @@ export function SettingsPage(props: SettingsPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProps) {
|
||||
function SettingsPageInner({
|
||||
currentSection,
|
||||
onSectionChange,
|
||||
muteRulePrefill = null,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
}: SettingsPageProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
@@ -197,6 +207,9 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
sectionId={safeSection}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
showDescription
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getSettingsItem,
|
||||
} from './index';
|
||||
import type { SectionId } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { SectionGate } from './SectionGate';
|
||||
|
||||
@@ -44,6 +45,9 @@ const LabelsSection = lazy(() =>
|
||||
const NotificationRoutingSection = lazy(() =>
|
||||
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
|
||||
);
|
||||
const NotificationSuppressionSection = lazy(() =>
|
||||
import('./NotificationSuppressionSection').then(m => ({ default: m.NotificationSuppressionSection })),
|
||||
);
|
||||
const CloudBackupSection = lazy(() =>
|
||||
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
|
||||
);
|
||||
@@ -72,6 +76,9 @@ function SectionSkeleton() {
|
||||
function renderSection(
|
||||
sectionId: SectionId,
|
||||
onDirtyChange: (section: SectionId, dirty: boolean) => void,
|
||||
muteRulePrefill: MuteRuleDraft | null | undefined,
|
||||
onMutePrefillConsumed: (() => void) | undefined,
|
||||
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
|
||||
) {
|
||||
switch (sectionId) {
|
||||
case 'account': return <AccountSection />;
|
||||
@@ -81,7 +88,7 @@ function renderSection(
|
||||
case 'sso': return <SSOSection />;
|
||||
case 'api-tokens': return <ApiTokensSection />;
|
||||
case 'registries': return <RegistriesSection />;
|
||||
case 'labels': return <LabelsSection />;
|
||||
case 'labels': return <LabelsSection onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} />;
|
||||
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
|
||||
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
|
||||
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
|
||||
@@ -89,6 +96,12 @@ function renderSection(
|
||||
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
|
||||
case 'notifications': return <NotificationsSection />;
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'notification-suppression': return (
|
||||
<NotificationSuppressionSection
|
||||
prefill={muteRulePrefill}
|
||||
onPrefillConsumed={onMutePrefillConsumed}
|
||||
/>
|
||||
);
|
||||
case 'webhooks': return <WebhooksSection />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
|
||||
@@ -109,6 +122,9 @@ interface SettingsSectionContentProps {
|
||||
onDirtyChange: (section: SectionId, dirty: boolean) => void;
|
||||
/** Render the section's lead description paragraph above the content. */
|
||||
showDescription?: boolean;
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,14 +133,18 @@ interface SettingsSectionContentProps {
|
||||
* the desktop SettingsPage and the mobile settings screen so the section switch,
|
||||
* lazy splitting, and gating live in exactly one place.
|
||||
*/
|
||||
export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) {
|
||||
export function SettingsSectionContent({
|
||||
sectionId,
|
||||
onDirtyChange,
|
||||
showDescription,
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
}: SettingsSectionContentProps) {
|
||||
const item = getSettingsItem(sectionId);
|
||||
// Memoize the section element so unrelated re-renders of the host page (the
|
||||
// command palette opening, a dirty-flag toggle) do not re-render the active
|
||||
// section. onDirtyChange is stable from both call sites.
|
||||
const element = useMemo(
|
||||
() => renderSection(sectionId, onDirtyChange),
|
||||
[sectionId, onDirtyChange],
|
||||
() => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill),
|
||||
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('settings registry', () => {
|
||||
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
||||
expect(byId.get('notifications')?.label).toBe('Channels');
|
||||
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
|
||||
expect(byId.get('notification-suppression')?.label).toBe('Mute Rules');
|
||||
});
|
||||
|
||||
it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => {
|
||||
|
||||
@@ -220,6 +220,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
id: 'notification-suppression',
|
||||
group: 'notifications',
|
||||
label: 'Mute Rules',
|
||||
description: 'Mute or drop matching alerts from the bell, external channels, or both.',
|
||||
keywords: ['mute', 'suppress', 'silence', 'hide', 'bell', 'rules'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
// Automation
|
||||
{
|
||||
id: 'image-updates',
|
||||
|
||||
@@ -69,6 +69,7 @@ export type SectionId =
|
||||
| 'app-store'
|
||||
| 'stacks'
|
||||
| 'notification-routing'
|
||||
| 'notification-suppression'
|
||||
| 'recovery'
|
||||
| 'support'
|
||||
| 'about';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
import { Check, Plus, BellOff } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
|
||||
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
|
||||
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
<ContextMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</ContextMenuItem>
|
||||
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
|
||||
<span className="text-xs">Mute label…</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[200px]">
|
||||
{ctx.labels.map(label => (
|
||||
<ContextMenuSub key={label.id}>
|
||||
<ContextMenuSubTrigger>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="font-mono text-[12px] ml-2">{label.name}</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => ctx.muteLabelAll(label.id, label.name)}>
|
||||
<span className="text-xs">Mute notifications for this label</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => ctx.muteLabelExternal(label.id, label.name)}>
|
||||
<span className="text-xs">Mute external alerts for this label</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => ctx.muteLabelLowPriority(label.id, label.name)}>
|
||||
<span className="text-xs">Mute low-priority stack alerts</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => ctx.openLabelMuteRules(label.id, label.name)}>
|
||||
<span className="text-xs">Manage label mute rules</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
@@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: {
|
||||
item: MenuItem;
|
||||
MenuSub: typeof ContextMenuSub;
|
||||
MenuSubTrigger: typeof ContextMenuSubTrigger;
|
||||
MenuSubContent: typeof ContextMenuSubContent;
|
||||
MenuItem: typeof ContextMenuItem;
|
||||
}) {
|
||||
return (
|
||||
<MenuSub>
|
||||
<MenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubContent className="min-w-[220px]">
|
||||
{item.subItems?.map((sub) => (
|
||||
<MenuItem key={sub.id} onClick={() => sub.onSelect()}>
|
||||
<span className="text-xs">{sub.label}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuSubContent>
|
||||
</MenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
||||
if (item.subItems?.length) {
|
||||
return (
|
||||
<SimpleSub
|
||||
key={item.id}
|
||||
item={item}
|
||||
MenuSub={ContextMenuSub}
|
||||
MenuSubTrigger={ContextMenuSubTrigger}
|
||||
MenuSubContent={ContextMenuSubContent}
|
||||
MenuItem={ContextMenuItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={item.id}
|
||||
|
||||
@@ -10,10 +10,11 @@ interface StackGroupProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
variant?: 'default' | 'pinned';
|
||||
headerActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) {
|
||||
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', headerActions, children }: StackGroupProps) {
|
||||
const isPinned = variant === 'pinned';
|
||||
const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle';
|
||||
return (
|
||||
@@ -32,6 +33,7 @@ export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'd
|
||||
{isPinned ? '★ ' : ''}{label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{headerActions}
|
||||
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
|
||||
{collapsed
|
||||
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, Check, Plus } from 'lucide-react';
|
||||
import { MoreVertical, Check, Plus, BellOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</DropdownMenuItem>
|
||||
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
|
||||
<span className="text-xs">Mute label…</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[200px]">
|
||||
{ctx.labels.map(label => (
|
||||
<DropdownMenuSub key={label.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="font-mono text-[12px] ml-2">{label.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => ctx.muteLabelAll(label.id, label.name)}>
|
||||
<span className="text-xs">Mute notifications for this label</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => ctx.muteLabelExternal(label.id, label.name)}>
|
||||
<span className="text-xs">Mute external alerts for this label</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => ctx.muteLabelLowPriority(label.id, label.name)}>
|
||||
<span className="text-xs">Mute low-priority stack alerts</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => ctx.openLabelMuteRules(label.id, label.name)}>
|
||||
<span className="text-xs">Manage label mute rules</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
@@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: {
|
||||
item: MenuItem;
|
||||
MenuSub: typeof DropdownMenuSub;
|
||||
MenuSubTrigger: typeof DropdownMenuSubTrigger;
|
||||
MenuSubContent: typeof DropdownMenuSubContent;
|
||||
MenuItem: typeof DropdownMenuItem;
|
||||
}) {
|
||||
return (
|
||||
<MenuSub>
|
||||
<MenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubContent className="min-w-[220px]">
|
||||
{item.subItems?.map((sub) => (
|
||||
<MenuItem key={sub.id} onSelect={() => sub.onSelect()}>
|
||||
<span className="text-xs">{sub.label}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuSubContent>
|
||||
</MenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
||||
if (item.subItems?.length) {
|
||||
return (
|
||||
<SimpleSub
|
||||
key={item.id}
|
||||
item={item}
|
||||
MenuSub={DropdownMenuSub}
|
||||
MenuSubTrigger={DropdownMenuSubTrigger}
|
||||
MenuSubContent={DropdownMenuSubContent}
|
||||
MenuItem={DropdownMenuItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { StackContextMenu } from './StackContextMenu';
|
||||
import { StackKebabMenu } from './StackKebabMenu';
|
||||
import { EmptyStackState } from './EmptyStackState';
|
||||
import type { StackMenuCtx, FilterChip } from './sidebar-types';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface RemoteNodeResult {
|
||||
nodeId: number;
|
||||
@@ -54,6 +57,7 @@ export interface StackListProps {
|
||||
// Open the create dialog on a starting mode. Present only when the user can
|
||||
// create stacks; drives the zero-stacks empty state.
|
||||
onOpenCreate?: (mode: 'import' | 'empty') => void;
|
||||
openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
interface BuiltGroup {
|
||||
@@ -63,6 +67,8 @@ interface BuiltGroup {
|
||||
count: number;
|
||||
files: string[];
|
||||
variant?: 'default' | 'pinned';
|
||||
labelId?: number;
|
||||
labelName?: string;
|
||||
}
|
||||
|
||||
function buildGroups(
|
||||
@@ -101,6 +107,8 @@ function buildGroups(
|
||||
result.push({
|
||||
kind: 'labeled', id: `label:${label.id}`,
|
||||
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
|
||||
labelId: label.id,
|
||||
labelName: label.name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +119,19 @@ function buildGroups(
|
||||
return result;
|
||||
}
|
||||
|
||||
function LabelGroupMuteActions({
|
||||
labelId,
|
||||
labelName,
|
||||
openMuteRulesWithPrefill,
|
||||
}: {
|
||||
labelId: number;
|
||||
labelName: string;
|
||||
openMuteRulesWithPrefill: (draft: MuteRuleDraft) => void;
|
||||
}) {
|
||||
const actions = useLabelMuteActions(labelId, labelName, openMuteRulesWithPrefill);
|
||||
return <LabelGroupMuteKebab actions={actions} />;
|
||||
}
|
||||
|
||||
interface StackListBulkProps {
|
||||
bulkMode: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
@@ -125,6 +146,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
bulkMode, selectedFiles, onToggleSelect,
|
||||
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
|
||||
filterChip, onOpenCreate,
|
||||
openMuteRulesWithPrefill,
|
||||
} = props;
|
||||
|
||||
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
|
||||
@@ -164,6 +186,17 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
collapsed={isCollapsed(g.id)}
|
||||
onToggle={() => toggleCollapse(g.id)}
|
||||
variant={g.variant}
|
||||
headerActions={
|
||||
g.kind === 'labeled' && g.labelId != null && g.labelName && openMuteRulesWithPrefill
|
||||
? (
|
||||
<LabelGroupMuteActions
|
||||
labelId={g.labelId}
|
||||
labelName={g.labelName}
|
||||
openMuteRulesWithPrefill={openMuteRulesWithPrefill}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{g.files.map(file => {
|
||||
const ctx = buildMenuCtx(file);
|
||||
|
||||
@@ -46,6 +46,15 @@ export interface StackMenuCtx {
|
||||
unpin: () => void;
|
||||
toggleLabel: (labelId: number) => void;
|
||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
|
||||
canMuteNotifications: boolean;
|
||||
muteStackAll: () => void;
|
||||
muteStackDeploySuccess: () => void;
|
||||
muteStackMonitor: () => void;
|
||||
openStackMuteRules: () => void;
|
||||
muteLabelAll: (labelId: number, labelName: string) => void;
|
||||
muteLabelExternal: (labelId: number, labelName: string) => void;
|
||||
muteLabelLowPriority: (labelId: number, labelName: string) => void;
|
||||
openLabelMuteRules: (labelId: number, labelName: string) => void;
|
||||
openLabelManager: () => void;
|
||||
openScheduleTask: () => void;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
@@ -13,6 +15,12 @@ import type { NotificationItem } from '@/components/dashboard/types';
|
||||
type ActivityLevel = 'info' | 'warning' | 'error';
|
||||
const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error'];
|
||||
|
||||
interface SuppressionMatchSnapshot {
|
||||
rules: { id: number; name: string }[];
|
||||
bellSuppressed: boolean;
|
||||
externalSuppressed: boolean;
|
||||
}
|
||||
|
||||
interface ActivityEvent {
|
||||
id: number;
|
||||
level: ActivityLevel;
|
||||
@@ -21,6 +29,7 @@ interface ActivityEvent {
|
||||
timestamp: number;
|
||||
stack_name?: string;
|
||||
actor_username?: string | null;
|
||||
suppression_match?: string | SuppressionMatchSnapshot | null;
|
||||
}
|
||||
|
||||
interface StackActivityTimelineProps {
|
||||
@@ -61,6 +70,25 @@ function formatActor(actor: string): { label: string; isSystem: boolean } {
|
||||
return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem };
|
||||
}
|
||||
|
||||
function parseSuppressionMatch(raw: ActivityEvent['suppression_match']): SuppressionMatchSnapshot | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SuppressionMatchSnapshot;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
if (!Array.isArray(parsed.rules)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function suppressionTooltip(match: SuppressionMatchSnapshot): string {
|
||||
const names = match.rules.map((r) => r.name).filter(Boolean);
|
||||
const ruleText = names.length > 0 ? names.join(', ') : 'Unnamed rule';
|
||||
return `Matched rule: ${ruleText}`;
|
||||
}
|
||||
|
||||
function isActivityEvent(value: unknown): value is ActivityEvent {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
@@ -261,6 +289,10 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
{g.events.map(e => {
|
||||
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
|
||||
const actor = e.actor_username ? formatActor(e.actor_username) : null;
|
||||
const suppression = parseSuppressionMatch(e.suppression_match);
|
||||
const showSuppressed = Boolean(
|
||||
suppression && (suppression.bellSuppressed || suppression.externalSuppressed),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
@@ -275,6 +307,18 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
|
||||
</span>
|
||||
)}
|
||||
{showSuppressed && suppression && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="ml-1.5 align-middle text-[9px] font-mono uppercase tracking-wide px-1 py-0 h-4">
|
||||
Suppressed
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="font-mono text-[10px] max-w-xs">
|
||||
{suppressionTooltip(suppression)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const CAPABILITIES = [
|
||||
'network-topology',
|
||||
'notifications',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'host-console',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||
import type { NotificationCategory, NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
export type MuteRuleLevel = 'info' | 'warning' | 'error';
|
||||
export type MuteRuleAppliesTo = 'bell' | 'external' | 'both';
|
||||
|
||||
export type MuteRuleDraft = {
|
||||
name: string;
|
||||
node_id?: number | null;
|
||||
stack_patterns?: string[];
|
||||
label_ids?: number[] | null;
|
||||
categories?: NotificationCategory[] | null;
|
||||
levels?: MuteRuleLevel[] | null;
|
||||
applies_to?: MuteRuleAppliesTo;
|
||||
enabled?: boolean;
|
||||
expires_at?: number | null;
|
||||
};
|
||||
|
||||
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
|
||||
|
||||
export function emitMuteRulesChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(MUTE_RULES_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
const DEFAULT_BODY = {
|
||||
applies_to: 'both' as MuteRuleAppliesTo,
|
||||
enabled: true,
|
||||
expires_at: null as number | null,
|
||||
node_id: null as number | null,
|
||||
stack_patterns: [] as string[],
|
||||
label_ids: null as number[] | null,
|
||||
categories: null as NotificationCategory[] | null,
|
||||
levels: null as MuteRuleLevel[] | null,
|
||||
};
|
||||
|
||||
export async function createMuteRule(draft: MuteRuleDraft): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await apiFetch('/notification-suppression-rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...DEFAULT_BODY, ...draft }),
|
||||
});
|
||||
if (res.ok) {
|
||||
emitMuteRulesChanged();
|
||||
return { ok: true };
|
||||
}
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as { error?: string })?.error || 'Failed to create mute rule.' };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMuteRuleWithToast(
|
||||
draft: MuteRuleDraft,
|
||||
successMessage = 'Mute rule created. Open Settings · Mute Rules to edit it.',
|
||||
): Promise<boolean> {
|
||||
const result = await createMuteRule(draft);
|
||||
if (result.ok) {
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
}
|
||||
toast.error(result.error || 'Failed to create mute rule.');
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stackMuteAllDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName}`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
};
|
||||
}
|
||||
|
||||
export function stackMuteDeploySuccessDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName} deploy success`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
categories: ['deploy_success'],
|
||||
};
|
||||
}
|
||||
|
||||
export function stackMuteMonitorDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName} monitor alerts`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
categories: ['monitor_alert'],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteAllDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName}`,
|
||||
node_id: nodeId,
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteUpdatesDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName} update notifications`,
|
||||
node_id: nodeId,
|
||||
categories: ['image_update_available', 'node_update_available', 'update_started'],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteMonitorDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName} monitor alerts`,
|
||||
node_id: nodeId,
|
||||
categories: ['monitor_alert'],
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteAllDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
applies_to: 'both',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteExternalDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute external for label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
applies_to: 'external',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteLowPriorityDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute low-priority alerts for label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
levels: ['info', 'warning'],
|
||||
};
|
||||
}
|
||||
|
||||
export type BellMuteMode = 'category' | 'similar' | 'stack';
|
||||
|
||||
export function muteDraftFromNotification(
|
||||
notif: NotificationItem,
|
||||
mode: BellMuteMode,
|
||||
): MuteRuleDraft | null {
|
||||
const categoryLabel = notif.category ? CATEGORY_LABELS[notif.category as NotificationCategory] : 'alert';
|
||||
|
||||
if (mode === 'category' && notif.category) {
|
||||
return {
|
||||
name: `Mute ${categoryLabel}`,
|
||||
node_id: notif.nodeId ?? null,
|
||||
categories: [notif.category as NotificationCategory],
|
||||
};
|
||||
}
|
||||
if (mode === 'stack' && notif.stack_name) {
|
||||
return {
|
||||
name: `Mute ${notif.stack_name}`,
|
||||
node_id: notif.nodeId ?? null,
|
||||
stack_patterns: [notif.stack_name],
|
||||
};
|
||||
}
|
||||
if (mode === 'similar') {
|
||||
return {
|
||||
name: 'Mute similar alerts',
|
||||
node_id: notif.nodeId ?? null,
|
||||
categories: notif.category ? [notif.category as NotificationCategory] : null,
|
||||
levels: [notif.level],
|
||||
stack_patterns: notif.stack_name ? [notif.stack_name] : [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createMuteFromNotification(
|
||||
notif: NotificationItem,
|
||||
mode: BellMuteMode,
|
||||
): Promise<boolean> {
|
||||
const draft = muteDraftFromNotification(notif, mode);
|
||||
if (!draft) {
|
||||
toast.error('This notification cannot be muted with that shortcut.');
|
||||
return false;
|
||||
}
|
||||
return createMuteRuleWithToast(draft);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
||||
autoheal_triggered: 'Auto-heal',
|
||||
monitor_alert: 'Monitor alert',
|
||||
scan_finding: 'Scan finding',
|
||||
drift_detected: 'Drift detected',
|
||||
drift_resolved: 'Drift resolved',
|
||||
update_started: 'Update started',
|
||||
health_gate_passed: 'Health gate passed',
|
||||
health_gate_failed: 'Health gate failed',
|
||||
node_update_available: 'Node update',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user