From 0cf7fd1159399a81e573b1377b27468ac8997135 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Thu, 20 Apr 2023 10:32:30 +0200 Subject: [PATCH] [Security Solution] Support snoozing in rules table (#153083) **Addresses:** https://github.com/elastic/kibana/issues/147735 ## Summary The PR adds an ability to set rule snoozing in the rules management table. Screen recording: https://user-images.githubusercontent.com/3775283/229066538-5effc1af-f481-4749-a964-a071c4393c8f.mov ### Checklist - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- x-pack/plugins/alerting/common/index.ts | 1 + .../alerting/server/routes/find_rules.ts | 6 +- .../rule_management/api/api.test.ts | 51 ++++++ .../rule_management/api/api.ts | 29 ++- .../use_create_prebuilt_rules_mutation.ts | 3 + .../hooks/use_fetch_rules_snooze_settings.ts | 62 +++++++ .../components/rule_snooze_badge.tsx | 59 +++++++ .../components/translations.ts | 15 ++ .../rule_management/logic/types.ts | 18 ++ .../__mocks__/rules_table_context.tsx | 5 + .../rules_table/rules_table_context.test.tsx | 166 +++++++++++++++--- .../rules_table/rules_table_context.tsx | 98 ++++++++--- .../components/rules_table/use_columns.tsx | 17 ++ .../detection_engine/rules/translations.ts | 7 + .../notify_badge/notify_badge_with_api.tsx | 9 +- 15 files changed, 493 insertions(+), 53 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index f12abcde69660..8b4c04d15cfc4 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -57,6 +57,7 @@ export interface AlertingFrameworkHealth { export const LEGACY_BASE_ALERT_API_PATH = '/api/alerts'; export const BASE_ALERTING_API_PATH = '/api/alerting'; export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting'; +export const INTERNAL_ALERTING_API_FIND_RULES_PATH = `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_find`; export const ALERTS_FEATURE_ID = 'alerts'; export const MONITORING_HISTORY_LIMIT = 200; export const ENABLE_MAINTENANCE_WINDOWS = false; diff --git a/x-pack/plugins/alerting/server/routes/find_rules.ts b/x-pack/plugins/alerting/server/routes/find_rules.ts index 50bd9ad387d7d..04b18da1a1b0c 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.ts @@ -20,7 +20,7 @@ import { RuleTypeParams, AlertingRequestHandlerContext, BASE_ALERTING_API_PATH, - INTERNAL_BASE_ALERTING_API_PATH, + INTERNAL_ALERTING_API_FIND_RULES_PATH, } from '../types'; import { trackLegacyTerminology } from './lib/track_legacy_terminology'; @@ -136,7 +136,7 @@ const buildFindRulesRoute = ({ }) ) ); - if (path === `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_find`) { + if (path === INTERNAL_ALERTING_API_FIND_RULES_PATH) { router.post( { path, @@ -205,7 +205,7 @@ export const findInternalRulesRoute = ( buildFindRulesRoute({ excludeFromPublicApi: false, licenseState, - path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_find`, + path: INTERNAL_ALERTING_API_FIND_RULES_PATH, router, usageCounter, }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index fe111c13debec..b1a2d0f95417a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -35,6 +35,7 @@ import { previewRule, findRuleExceptionReferences, performBulkAction, + fetchRulesSnoozeSettings, } from './api'; const abortCtrl = new AbortController(); @@ -786,4 +787,54 @@ describe('Detections Rules API', () => { expect(result).toBe(fetchMockResult); }); }); + + describe('fetchRulesSnoozeSettings', () => { + beforeEach(() => { + fetchMock.mockClear(); + }); + + test('requests snooze settings of multiple rules by their IDs', () => { + fetchRulesSnoozeSettings({ ids: ['id1', 'id2'] }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + filter: 'alert.id:"alert:id1" or alert.id:"alert:id2"', + }), + }) + ); + }); + + test('requests the same number of rules as the number of ids provided', () => { + fetchRulesSnoozeSettings({ ids: ['id1', 'id2'] }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + per_page: 2, + }), + }) + ); + }); + + test('requests only snooze settings fields', () => { + fetchRulesSnoozeSettings({ ids: ['id1', 'id2'] }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + fields: JSON.stringify([ + 'muteAll', + 'activeSnoozes', + 'isSnoozedUntil', + 'snoozeSchedule', + ]), + }), + }) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 386dbf3c7b525..b8078421ce683 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -9,7 +9,7 @@ import type { CreateRuleExceptionListItemSchema, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; - +import { INTERNAL_ALERTING_API_FIND_RULES_PATH } from '@kbn/alerting-plugin/common'; import type { BulkInstallPackagesResponse } from '@kbn/fleet-plugin/common'; import { epmRouteService } from '@kbn/fleet-plugin/common'; import type { InstallPackageResponse } from '@kbn/fleet-plugin/common/types'; @@ -47,6 +47,7 @@ import type { CreateRulesProps, ExportDocumentsProps, FetchRuleProps, + FetchRuleSnoozingProps, FetchRulesProps, FetchRulesResponse, FindRulesReferencedByExceptionsProps, @@ -56,6 +57,7 @@ import type { PrePackagedRulesStatusResponse, PreviewRulesProps, Rule, + RulesSnoozeSettingsResponse, UpdateRulesProps, } from '../logic/types'; import { convertRulesFilterToKQL } from '../logic/utils'; @@ -184,6 +186,31 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => + KibanaServices.get().http.fetch( + INTERNAL_ALERTING_API_FIND_RULES_PATH, + { + method: 'GET', + query: { + filter: ids.map((x) => `alert.id:"alert:${x}"`).join(' or '), + fields: JSON.stringify(['muteAll', 'activeSnoozes', 'isSnoozedUntil', 'snoozeSchedule']), + per_page: ids.length, + }, + signal, + } + ); + export interface BulkActionSummary { failed: number; skipped: number; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts index 41bce8f0cc154..37001caf43b8c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts @@ -12,6 +12,7 @@ import { createPrepackagedRules } from '../api'; import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_management_filters_query'; +import { useInvalidateFetchRulesSnoozeSettingsQuery } from './use_fetch_rules_snooze_settings'; export const CREATE_PREBUILT_RULES_MUTATION_KEY = ['PUT', PREBUILT_RULES_URL]; @@ -19,6 +20,7 @@ export const useCreatePrebuiltRulesMutation = ( options?: UseMutationOptions ) => { const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); + const invalidateFetchRulesSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery(); const invalidatePrePackagedRulesStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); @@ -30,6 +32,7 @@ export const useCreatePrebuiltRulesMutation = ( // the number of rules might change after the installation invalidatePrePackagedRulesStatus(); invalidateFindRulesQuery(); + invalidateFetchRulesSnoozeSettings(); invalidateFetchRuleManagementFilters(); if (options?.onSettled) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts new file mode 100644 index 0000000000000..bdc101fe18644 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { INTERNAL_ALERTING_API_FIND_RULES_PATH } from '@kbn/alerting-plugin/common'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import type { RuleSnoozeSettings } from '../../logic'; +import { fetchRulesSnoozeSettings } from '../api'; +import { DEFAULT_QUERY_OPTIONS } from './constants'; + +const FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY = ['GET', INTERNAL_ALERTING_API_FIND_RULES_PATH]; + +/** + * A wrapper around useQuery provides default values to the underlying query, + * like query key, abortion signal. + * + * @param queryArgs - fetch rule snoozing settings ids + * @param queryOptions - react-query options + * @returns useQuery result + */ +export const useFetchRulesSnoozeSettings = ( + ids: string[], + queryOptions?: UseQueryOptions +) => { + return useQuery( + [...FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY, ...ids], + async ({ signal }) => { + const response = await fetchRulesSnoozeSettings({ ids, signal }); + + return response.data; + }, + { + ...DEFAULT_QUERY_OPTIONS, + ...queryOptions, + } + ); +}; + +/** + * We should use this hook to invalidate the cache. For example, rule + * snooze modification should lead to cache invalidation. + * + * @returns A rules cache invalidation callback + */ +export const useInvalidateFetchRulesSnoozeSettingsQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + /** + * Invalidate all queries that start with FIND_RULES_QUERY_KEY. This + * includes the in-memory query cache and paged query cache. + */ + queryClient.invalidateQueries(FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY, { + refetchType: 'active', + }); + }, [queryClient]); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx new file mode 100644 index 0000000000000..f2a06cd5475e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { useUserData } from '../../../detections/components/user_info'; +import { hasUserCRUDPermission } from '../../../common/utils/privileges'; +import { useKibana } from '../../../common/lib/kibana'; +import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../api/hooks/use_fetch_rules_snooze_settings'; +import { useRulesTableContext } from '../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import * as i18n from './translations'; + +interface RuleSnoozeBadgeProps { + id: string; // Rule SO's id (not ruleId) +} + +export function RuleSnoozeBadge({ id }: RuleSnoozeBadgeProps): JSX.Element { + const RulesListNotifyBadge = useKibana().services.triggersActionsUi.getRulesListNotifyBadge; + const [{ canUserCRUD }] = useUserData(); + const hasCRUDPermissions = hasUserCRUDPermission(canUserCRUD); + const { + state: { rulesSnoozeSettings }, + } = useRulesTableContext(); + const invalidateFetchRuleSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery(); + const rule = useMemo(() => { + const ruleSnoozeSettings = rulesSnoozeSettings.data[id]; + + return { + id: ruleSnoozeSettings?.id ?? '', + muteAll: ruleSnoozeSettings?.mute_all ?? false, + activeSnoozes: ruleSnoozeSettings?.active_snoozes ?? [], + isSnoozedUntil: ruleSnoozeSettings?.is_snoozed_until + ? new Date(ruleSnoozeSettings.is_snoozed_until) + : undefined, + snoozeSchedule: ruleSnoozeSettings?.snooze_schedule, + isEditable: hasCRUDPermissions, + }; + }, [id, rulesSnoozeSettings, hasCRUDPermissions]); + + if (rulesSnoozeSettings.isError) { + return ( + + + + ); + } + + return ( + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts new file mode 100644 index 0000000000000..1b98a9c6212eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const UNABLE_TO_FETCH_RULE_SNOOZE_SETTINGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.ruleSnoozeBadge.error.unableToFetch', + { + defaultMessage: 'Unable to fetch snooze settings', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index 0f93d66efbf6f..ca71fa2680f17 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; +import type { RuleSnooze } from '@kbn/alerting-plugin/common'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { RiskScore, @@ -217,6 +218,18 @@ export interface FetchRulesProps { signal?: AbortSignal; } +export interface RuleSnoozeSettings { + id: string; + mute_all: boolean; + snooze_schedule?: RuleSnooze; + active_snoozes?: string[]; + is_snoozed_until?: string; +} + +export interface RulesSnoozeSettingsResponse { + data: RuleSnoozeSettings[]; +} + export type SortingOptions = t.TypeOf; export const SortingOptions = t.type({ field: FindRulesSortField, @@ -244,6 +257,11 @@ export interface FetchRuleProps { signal?: AbortSignal; } +export interface FetchRuleSnoozingProps { + ids: string[]; + signal?: AbortSignal; +} + export interface BasicFetchProps { signal: AbortSignal; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx index 688b90c860ceb..8ab84b2e60a60 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx @@ -12,6 +12,11 @@ export const useRulesTableContextMock = { create: (): jest.Mocked => ({ state: { rules: [], + rulesSnoozeSettings: { + data: {}, + isLoading: false, + isError: false, + }, pagination: { page: 1, perPage: 20, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx index cb2eb08c5381b..22a3af8ff0814 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx @@ -9,7 +9,9 @@ import { renderHook } from '@testing-library/react-hooks'; import type { PropsWithChildren } from 'react'; import React from 'react'; import { useUiSetting$ } from '../../../../../common/lib/kibana'; +import type { Rule, RuleSnoozeSettings } from '../../../../rule_management/logic/types'; import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; +import { useFetchRulesSnoozeSettings } from '../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'; import type { RulesTableState } from './rules_table_context'; import { RulesTableContextProvider, useRulesTableContext } from './rules_table_context'; import { @@ -23,22 +25,51 @@ import { useRulesTableSavedState } from './use_rules_table_saved_state'; jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../rule_management/logic/use_find_rules'); +jest.mock('../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'); jest.mock('./use_rules_table_saved_state'); -function renderUseRulesTableContext( - savedState: ReturnType -): RulesTableState { +function renderUseRulesTableContext({ + rules, + rulesSnoozeSettings, + savedState, +}: { + rules?: Rule[] | Error; + rulesSnoozeSettings?: RuleSnoozeSettings[] | Error; + savedState?: ReturnType; +}): RulesTableState { (useFindRules as jest.Mock).mockReturnValue({ - data: { rules: [], total: 0 }, + data: rules instanceof Error || !rules ? undefined : { rules, total: rules?.length }, refetch: jest.fn(), dataUpdatedAt: 0, - isFetched: false, - isFetching: false, - isLoading: false, + isFetched: !!rules, + isFetching: !rules, + isLoading: !rules, isRefetching: false, + isError: rules instanceof Error, + }); + (useFetchRulesSnoozeSettings as jest.Mock).mockReturnValue({ + data: rulesSnoozeSettings instanceof Error ? undefined : rulesSnoozeSettings, + isError: rulesSnoozeSettings instanceof Error, }); (useUiSetting$ as jest.Mock).mockReturnValue([{ on: false, value: 0, idleTimeout: 0 }]); - (useRulesTableSavedState as jest.Mock).mockReturnValue(savedState); + (useRulesTableSavedState as jest.Mock).mockReturnValue( + savedState ?? { + filter: { + searchTerm: undefined, + source: undefined, + tags: undefined, + enabled: undefined, + }, + sorting: { + field: undefined, + order: undefined, + }, + pagination: { + page: undefined, + perPage: undefined, + }, + } + ); const wrapper = ({ children }: PropsWithChildren<{}>) => ( {children} @@ -56,19 +87,22 @@ describe('RulesTableContextProvider', () => { describe('persisted state', () => { it('restores persisted rules table state', () => { const state = renderUseRulesTableContext({ - filter: { - searchTerm: 'test', - source: RuleSource.Custom, - tags: ['test'], - enabled: true, - }, - sorting: { - field: 'name', - order: 'asc', - }, - pagination: { - page: 2, - perPage: 10, + rules: [], + savedState: { + filter: { + searchTerm: 'test', + source: RuleSource.Custom, + tags: ['test'], + enabled: true, + }, + sorting: { + field: 'name', + order: 'asc', + }, + pagination: { + page: 2, + perPage: 10, + }, }, }); @@ -112,4 +146,94 @@ describe('RulesTableContextProvider', () => { expect(state.isDefault).toBeTruthy(); }); }); + + describe('state', () => { + describe('rules', () => { + it('returns an empty array while loading', () => { + const state = renderUseRulesTableContext({ + rules: undefined, + }); + + expect(state.rules).toEqual([]); + }); + + it('returns an empty array upon error', () => { + const state = renderUseRulesTableContext({ + rules: new Error('some error'), + }); + + expect(state.rules).toEqual([]); + }); + + it('returns rules while snooze settings are not loaded yet', () => { + const state = renderUseRulesTableContext({ + rules: [{ name: 'rule 1' }, { name: 'rule 2' }] as Rule[], + rulesSnoozeSettings: undefined, + }); + + expect(state.rules).toEqual([{ name: 'rule 1' }, { name: 'rule 2' }]); + }); + + it('returns rules even if snooze settings failed to be loaded', () => { + const state = renderUseRulesTableContext({ + rules: [{ name: 'rule 1' }, { name: 'rule 2' }] as Rule[], + rulesSnoozeSettings: new Error('some error'), + }); + + expect(state.rules).toEqual([{ name: 'rule 1' }, { name: 'rule 2' }]); + }); + + it('returns rules after snooze settings loaded', () => { + const state = renderUseRulesTableContext({ + rules: [ + { id: '1', name: 'rule 1' }, + { id: '2', name: 'rule 2' }, + ] as Rule[], + rulesSnoozeSettings: [ + { id: '1', mute_all: true, snooze_schedule: [] }, + { id: '2', mute_all: false, snooze_schedule: [] }, + ], + }); + + expect(state.rules).toEqual([ + { + id: '1', + name: 'rule 1', + }, + { + id: '2', + name: 'rule 2', + }, + ]); + }); + }); + + describe('rules snooze settings', () => { + it('returns snooze settings', () => { + const state = renderUseRulesTableContext({ + rules: [ + { id: '1', name: 'rule 1' }, + { id: '2', name: 'rule 2' }, + ] as Rule[], + rulesSnoozeSettings: [ + { id: '1', mute_all: true, snooze_schedule: [] }, + { id: '2', mute_all: false, snooze_schedule: [] }, + ], + }); + + expect(state.rulesSnoozeSettings.data).toEqual({ + '1': { + id: '1', + mute_all: true, + snooze_schedule: [], + }, + '2': { + id: '2', + mute_all: false, + snooze_schedule: [], + }, + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx index f6ec9a3714f66..fa2c64f01d2d4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx @@ -15,6 +15,7 @@ import React, { useRef, useState, } from 'react'; +import { useFetchRulesSnoozeSettings } from '../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; import { invariant } from '../../../../../../common/utils/invariant'; import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state'; @@ -24,6 +25,7 @@ import type { FilterOptions, PaginationOptions, Rule, + RuleSnoozeSettings, SortingOptions, } from '../../../../rule_management/logic/types'; import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; @@ -37,6 +39,12 @@ import { import { RuleSource } from './rules_table_saved_state'; import { useRulesTableSavedState } from './use_rules_table_saved_state'; +interface RulesSnoozeSettings { + data: Record; // The key is a rule SO's id (not ruleId) + isLoading: boolean; + isError: boolean; +} + export interface RulesTableState { /** * Rules to display (sorted and paginated in case of in-memory) @@ -106,6 +114,10 @@ export interface RulesTableState { * Whether the state has its default value */ isDefault: boolean; + /** + * Rules snooze settings for the current rules + */ + rulesSnoozeSettings: RulesSnoozeSettings; } export type LoadingRuleAction = @@ -274,9 +286,27 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide } ); + // Fetch rules snooze settings + const { + data: rulesSnoozeSettings, + isLoading: isSnoozeSettingsLoading, + isError: isSnoozeSettingsFetchError, + refetch: refetchSnoozeSettings, + } = useFetchRulesSnoozeSettings( + rules.map((x) => x.id), + { enabled: rules.length > 0 } + ); + + const refetchRulesAndSnoozeSettings = useCallback(async () => { + const response = await refetch(); + await refetchSnoozeSettings(); + + return response; + }, [refetch, refetchSnoozeSettings]); + const actions = useMemo( () => ({ - reFetchRules: refetch, + reFetchRules: refetchRulesAndSnoozeSettings, setFilterOptions: handleFilterOptionsChange, setIsAllSelected, setIsRefreshOn, @@ -290,7 +320,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide clearFilters, }), [ - refetch, + refetchRulesAndSnoozeSettings, handleFilterOptionsChange, setIsAllSelected, setIsRefreshOn, @@ -305,10 +335,22 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide ] ); - const providerValue = useMemo( - () => ({ + const providerValue = useMemo(() => { + const rulesSnoozeSettingsMap = + rulesSnoozeSettings?.reduce((map, snoozeSettings) => { + map[snoozeSettings.id] = snoozeSettings; + + return map; + }, {} as Record) ?? {}; + + return { state: { rules, + rulesSnoozeSettings: { + data: rulesSnoozeSettingsMap, + isLoading: isSnoozeSettingsLoading, + isError: isSnoozeSettingsFetchError, + }, pagination: { page, perPage, @@ -335,29 +377,31 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide }), }, actions, - }), - [ - rules, - page, - perPage, - total, - filterOptions, - isPreflightInProgress, - isActionInProgress, - isAllSelected, - isFetched, - isFetching, - isLoading, - isRefetching, - isRefreshOn, - dataUpdatedAt, - loadingRules.ids, - loadingRules.action, - selectedRuleIds, - sortingOptions, - actions, - ] - ); + }; + }, [ + rules, + rulesSnoozeSettings, + isSnoozeSettingsLoading, + isSnoozeSettingsFetchError, + page, + perPage, + total, + filterOptions, + isPreflightInProgress, + isActionInProgress, + isAllSelected, + isFetched, + isFetching, + isLoading, + isRefetching, + isRefreshOn, + dataUpdatedAt, + loadingRules.ids, + loadingRules.action, + selectedRuleIds, + sortingOptions, + actions, + ]); return {children}; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index 1296e9728d2e6..e20a2f2c70e4f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -22,6 +22,7 @@ import type { } from '../../../../../common/detection_engine/rule_monitoring'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; +import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze_badge'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; @@ -106,6 +107,19 @@ const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): Ta ); }; +const useRuleSnoozeColumn = (): TableColumn => { + return useMemo( + () => ({ + field: 'snooze', + name: i18n.COLUMN_SNOOZE, + render: (_, rule: Rule) => , + width: '100px', + sortable: false, + }), + [] + ); +}; + export const RuleLink = ({ name, id }: Pick) => { return ( @@ -248,6 +262,7 @@ export const useRulesColumns = ({ isLoadingJobs, mlJobs, }); + const snoozeColumn = useRuleSnoozeColumn(); return useMemo( () => [ @@ -317,6 +332,7 @@ export const useRulesColumns = ({ width: '18%', truncateText: true, }, + snoozeColumn, enabledColumn, ...(hasCRUDPermissions ? [actionsColumn] : []), ], @@ -324,6 +340,7 @@ export const useRulesColumns = ({ actionsColumn, enabledColumn, executionStatusColumn, + snoozeColumn, hasCRUDPermissions, ruleNameColumn, showRelatedIntegrations, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 9511138f78f34..487a6176969d1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -553,6 +553,13 @@ export const COLUMN_ENABLE = i18n.translate( } ); +export const COLUMN_SNOOZE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.columns.snoozeTitle', + { + defaultMessage: 'Notify', + } +); + export const COLUMN_INDEXING_TIMES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.indexingTimes', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx index d4cd600f98ee5..238a88ba951f1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useKibana } from '../../../../../common/lib/kibana'; import { SnoozeSchedule } from '../../../../../types'; import { loadRule } from '../../../../lib/rule_api/get_rule'; @@ -24,6 +24,13 @@ export const RulesListNotifyBadgeWithApi: React.FunctionComponent< const [ruleSnoozeInfo, setRuleSnoozeInfo] = useState(rule); + // This helps to fix problems related to rule prop updates. As component handles the loading state via isLoading prop + // rule prop is obviously not ready atm so when it's ready ruleSnoozeInfo won't be updated without useEffect so + // incorrect state will be shown. + useEffect(() => { + setRuleSnoozeInfo(rule); + }, [rule]); + const onSnoozeRule = useCallback( (snoozeSchedule: SnoozeSchedule) => { return snoozeRuleApi({ http, id: ruleSnoozeInfo.id, snoozeSchedule });