Skip to content

Commit

Permalink
[Security Solution] Support snoozing in rules table (#153083)
Browse files Browse the repository at this point in the history
**Addresses:** #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
  • Loading branch information
maximpn authored Apr 20, 2023
1 parent 7ef3c9a commit 0cf7fd1
Show file tree
Hide file tree
Showing 15 changed files with 493 additions and 53 deletions.
1 change: 1 addition & 0 deletions x-pack/plugins/alerting/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 3 additions & 3 deletions x-pack/plugins/alerting/server/routes/find_rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
previewRule,
findRuleExceptionReferences,
performBulkAction,
fetchRulesSnoozeSettings,
} from './api';

const abortCtrl = new AbortController();
Expand Down Expand Up @@ -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',
]),
}),
})
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +47,7 @@ import type {
CreateRulesProps,
ExportDocumentsProps,
FetchRuleProps,
FetchRuleSnoozingProps,
FetchRulesProps,
FetchRulesResponse,
FindRulesReferencedByExceptionsProps,
Expand All @@ -56,6 +57,7 @@ import type {
PrePackagedRulesStatusResponse,
PreviewRulesProps,
Rule,
RulesSnoozeSettingsResponse,
UpdateRulesProps,
} from '../logic/types';
import { convertRulesFilterToKQL } from '../logic/utils';
Expand Down Expand Up @@ -184,6 +186,31 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise<Rul
signal,
});

/**
* Fetch rule snooze settings for each provided ruleId
*
* @param ids Rule IDs (not rule_id)
* @param signal to cancel request
*
* @returns An error if response is not OK
*/
export const fetchRulesSnoozeSettings = async ({
ids,
signal,
}: FetchRuleSnoozingProps): Promise<RulesSnoozeSettingsResponse> =>
KibanaServices.get().http.fetch<RulesSnoozeSettingsResponse>(
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ 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];

export const useCreatePrebuiltRulesMutation = (
options?: UseMutationOptions<CreatePrepackagedRulesResponse>
) => {
const invalidateFindRulesQuery = useInvalidateFindRulesQuery();
const invalidateFetchRulesSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery();
const invalidatePrePackagedRulesStatus = useInvalidateFetchPrebuiltRulesStatusQuery();
const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery();

Expand All @@ -30,6 +32,7 @@ export const useCreatePrebuiltRulesMutation = (
// the number of rules might change after the installation
invalidatePrePackagedRulesStatus();
invalidateFindRulesQuery();
invalidateFetchRulesSnoozeSettings();
invalidateFetchRuleManagementFilters();

if (options?.onSettled) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RuleSnoozeSettings[], Error, RuleSnoozeSettings[], string[]>
) => {
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]);
};
Original file line number Diff line number Diff line change
@@ -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 (
<EuiToolTip content={i18n.UNABLE_TO_FETCH_RULE_SNOOZE_SETTINGS}>
<EuiButtonIcon size="s" iconType="bellSlash" disabled />
</EuiToolTip>
);
}

return (
<RulesListNotifyBadge
rule={rule}
isLoading={rulesSnoozeSettings.isLoading}
onRuleChanged={invalidateFetchRuleSnoozeSettings}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof SortingOptions>;
export const SortingOptions = t.type({
field: FindRulesSortField,
Expand Down Expand Up @@ -244,6 +257,11 @@ export interface FetchRuleProps {
signal?: AbortSignal;
}

export interface FetchRuleSnoozingProps {
ids: string[];
signal?: AbortSignal;
}

export interface BasicFetchProps {
signal: AbortSignal;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const useRulesTableContextMock = {
create: (): jest.Mocked<RulesTableContextType> => ({
state: {
rules: [],
rulesSnoozeSettings: {
data: {},
isLoading: false,
isError: false,
},
pagination: {
page: 1,
perPage: 20,
Expand Down
Loading

0 comments on commit 0cf7fd1

Please sign in to comment.