[RAM][Maintenance Window] Add maintenance window solution selection. (#166781)

## Summary
Resolves: https://github.com/elastic/kibana/issues/166301

Adds support for solution/category filtering to maintenance windows by
adding a new property: `category_ids`. Selecting one or more solutions
when creating/updating a maintenance window will cause the maintenance
window to only suppress rule types belonging to said solutions. In order
to achieve filtering by solution/category, we are adding a new field to
the rule types schema called `category`. This field should map to the
feature category that the rule type belongs to (`observability`,
`securitySolution` or `management`).

Our initial plan was to use feature IDs or rule type IDs to accomplish
this filtering, we decided against using rule type IDs because if a new
rule type gets added, we need to change the API to support this new rule
type. We decided against feature IDs because it's a very anti-serverless
way of accomplishing this feature, as we don't want to expose feature
IDs to APIs. We decided on app categories because it works well with
serverless and should be much easier to maintain if new rule types are
added in the future.

This means the `rule_types` API has to be changed to include this new
field, although it shouldn't be a breaking change since we're just
adding a new field. No migrations are needed since rule types are in
memory and maintenance windows are backwards compatible.


![image](d07b05cd-ade8-46a4-a4c0-ab623c31c11b)

### Error state:

![image](b61984b4-c1e1-4e9b-98b4-97a681e977a8)

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Dima Arnautov <arnautov.dima@gmail.com>
This commit is contained in:
Jiawei Wu 2023-10-02 01:20:53 -07:00 committed by GitHub
parent 566e086963
commit 092cc0d098
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
145 changed files with 1604 additions and 94 deletions

View file

@ -6,15 +6,45 @@
* Side Public License, v 1.
*/
import { KibanaServices } from './types';
import { AsApiContract } from '@kbn/actions-plugin/common';
import type { KibanaServices, MaintenanceWindow } from './types';
const rewriteMaintenanceWindowRes = ({
expiration_date: expirationDate,
r_rule: rRule,
created_by: createdBy,
updated_by: updatedBy,
created_at: createdAt,
updated_at: updatedAt,
event_start_time: eventStartTime,
event_end_time: eventEndTime,
category_ids: categoryIds,
...rest
}: AsApiContract<MaintenanceWindow>): MaintenanceWindow => ({
...rest,
expirationDate,
rRule,
createdBy,
updatedBy,
createdAt,
updatedAt,
eventStartTime,
eventEndTime,
categoryIds,
});
export const fetchActiveMaintenanceWindows = async (
http: KibanaServices['http'],
signal?: AbortSignal
) =>
http.fetch(INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH, {
method: 'GET',
signal,
});
): Promise<MaintenanceWindow[]> => {
const result = await http.fetch<Array<AsApiContract<MaintenanceWindow>>>(
INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH,
{
method: 'GET',
signal,
}
);
return result.map((mw) => rewriteMaintenanceWindowRes(mw));
};
const INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH = `/internal/alerting/rules/maintenance_window/_active`;

View file

@ -9,7 +9,7 @@
import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, waitFor, cleanup } from '@testing-library/react';
import { render, waitFor, cleanup, screen } from '@testing-library/react';
import { MAINTENANCE_WINDOW_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { MaintenanceWindowCallout } from '.';
import { fetchActiveMaintenanceWindows } from './api';
@ -215,4 +215,56 @@ describe('MaintenanceWindowCallout', () => {
expect(await findByText('Maintenance window is running')).toBeInTheDocument();
});
it('should display the callout if the category ids contains the specified category', async () => {
fetchActiveMaintenanceWindowsMock.mockResolvedValue([
{
...RUNNING_MAINTENANCE_WINDOW_1,
categoryIds: ['observability'],
},
]);
render(
<MaintenanceWindowCallout
kibanaServices={kibanaServicesMock}
categories={['securitySolution']}
/>,
{
wrapper: TestProviders,
}
);
await waitFor(() => {
expect(screen.queryByTestId('maintenanceWindowCallout')).not.toBeInTheDocument();
});
fetchActiveMaintenanceWindowsMock.mockResolvedValue([
{
...RUNNING_MAINTENANCE_WINDOW_1,
categoryIds: ['securitySolution'],
},
]);
render(
<MaintenanceWindowCallout
kibanaServices={kibanaServicesMock}
categories={['securitySolution']}
/>,
{
wrapper: TestProviders,
}
);
await waitFor(() => {
expect(screen.getByTestId('maintenanceWindowCallout')).toBeInTheDocument();
});
render(<MaintenanceWindowCallout kibanaServices={kibanaServicesMock} />, {
wrapper: TestProviders,
});
await waitFor(() => {
expect(screen.getByTestId('maintenanceWindowCallout')).toBeInTheDocument();
});
});
});

View file

@ -6,10 +6,10 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut } from '@elastic/eui';
import { MaintenanceWindow, MaintenanceWindowStatus, KibanaServices } from './types';
import { MaintenanceWindowStatus, KibanaServices } from './types';
import { useFetchActiveMaintenanceWindows } from './use_fetch_active_maintenance_windows';
const MAINTENANCE_WINDOW_FEATURE_ID = 'maintenanceWindow';
@ -28,8 +28,10 @@ const MAINTENANCE_WINDOW_RUNNING_DESCRIPTION = i18n.translate(
export function MaintenanceWindowCallout({
kibanaServices,
categories,
}: {
kibanaServices: KibanaServices;
categories?: string[];
}): JSX.Element | null {
const {
application: { capabilities },
@ -38,28 +40,48 @@ export function MaintenanceWindowCallout({
const isMaintenanceWindowDisabled =
!capabilities[MAINTENANCE_WINDOW_FEATURE_ID].show &&
!capabilities[MAINTENANCE_WINDOW_FEATURE_ID].save;
const { data } = useFetchActiveMaintenanceWindows(kibanaServices, {
const { data: activeMaintenanceWindows = [] } = useFetchActiveMaintenanceWindows(kibanaServices, {
enabled: !isMaintenanceWindowDisabled,
});
const shouldShowMaintenanceWindowCallout = useMemo(() => {
if (!activeMaintenanceWindows) {
return false;
}
if (activeMaintenanceWindows.length === 0) {
return false;
}
if (!Array.isArray(categories)) {
return true;
}
return activeMaintenanceWindows.some(({ status, categoryIds }) => {
if (status !== MaintenanceWindowStatus.Running) {
return false;
}
if (!Array.isArray(categoryIds)) {
return true;
}
return categoryIds.some((category) => categories.includes(category));
});
}, [categories, activeMaintenanceWindows]);
if (isMaintenanceWindowDisabled) {
return null;
}
const activeMaintenanceWindows = (data as MaintenanceWindow[]) || [];
if (activeMaintenanceWindows.some(({ status }) => status === MaintenanceWindowStatus.Running)) {
return (
<EuiCallOut
title={MAINTENANCE_WINDOW_RUNNING}
color="warning"
iconType="iInCircle"
data-test-subj="maintenanceWindowCallout"
>
{MAINTENANCE_WINDOW_RUNNING_DESCRIPTION}
</EuiCallOut>
);
if (!shouldShowMaintenanceWindowCallout) {
return null;
}
return null;
return (
<EuiCallOut
title={MAINTENANCE_WINDOW_RUNNING}
color="warning"
iconType="iInCircle"
data-test-subj="maintenanceWindowCallout"
>
{MAINTENANCE_WINDOW_RUNNING_DESCRIPTION}
</EuiCallOut>
);
}

View file

@ -34,6 +34,7 @@ export interface MaintenanceWindowSOProperties {
expirationDate: string;
events: DateRange[];
rRule: RRuleParams;
categoryIds?: string[];
}
export type MaintenanceWindowSOAttributes = MaintenanceWindowSOProperties &

View file

@ -21,6 +21,7 @@
"@kbn/core",
"@kbn/i18n-react",
"@kbn/alerting-plugin",
"@kbn/rrule"
"@kbn/rrule",
"@kbn/actions-plugin"
]
}

View file

@ -78,6 +78,7 @@ export const alertType: RuleType<
},
};
},
category: 'kibana',
producer: ALERTING_EXAMPLE_APP_ID,
validate: {
params: schema.object({

View file

@ -81,6 +81,7 @@ export const alertType: RuleType<
},
};
},
category: 'example',
producer: ALERTING_EXAMPLE_APP_ID,
getViewInAppRelativeUrl({ rule }) {
return `/app/${ALERTING_EXAMPLE_APP_ID}/astros/${rule.id}`;

View file

@ -51,6 +51,7 @@ function getPatternRuleType(): RuleType {
id: 'example.pattern',
name: 'Example: Creates alerts on a pattern, for testing',
actionGroups: [{ id: 'default', name: 'Default' }],
category: 'kibana',
producer: 'alertsFixture',
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',

View file

@ -33,6 +33,7 @@ export interface MaintenanceWindowSOProperties {
expirationDate: string;
events: DateRange[];
rRule: RRuleParams;
categoryIds?: string[] | null;
}
export type MaintenanceWindowSOAttributes = MaintenanceWindowSOProperties &

View file

@ -6,10 +6,12 @@
*/
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchemaV1 } from '../../../shared';
import { rRuleRequestSchemaV1 } from '../../../../r_rule';
export const createBodySchema = schema.object({
title: schema.string(),
duration: schema.number(),
r_rule: rRuleRequestSchemaV1,
category_ids: maintenanceWindowCategoryIdsSchemaV1,
});

View file

@ -6,6 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchemaV1 } from '../../../shared';
import { rRuleRequestSchemaV1 } from '../../../../r_rule';
export const updateParamsSchema = schema.object({
@ -17,4 +18,5 @@ export const updateBodySchema = schema.object({
enabled: schema.maybe(schema.boolean()),
duration: schema.maybe(schema.number()),
r_rule: schema.maybe(rRuleRequestSchemaV1),
category_ids: maintenanceWindowCategoryIdsSchemaV1,
});

View file

@ -5,4 +5,6 @@
* 2.0.
*/
export * from './v1';
export type { MaintenanceWindowStatus } from './v1';
export { maintenanceWindowStatus } from './v1';

View file

@ -7,6 +7,7 @@
import { schema } from '@kbn/config-schema';
import { maintenanceWindowStatusV1 } from '..';
import { maintenanceWindowCategoryIdsSchemaV1 } from '../../shared';
import { rRuleResponseSchemaV1 } from '../../../r_rule';
export const maintenanceWindowEventSchema = schema.object({
@ -34,4 +35,5 @@ export const maintenanceWindowResponseSchema = schema.object({
schema.literal(maintenanceWindowStatusV1.FINISHED),
schema.literal(maintenanceWindowStatusV1.ARCHIVED),
]),
category_ids: maintenanceWindowCategoryIdsSchemaV1,
});

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export type { MaintenanceWindowCategoryIdTypes } from './v1';
export { maintenanceWindowCategoryIdTypes } from './v1';

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
export const maintenanceWindowCategoryIdTypes = {
KIBANA: 'kibana',
OBSERVABILITY: 'observability',
SECURITY_SOLUTION: 'securitySolution',
MANAGEMENT: 'management',
} as const;
export type MaintenanceWindowCategoryIdTypes =
typeof maintenanceWindowCategoryIdTypes[keyof typeof maintenanceWindowCategoryIdTypes];

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
export { maintenanceWindowCategoryIdTypes } from './constants/latest';
export type { MaintenanceWindowCategoryIdTypes } from './constants/latest';
export { maintenanceWindowCategoryIdsSchema } from './schemas/latest';
export type { MaintenanceWindowCategoryIds } from './types/latest';
export { maintenanceWindowCategoryIdTypes as maintenanceWindowCategoryIdTypesV1 } from './constants/v1';
export type { MaintenanceWindowCategoryIdTypes as MaintenanceWindowCategoryIdTypesV1 } from './constants/v1';
export { maintenanceWindowCategoryIdsSchema as maintenanceWindowCategoryIdsSchemaV1 } from './schemas/v1';
export type { MaintenanceWindowCategoryIds as MaintenanceWindowCategoryIdsV1 } from './types/v1';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { maintenanceWindowCategoryIdsSchema } from './v1';

View file

@ -0,0 +1,21 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdTypes as maintenanceWindowCategoryIdTypesV1 } from '../constants/v1';
export const maintenanceWindowCategoryIdsSchema = schema.maybe(
schema.nullable(
schema.arrayOf(
schema.oneOf([
schema.literal(maintenanceWindowCategoryIdTypesV1.OBSERVABILITY),
schema.literal(maintenanceWindowCategoryIdTypesV1.SECURITY_SOLUTION),
schema.literal(maintenanceWindowCategoryIdTypesV1.MANAGEMENT),
])
)
)
);

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export type { MaintenanceWindowCategoryIds } from './v1';

View file

@ -0,0 +1,11 @@
/*
* 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 type { TypeOf } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchemaV1 } from '..';
export type MaintenanceWindowCategoryIds = TypeOf<typeof maintenanceWindowCategoryIdsSchemaV1>;

View file

@ -31,6 +31,7 @@ export interface RuleType<
params: ActionVariable[];
};
defaultActionGroupId: ActionGroupIds;
category: string;
producer: string;
minimumLicenseRequired: LicenseType;
isExportable: boolean;

View file

@ -22,6 +22,7 @@ const mockRuleType = (id: string): RuleType => ({
params: [],
},
defaultActionGroupId: 'default',
category: 'test',
producer: 'alerts',
minimumLicenseRequired: 'basic',
isExportable: true,

View file

@ -0,0 +1,41 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../utils/kibana_react';
import { loadRuleTypes } from '../services/alert_api';
export const useGetRuleTypes = () => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryFn = () => {
return loadRuleTypes({ http });
};
const onError = () => {
toasts.addDanger(
i18n.translate('xpack.alerting.hooks.useGetRuleTypes.error', {
defaultMessage: 'Unable to load rule types.',
})
);
};
const { isLoading, isFetching, data } = useQuery({
queryKey: ['useGetRuleTypes'],
queryFn,
onError,
});
return {
data,
isLoading: isLoading || isFetching,
};
};

View file

@ -6,18 +6,21 @@
*/
import React from 'react';
import { within } from '@testing-library/react';
import { within, fireEvent, waitFor } from '@testing-library/react';
import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils';
import {
CreateMaintenanceWindowFormProps,
CreateMaintenanceWindowForm,
} from './create_maintenance_windows_form';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn(),
jest.mock('../../../utils/kibana_react');
jest.mock('../../../services/alert_api', () => ({
loadRuleTypes: jest.fn(),
}));
const { loadRuleTypes } = jest.requireMock('../../../services/alert_api');
const { useKibana, useUiSetting } = jest.requireMock('../../../utils/kibana_react');
const formProps: CreateMaintenanceWindowFormProps = {
onCancel: jest.fn(),
onSuccess: jest.fn(),
@ -28,28 +31,59 @@ describe('CreateMaintenanceWindowForm', () => {
beforeEach(() => {
jest.clearAllMocks();
loadRuleTypes.mockResolvedValue([
{ category: 'observability' },
{ category: 'management' },
{ category: 'securitySolution' },
]);
useKibana.mockReturnValue({
services: {
notifications: {
toasts: {
addSuccess: jest.fn(),
addDanger: jest.fn(),
},
},
},
});
useUiSetting.mockReturnValue('America/New_York');
appMockRenderer = createAppMockRenderer();
(useUiSetting as jest.Mock).mockReturnValue('America/New_York');
});
it('renders all form fields except the recurring form fields', async () => {
const result = appMockRenderer.render(<CreateMaintenanceWindowForm {...formProps} />);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
});
expect(result.getByTestId('title-field')).toBeInTheDocument();
expect(result.getByTestId('date-field')).toBeInTheDocument();
expect(result.getByTestId('recurring-field')).toBeInTheDocument();
expect(result.getByTestId('maintenanceWindowCategorySelection')).toBeInTheDocument();
expect(result.queryByTestId('recurring-form')).not.toBeInTheDocument();
expect(result.queryByTestId('timezone-field')).not.toBeInTheDocument();
});
it('renders timezone field when the kibana setting is set to browser', async () => {
(useUiSetting as jest.Mock).mockReturnValue('Browser');
useUiSetting.mockReturnValue('Browser');
const result = appMockRenderer.render(<CreateMaintenanceWindowForm {...formProps} />);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
});
expect(result.getByTestId('title-field')).toBeInTheDocument();
expect(result.getByTestId('date-field')).toBeInTheDocument();
expect(result.getByTestId('recurring-field')).toBeInTheDocument();
expect(result.getByTestId('maintenanceWindowCategorySelection')).toBeInTheDocument();
expect(result.queryByTestId('recurring-form')).not.toBeInTheDocument();
expect(result.getByTestId('timezone-field')).toBeInTheDocument();
});
@ -71,7 +105,56 @@ describe('CreateMaintenanceWindowForm', () => {
expect(recurringInput).not.toBeChecked();
});
it('should prefill the form when provided with initialValue', () => {
it('should prefill the form when provided with initialValue', async () => {
const result = appMockRenderer.render(
<CreateMaintenanceWindowForm
{...formProps}
initialValue={{
title: 'test',
startDate: '2023-03-24',
endDate: '2023-03-26',
timezone: ['America/Los_Angeles'],
recurring: true,
categoryIds: [],
}}
/>
);
const titleInput = within(result.getByTestId('title-field')).getByTestId('input');
const dateInputs = within(result.getByTestId('date-field')).getAllByLabelText(
// using the aria-label to query for the date-picker input
'Press the down key to open a popover containing a calendar.'
);
const recurringInput = within(result.getByTestId('recurring-field')).getByTestId('input');
const timezoneInput = within(result.getByTestId('timezone-field')).getByTestId('input');
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
});
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
expect(titleInput).toHaveValue('test');
expect(dateInputs[0]).toHaveValue('03/23/2023 09:00 PM');
expect(dateInputs[1]).toHaveValue('03/25/2023 09:00 PM');
expect(recurringInput).toBeChecked();
expect(timezoneInput).toHaveTextContent('America/Los_Angeles');
});
it('should initialize MWs without category ids properly', async () => {
const result = appMockRenderer.render(
<CreateMaintenanceWindowForm
{...formProps}
@ -85,18 +168,61 @@ describe('CreateMaintenanceWindowForm', () => {
/>
);
const titleInput = within(result.getByTestId('title-field')).getByTestId('input');
const dateInputs = within(result.getByTestId('date-field')).getAllByLabelText(
// using the aria-label to query for the date-picker input
'Press the down key to open a popover containing a calendar.'
);
const recurringInput = within(result.getByTestId('recurring-field')).getByTestId('input');
const timezoneInput = within(result.getByTestId('timezone-field')).getByTestId('input');
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
});
expect(titleInput).toHaveValue('test');
expect(dateInputs[0]).toHaveValue('03/23/2023 09:00 PM');
expect(dateInputs[1]).toHaveValue('03/25/2023 09:00 PM');
expect(recurringInput).toBeChecked();
expect(timezoneInput).toHaveTextContent('America/Los_Angeles');
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
});
it('can select category IDs', async () => {
const result = appMockRenderer.render(<CreateMaintenanceWindowForm {...formProps} />);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
});
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('checkbox-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
fireEvent.click(observabilityInput);
expect(observabilityInput).not.toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
fireEvent.click(securityInput);
fireEvent.click(observabilityInput);
expect(observabilityInput).toBeChecked();
expect(securityInput).not.toBeChecked();
expect(managementInput).toBeChecked();
});
});

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react';
import moment from 'moment';
import {
FIELD_TYPES,
@ -24,8 +24,12 @@ import {
EuiFlexItem,
EuiFormLabel,
EuiHorizontalRule,
EuiSpacer,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { TIMEZONE_OPTIONS as UI_TIMEZONE_OPTIONS } from '@kbn/core-ui-settings-common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import { FormProps, schema } from './schema';
import * as i18n from '../translations';
@ -34,9 +38,11 @@ import { SubmitButton } from './submit_button';
import { convertToRRule } from '../helpers/convert_to_rrule';
import { useCreateMaintenanceWindow } from '../../../hooks/use_create_maintenance_window';
import { useUpdateMaintenanceWindow } from '../../../hooks/use_update_maintenance_window';
import { useGetRuleTypes } from '../../../hooks/use_get_rule_types';
import { useUiSetting } from '../../../utils/kibana_react';
import { DatePickerRangeField } from './fields/date_picker_range_field';
import { useArchiveMaintenanceWindow } from '../../../hooks/use_archive_maintenance_window';
import { MaintenanceWindowCategorySelection } from './maintenance_window_category_selection';
const UseField = getUseField({ component: Field });
@ -64,12 +70,17 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
const { defaultTimezone, isBrowser } = useDefaultTimezone();
const isEditMode = initialValue !== undefined && maintenanceWindowId !== undefined;
const hasSetInitialCategories = useRef<boolean>(false);
const { mutate: createMaintenanceWindow, isLoading: isCreateLoading } =
useCreateMaintenanceWindow();
const { mutate: updateMaintenanceWindow, isLoading: isUpdateLoading } =
useUpdateMaintenanceWindow();
const { mutate: archiveMaintenanceWindow } = useArchiveMaintenanceWindow();
const { data: ruleTypes, isLoading: isLoadingRuleTypes } = useGetRuleTypes();
const submitMaintenanceWindow = useCallback(
async (formData, isValid) => {
if (isValid) {
@ -83,6 +94,7 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
formData.timezone ? formData.timezone[0] : defaultTimezone,
formData.recurringSchedule
),
categoryIds: formData.categoryIds,
};
if (isEditMode) {
updateMaintenanceWindow({ maintenanceWindowId, maintenanceWindow }, { onSuccess });
@ -108,9 +120,9 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
onSubmit: submitMaintenanceWindow,
});
const [{ recurring, timezone }] = useFormData<FormProps>({
const [{ recurring, timezone, categoryIds }] = useFormData<FormProps>({
form,
watch: ['recurring', 'timezone'],
watch: ['recurring', 'timezone', 'categoryIds'],
});
const isRecurring = recurring || false;
const showTimezone = isBrowser || initialValue?.timezone !== undefined;
@ -118,6 +130,25 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
const closeModal = useCallback(() => setIsModalVisible(false), []);
const showModal = useCallback(() => setIsModalVisible(true), []);
const { setFieldValue } = form;
const onCategoryIdsChange = useCallback(
(id: string) => {
if (!categoryIds) {
return;
}
if (categoryIds.includes(id)) {
setFieldValue(
'categoryIds',
categoryIds.filter((category) => category !== id)
);
return;
}
setFieldValue('categoryIds', [...categoryIds, id]);
},
[categoryIds, setFieldValue]
);
const modal = useMemo(() => {
let m;
if (isModalVisible) {
@ -144,6 +175,52 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
return m;
}, [closeModal, archiveMaintenanceWindow, isModalVisible, maintenanceWindowId, onSuccess]);
const availableCategories = useMemo(() => {
if (!ruleTypes) {
return [];
}
return [...new Set(ruleTypes.map((ruleType) => ruleType.category))];
}, [ruleTypes]);
// For create mode, we want to initialize options to the rule type category the
// user has access
useEffect(() => {
if (isEditMode) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (!ruleTypes) {
return;
}
setFieldValue('categoryIds', [...new Set(ruleTypes.map((ruleType) => ruleType.category))]);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, ruleTypes]);
// For edit mode, if a maintenance window => category_ids is not an array, this means
// the maintenance window was created before the introduction of category filters.
// For backwards compat we will initialize all options for these.
useEffect(() => {
if (!isEditMode) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (Array.isArray(categoryIds)) {
return;
}
setFieldValue('categoryIds', [
DEFAULT_APP_CATEGORIES.observability.id,
DEFAULT_APP_CATEGORIES.security.id,
DEFAULT_APP_CATEGORIES.management.id,
]);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, categoryIds]);
return (
<Form form={form}>
<EuiFlexGroup direction="column" responsive={false}>
@ -158,6 +235,17 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
}}
/>
</EuiFlexItem>
<EuiSpacer size="xs" />
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_TIMEFRAME_TITLE}</h4>
<p>
<EuiTextColor color="subdued">
{i18n.CREATE_FORM_TIMEFRAME_DESCRIPTION}
</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexEnd" responsive={false}>
<EuiFlexItem grow={3}>
@ -229,20 +317,39 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
}}
/>
</EuiFlexItem>
{isRecurring && (
<EuiFlexItem>
<RecurringSchedule data-test-subj="recurring-form" />
</EuiFlexItem>
)}
<EuiFlexItem>
{isRecurring ? <RecurringSchedule data-test-subj="recurring-form" /> : null}
<EuiHorizontalRule margin="xl" />
<UseField path="categoryIds">
{(field) => (
<MaintenanceWindowCategorySelection
selectedCategories={categoryIds || []}
availableCategories={availableCategories}
isLoading={isLoadingRuleTypes}
errors={field.errors.map((error) => error.message)}
onChange={onCategoryIdsChange}
/>
)}
</UseField>
<EuiHorizontalRule margin="xl" />
</EuiFlexItem>
</EuiFlexGroup>
{isEditMode ? (
<EuiCallOut title={i18n.ARCHIVE_TITLE} color="danger" iconType="trash">
<p>{i18n.ARCHIVE_SUBTITLE}</p>
<EuiButton fill color="danger" onClick={showModal}>
{i18n.ARCHIVE}
</EuiButton>
{modal}
</EuiCallOut>
) : null}
<EuiHorizontalRule margin="xl" />
{isEditMode && (
<>
<EuiCallOut title={i18n.ARCHIVE_TITLE} color="danger" iconType="trash">
<p>{i18n.ARCHIVE_SUBTITLE}</p>
<EuiButton fill color="danger" onClick={showModal}>
{i18n.ARCHIVE}
</EuiButton>
{modal}
</EuiCallOut>
<EuiHorizontalRule margin="xl" />
</>
)}
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"

View file

@ -0,0 +1,109 @@
/*
* 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 React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { MaintenanceWindowCategorySelection } from './maintenance_window_category_selection';
import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils';
const mockOnChange = jest.fn();
describe('maintenanceWindowCategorySelection', () => {
let appMockRenderer: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
appMockRenderer = createAppMockRenderer();
});
it('renders correctly', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(screen.getByTestId('checkbox-observability')).not.toBeDisabled();
expect(screen.getByTestId('checkbox-securitySolution')).not.toBeDisabled();
expect(screen.getByTestId('checkbox-management')).not.toBeDisabled();
});
it('should disable options if option is not in the available categories array', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={[]}
availableCategories={[]}
onChange={mockOnChange}
/>
);
expect(screen.getByTestId('checkbox-observability')).toBeDisabled();
expect(screen.getByTestId('checkbox-securitySolution')).toBeDisabled();
expect(screen.getByTestId('checkbox-management')).toBeDisabled();
});
it('can initialize checkboxes with initial values from props', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={['securitySolution', 'management']}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(screen.getByTestId('checkbox-observability')).not.toBeChecked();
expect(screen.getByTestId('checkbox-securitySolution')).toBeChecked();
expect(screen.getByTestId('checkbox-management')).toBeChecked();
});
it('can check checkboxes', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={['observability']}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
const managementCheckbox = screen.getByTestId('checkbox-management');
const securityCheckbox = screen.getByTestId('checkbox-securitySolution');
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith('management', expect.anything());
fireEvent.click(securityCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith('securitySolution', expect.anything());
});
it('should display loading spinner if isLoading is true', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isLoading
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(screen.getByTestId('maintenanceWindowCategorySelectionLoading')).toBeInTheDocument();
});
it('should display error message if it exists', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
errors={['test error']}
onChange={mockOnChange}
/>
);
expect(screen.getByText('test error')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 React, { useMemo } from 'react';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import {
EuiFlexGroup,
EuiText,
EuiFlexItem,
EuiFormRow,
EuiTextColor,
EuiCheckboxGroup,
EuiCheckboxGroupOption,
EuiLoadingSpinner,
} from '@elastic/eui';
import * as i18n from '../translations';
const CHECKBOX_OPTIONS: EuiCheckboxGroupOption[] = [
{
id: DEFAULT_APP_CATEGORIES.observability.id,
label: i18n.CREATE_FORM_CATEGORY_OBSERVABILITY_RULES,
['data-test-subj']: `checkbox-${DEFAULT_APP_CATEGORIES.observability.id}`,
},
{
id: DEFAULT_APP_CATEGORIES.security.id,
label: i18n.CREATE_FORM_CATEGORY_SECURITY_RULES,
['data-test-subj']: `checkbox-${DEFAULT_APP_CATEGORIES.security.id}`,
},
{
id: DEFAULT_APP_CATEGORIES.management.id,
label: i18n.CREATE_FORM_CATEGORY_STACK_RULES,
['data-test-subj']: `checkbox-${DEFAULT_APP_CATEGORIES.management.id}`,
},
];
export interface MaintenanceWindowCategorySelectionProps {
selectedCategories: string[];
availableCategories: string[];
errors?: string[];
isLoading?: boolean;
onChange: (category: string) => void;
}
export const MaintenanceWindowCategorySelection = (
props: MaintenanceWindowCategorySelectionProps
) => {
const {
selectedCategories,
availableCategories,
errors = [],
isLoading = false,
onChange,
} = props;
const selectedMap = useMemo(() => {
return selectedCategories.reduce<Record<string, boolean>>((result, category) => {
result[category] = true;
return result;
}, {});
}, [selectedCategories]);
const options: EuiCheckboxGroupOption[] = useMemo(() => {
return CHECKBOX_OPTIONS.map((option) => ({
...option,
disabled: !availableCategories.includes(option.id),
}));
}, [availableCategories]);
if (isLoading) {
return (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj="maintenanceWindowCategorySelectionLoading"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup data-test-subj="maintenanceWindowCategorySelection">
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_CATEGORY_SELECTION_TITLE}</h4>
<p>
<EuiTextColor color="subdued">
{i18n.CREATE_FORM_CATEGORY_SELECTION_DESCRIPTION}
</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.CREATE_FORM_CATEGORIES_SELECTION_CHECKBOX_GROUP_TITLE}
isInvalid={!!errors.length}
error={errors[0]}
>
<EuiCheckboxGroup options={options} idToSelectedMap={selectedMap} onChange={onChange} />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -23,6 +23,7 @@ const initialValue: FormProps = {
frequency: 'CUSTOM',
ends: EndsOptions.NEVER,
},
categoryIds: [],
};
describe('CustomRecurringSchedule', () => {

View file

@ -19,6 +19,7 @@ const initialValue: FormProps = {
startDate: '2023-03-24',
endDate: '2023-03-26',
recurring: true,
categoryIds: [],
};
describe('RecurringSchedule', () => {

View file

@ -22,6 +22,7 @@ export interface FormProps {
timezone?: string[];
recurring: boolean;
recurringSchedule?: RecurringScheduleFormProps;
categoryIds?: string[];
}
export interface RecurringScheduleFormProps {
@ -45,6 +46,13 @@ export const schema: FormSchema<FormProps> = {
},
],
},
categoryIds: {
validations: [
{
validator: emptyField(i18n.CREATE_FORM_CATEGORY_IDS_REQUIRED),
},
],
},
startDate: {},
endDate: {},
timezone: {},

View file

@ -35,6 +35,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
endDate: endDate.toISOString(),
timezone: ['UTC'],
recurring: false,
categoryIds: [],
});
});
@ -63,6 +64,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.DAILY,
interval: 1,
},
categoryIds: [],
});
});
@ -95,6 +97,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.DAILY,
interval: 1,
},
categoryIds: [],
});
});
@ -125,6 +128,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.DAILY,
interval: 1,
},
categoryIds: [],
});
});
@ -153,6 +157,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false },
interval: 1,
},
categoryIds: [],
});
});
@ -181,6 +186,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
bymonth: 'weekday',
interval: 1,
},
categoryIds: [],
});
});
@ -209,6 +215,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.YEARLY,
interval: 1,
},
categoryIds: [],
});
});
@ -236,6 +243,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 1,
},
categoryIds: [],
});
});
@ -265,6 +273,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 1,
},
categoryIds: [],
});
});
@ -294,6 +303,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 1,
},
categoryIds: [],
});
});
@ -323,6 +333,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 3,
},
categoryIds: [],
});
});
});

View file

@ -27,6 +27,7 @@ export const convertFromMaintenanceWindowToForm = (
endDate: endDate.toISOString(),
timezone: [maintenanceWindow.rRule.tzid],
recurring,
categoryIds: maintenanceWindow.categoryIds || [],
};
if (!recurring) return form;

View file

@ -144,6 +144,70 @@ export const CREATE_FORM_FREQUENCY_WEEKLY = i18n.translate(
}
);
export const CREATE_FORM_TIMEFRAME_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.frequency.title',
{
defaultMessage: 'Timeframe',
}
);
export const CREATE_FORM_TIMEFRAME_DESCRIPTION = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.frequency.description',
{
defaultMessage: 'Define the start and end time when events should be affected by the window.',
}
);
export const CREATE_FORM_CATEGORY_IDS_REQUIRED = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.required',
{
defaultMessage: 'A category is required.',
}
);
export const CREATE_FORM_CATEGORY_SELECTION_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoriesSelection.title',
{
defaultMessage: 'Category specific maintenance window',
}
);
export const CREATE_FORM_CATEGORY_SELECTION_DESCRIPTION = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoriesSelection.description',
{
defaultMessage:
'Only rules associated with the selected categories are affected by the maintenance window.',
}
);
export const CREATE_FORM_CATEGORIES_SELECTION_CHECKBOX_GROUP_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categorySelection.checkboxGroupTitle',
{
defaultMessage: 'Select the categories this should affect',
}
);
export const CREATE_FORM_CATEGORY_OBSERVABILITY_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.observabilityRules',
{
defaultMessage: 'Observability rules',
}
);
export const CREATE_FORM_CATEGORY_SECURITY_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.securityRules',
{
defaultMessage: 'Security rules',
}
);
export const CREATE_FORM_CATEGORY_STACK_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.stackRules',
{
defaultMessage: 'Stack rules',
}
);
export const CREATE_FORM_FREQUENCY_WEEKLY_ON = (dayOfWeek: string) =>
i18n.translate('xpack.alerting.maintenanceWindows.createForm.frequency.weeklyOnWeekday', {
defaultMessage: 'Weekly on {dayOfWeek}',

View file

@ -18,7 +18,10 @@ export const RRuleFrequencyMap = {
'3': Frequency.DAILY,
};
export type MaintenanceWindow = Pick<MaintenanceWindowServerSide, 'title' | 'duration' | 'rRule'>;
export type MaintenanceWindow = Pick<
MaintenanceWindowServerSide,
'title' | 'duration' | 'rRule' | 'categoryIds'
>;
export type MaintenanceWindowFindResponse = MaintenanceWindowServerSide &
MaintenanceWindowModificationMetadata & {

View file

@ -53,6 +53,7 @@ describe('loadRuleTypes', () => {
"read": true,
},
},
"category": "management",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"id": ".index-threshold",
@ -152,6 +153,7 @@ function getApiRuleType() {
return {
id: '.index-threshold',
name: 'Index threshold',
category: 'management',
producer: 'stackAlerts',
enabled_in_license: true,
recovery_action_group: {
@ -202,6 +204,7 @@ function getRuleType(): RuleType {
return {
id: '.index-threshold',
name: 'Index threshold',
category: 'management',
producer: 'stackAlerts',
enabledInLicense: true,
recoveryActionGroup: {

View file

@ -10,9 +10,14 @@ import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import { MaintenanceWindow } from '../../pages/maintenance_windows/types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
...rest
}) => ({
...rest,
rRule,
categoryIds,
});
export async function archiveMaintenanceWindow({

View file

@ -10,14 +10,24 @@ import { AsApiContract, RewriteRequestCase, RewriteResponseCase } from '@kbn/act
import { MaintenanceWindow } from '../../pages/maintenance_windows/types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({ rRule, ...res }) => ({
const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({
rRule,
categoryIds,
...res
}) => ({
...res,
r_rule: rRule,
category_ids: categoryIds,
});
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
...rest
}) => ({
...rest,
rRule,
categoryIds,
});
export async function createMaintenanceWindow({

View file

@ -10,9 +10,14 @@ import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import { MaintenanceWindow } from '../../pages/maintenance_windows/types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
...rest
}) => ({
...rest,
rRule,
categoryIds,
});
export async function finishMaintenanceWindow({

View file

@ -10,8 +10,13 @@ import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import { MaintenanceWindow } from '../../pages/maintenance_windows/types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
...rest
}) => ({
...rest,
categoryIds,
rRule,
});

View file

@ -10,14 +10,24 @@ import { AsApiContract, RewriteRequestCase, RewriteResponseCase } from '@kbn/act
import { MaintenanceWindow } from '../../pages/maintenance_windows/types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common';
const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({ rRule, ...res }) => ({
const rewriteBodyRequest: RewriteResponseCase<MaintenanceWindow> = ({
rRule,
categoryIds,
...res
}) => ({
...res,
r_rule: rRule,
category_ids: categoryIds,
});
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
const rewriteBodyRes: RewriteRequestCase<MaintenanceWindow> = ({
r_rule: rRule,
category_ids: categoryIds,
...rest
}) => ({
...rest,
rRule,
categoryIds,
});
export async function updateMaintenanceWindow({

View file

@ -48,6 +48,7 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -93,6 +93,7 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -17,6 +17,7 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -196,6 +196,7 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -11,3 +11,10 @@ export const maintenanceWindowStatus = {
FINISHED: 'finished',
ARCHIVED: 'archived',
} as const;
export const maintenanceWindowCategoryIdTypes = {
KIBANA: 'kibana',
OBSERVABILITY: 'observability',
SECURITY_SOLUTION: 'securitySolution',
MANAGEMENT: 'management',
} as const;

View file

@ -15,6 +15,7 @@ import {
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../../../common';
import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers';
import type { MaintenanceWindow } from '../../types';
const savedObjectsClient = savedObjectsClientMock.create();
@ -86,4 +87,75 @@ describe('MaintenanceWindowClient - create', () => {
})
);
});
it('should create maintenance window with category ids', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
savedObjectsClient.create.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
const result = await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['observability', 'securitySolution'],
},
});
expect(savedObjectsClient.create).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
expect.objectContaining({
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule,
enabled: true,
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
categoryIds: ['observability', 'securitySolution'],
...updatedMetadata,
}),
{
id: expect.any(String),
}
);
expect(result).toEqual(
expect.objectContaining({
id: 'test-id',
})
);
});
it('should throw if trying to create a maintenance window with invalid category ids', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
await expect(async () => {
await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['invalid_id'] as unknown as MaintenanceWindow['categoryIds'],
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error validating create maintenance window data - [data.categoryIds]: types that failed validation:
- [data.categoryIds.0.0]: types that failed validation:
- [data.categoryIds.0.0]: expected value to equal [observability]
- [data.categoryIds.0.1]: expected value to equal [securitySolution]
- [data.categoryIds.0.2]: expected value to equal [management]
- [data.categoryIds.1]: expected value to equal [null]"
`);
});
});

View file

@ -25,7 +25,7 @@ export async function createMaintenanceWindow(
): Promise<MaintenanceWindow> {
const { data } = params;
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { title, duration, rRule } = data;
const { title, duration, rRule, categoryIds } = data;
try {
createMaintenanceWindowParamsSchema.validate(params);
@ -42,6 +42,7 @@ export async function createMaintenanceWindow(
title,
enabled: true,
expirationDate,
categoryIds,
rRule: rRule as MaintenanceWindow['rRule'],
duration,
events,

View file

@ -6,6 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchema } from '../../../schemas';
import { rRuleRequestSchema } from '../../../../r_rule/schemas';
export const createMaintenanceWindowParamsSchema = schema.object({
@ -13,5 +14,6 @@ export const createMaintenanceWindowParamsSchema = schema.object({
title: schema.string(),
duration: schema.number(),
rRule: rRuleRequestSchema,
categoryIds: maintenanceWindowCategoryIdsSchema,
}),
});

View file

@ -6,6 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchema } from '../../../schemas';
import { rRuleRequestSchema } from '../../../../r_rule/schemas';
export const updateMaintenanceWindowParamsSchema = schema.object({
@ -15,5 +16,6 @@ export const updateMaintenanceWindowParamsSchema = schema.object({
enabled: schema.maybe(schema.boolean()),
duration: schema.maybe(schema.number()),
rRule: schema.maybe(rRuleRequestSchema),
categoryIds: maintenanceWindowCategoryIdsSchema,
}),
});

View file

@ -90,6 +90,7 @@ describe('MaintenanceWindowClient - update', () => {
data: {
...updatedAttributes,
rRule: updatedAttributes.rRule as UpdateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['observability', 'securitySolution'],
},
});
@ -110,6 +111,7 @@ describe('MaintenanceWindowClient - update', () => {
createdBy: 'test-user',
updatedAt: updatedMetadata.updatedAt,
updatedBy: updatedMetadata.updatedBy,
categoryIds: ['observability', 'securitySolution'],
},
{
id: 'test-id',
@ -117,7 +119,7 @@ describe('MaintenanceWindowClient - update', () => {
version: '123',
}
);
// Only these 3 properties are worth asserting since the rest come from mocks
// Only these properties are worth asserting since the rest come from mocks
expect(result).toEqual(
expect.objectContaining({
id: 'test-id',
@ -235,4 +237,28 @@ describe('MaintenanceWindowClient - update', () => {
'Failed to update maintenance window by id: test-id, Error: Error: Cannot edit archived maintenance windows'
);
});
it('should throw if updating a maintenance window with invalid category ids', async () => {
await expect(async () => {
await updateMaintenanceWindow(mockContext, {
id: 'test-id',
data: {
categoryIds: ['invalid_id'] as unknown as MaintenanceWindow['categoryIds'],
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: Frequency.WEEKLY,
count: 2,
},
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error validating update maintenance window data - [data.categoryIds]: types that failed validation:
- [data.categoryIds.0.0]: types that failed validation:
- [data.categoryIds.0.0]: expected value to equal [observability]
- [data.categoryIds.0.1]: expected value to equal [securitySolution]
- [data.categoryIds.0.2]: expected value to equal [management]
- [data.categoryIds.1]: expected value to equal [null]"
`);
});
});

View file

@ -45,7 +45,7 @@ async function updateWithOCC(
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { id, data } = params;
const { title, enabled, duration, rRule } = data;
const { title, enabled, duration, rRule, categoryIds } = data;
try {
updateMaintenanceWindowParamsSchema.validate(params);
@ -87,6 +87,7 @@ async function updateWithOCC(
...maintenanceWindow,
...(title ? { title } : {}),
...(rRule ? { rRule: rRule as MaintenanceWindow['rRule'] } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
...(typeof duration === 'number' ? { duration } : {}),
...(typeof enabled === 'boolean' ? { enabled } : {}),
expirationDate,

View file

@ -8,4 +8,5 @@
export {
maintenanceWindowEventSchema,
maintenanceWindowSchema,
maintenanceWindowCategoryIdsSchema,
} from './maintenance_window_schemas';

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { maintenanceWindowStatus } from '../constants';
import { maintenanceWindowStatus, maintenanceWindowCategoryIdTypes } from '../constants';
import { rRuleSchema } from '../../r_rule/schemas';
export const maintenanceWindowEventSchema = schema.object({
@ -14,6 +14,18 @@ export const maintenanceWindowEventSchema = schema.object({
lte: schema.string(),
});
export const maintenanceWindowCategoryIdsSchema = schema.maybe(
schema.nullable(
schema.arrayOf(
schema.oneOf([
schema.literal(maintenanceWindowCategoryIdTypes.OBSERVABILITY),
schema.literal(maintenanceWindowCategoryIdTypes.SECURITY_SOLUTION),
schema.literal(maintenanceWindowCategoryIdTypes.MANAGEMENT),
])
)
)
);
export const maintenanceWindowSchema = schema.object({
id: schema.string(),
title: schema.string(),
@ -34,4 +46,5 @@ export const maintenanceWindowSchema = schema.object({
schema.literal(maintenanceWindowStatus.FINISHED),
schema.literal(maintenanceWindowStatus.ARCHIVED),
]),
categoryIds: maintenanceWindowCategoryIdsSchema,
});

View file

@ -39,5 +39,6 @@ export const transformMaintenanceWindowAttributesToMaintenanceWindow = (
eventStartTime,
eventEndTime,
status,
...(attributes.categoryIds !== undefined ? { categoryIds: attributes.categoryIds } : {}),
};
};

View file

@ -22,5 +22,8 @@ export const transformMaintenanceWindowToMaintenanceWindowAttributes = (
updatedBy: maintenanceWindow.updatedBy,
createdAt: maintenanceWindow.createdAt,
updatedAt: maintenanceWindow.updatedAt,
...(maintenanceWindow.categoryIds !== undefined
? { categoryIds: maintenanceWindow.categoryIds }
: {}),
};
};

View file

@ -80,6 +80,7 @@ describe('aggregate()', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myType',
name: 'myType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -162,6 +163,7 @@ describe('aggregate()', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
category: 'test',
producer: 'alerts',
authorizedConsumers: {
myApp: { read: true, all: true },

View file

@ -156,6 +156,7 @@ describe('bulkDelete', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),

View file

@ -241,6 +241,7 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -739,6 +740,7 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -2354,6 +2356,7 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validLegacyConsumers: [],
});
@ -2399,6 +2402,7 @@ describe('bulkEdit()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validLegacyConsumers: [],
});

View file

@ -1545,6 +1545,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: extractReferencesFn,
@ -1733,6 +1734,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: extractReferencesFn,
@ -2560,6 +2562,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validLegacyConsumers: [],
});
@ -3028,6 +3031,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3101,6 +3105,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3139,6 +3144,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3232,6 +3238,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3282,6 +3289,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3345,6 +3353,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3426,6 +3435,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3626,6 +3636,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -3684,6 +3695,7 @@ describe('create()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),

View file

@ -197,6 +197,7 @@ beforeEach(() => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'myApp',
validate: {
params: schema.any(),
@ -761,6 +762,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,
@ -776,6 +778,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -791,6 +794,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'mySecondAppAlertType',
name: 'mySecondAppAlertType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -1165,6 +1169,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'myOtherApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -1180,6 +1185,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -1238,6 +1244,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1274,6 +1281,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1356,6 +1364,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1384,6 +1393,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1453,6 +1463,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1560,6 +1571,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1588,6 +1600,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1674,6 +1687,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1703,6 +1717,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'alerts',
enabledInLicense: true,
isExportable: true,
@ -1718,6 +1733,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
isExportable: true,
@ -1733,6 +1749,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'mySecondAppAlertType',
name: 'mySecondAppAlertType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
isExportable: true,
@ -1790,6 +1807,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1866,6 +1884,7 @@ describe('AlertingAuthorization', () => {
"read": true,
},
},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -1902,6 +1921,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: '.esQuery',
name: 'ES Query',
category: 'management',
producer: 'stackAlerts',
enabledInLicense: true,
hasAlertsMappings: false,
@ -1917,6 +1937,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: '.threshold-rule-o11y',
name: 'New threshold 011y',
category: 'observability',
producer: 'observability',
enabledInLicense: true,
hasAlertsMappings: false,
@ -1932,6 +1953,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: '.infrastructure-threshold-o11y',
name: 'Metrics o11y',
category: 'observability',
producer: 'infrastructure',
enabledInLicense: true,
hasAlertsMappings: false,
@ -1947,6 +1969,7 @@ describe('AlertingAuthorization', () => {
recoveryActionGroup: RecoveredActionGroup,
id: '.logs-threshold-o11y',
name: 'Logs o11y',
category: 'observability',
producer: 'logs',
enabledInLicense: true,
hasAlertsMappings: false,

View file

@ -25,6 +25,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
minimumLicenseRequired: 'basic',
isExportable: true,
@ -63,6 +64,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -103,6 +105,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -123,6 +126,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'alerts',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -143,6 +147,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'mySecondAppAlertType',
name: 'mySecondAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -184,6 +189,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -204,6 +210,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'alerts',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -246,6 +253,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -266,6 +274,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'alerts',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -305,6 +314,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
minimumLicenseRequired: 'basic',
isExportable: true,
@ -340,6 +350,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
minimumLicenseRequired: 'basic',
isExportable: true,
@ -405,6 +416,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -477,6 +489,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -497,6 +510,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myOtherAppAlertType',
name: 'myOtherAppAlertType',
category: 'test',
producer: 'alerts',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -517,6 +531,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'mySecondAppAlertType',
name: 'mySecondAppAlertType',
category: 'test',
producer: 'myApp',
authorizedConsumers: {
alerts: { read: true, all: true },
@ -686,6 +701,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
minimumLicenseRequired: 'basic',
isExportable: true,

View file

@ -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.
*/
export const maintenanceWindowCategoryIdTypes = {
OBSERVABILITY: 'observability',
SECURITY_SOLUTION: 'securitySolution',
MANAGEMENT: 'management',
} as const;
export type MaintenanceWindowCategoryIdTypes =
typeof maintenanceWindowCategoryIdTypes[keyof typeof maintenanceWindowCategoryIdTypes];

View file

@ -6,6 +6,7 @@
*/
import { RRuleAttributes } from '../../r_rule/types';
import { MaintenanceWindowCategoryIdTypes } from '../constants';
export interface MaintenanceWindowEventAttributes {
gte: string;
@ -23,4 +24,5 @@ export interface MaintenanceWindowAttributes {
updatedBy: string | null;
createdAt: string;
updatedAt: string;
categoryIds?: MaintenanceWindowCategoryIdTypes[] | null;
}

View file

@ -41,6 +41,7 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
ruleTaskTimeout: '1m',
validate: {

View file

@ -22,6 +22,7 @@ describe('createAlertEventLogRecordObject', () => {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),

View file

@ -46,6 +46,7 @@ describe('createGetAlertIndicesAliasFn', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
alerts: {
context: 'test',
@ -68,6 +69,7 @@ describe('createGetAlertIndicesAliasFn', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
alerts: {
context: 'spaceAware',
@ -91,6 +93,7 @@ describe('createGetAlertIndicesAliasFn', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),

View file

@ -68,6 +68,7 @@ describe('getLicenseCheckForRuleType', () => {
],
defaultActionGroupId: 'default',
executor: jest.fn(),
category: 'test',
producer: 'alerts',
minimumLicenseRequired: 'gold',
isExportable: true,
@ -206,6 +207,7 @@ describe('ensureLicenseForRuleType()', () => {
],
defaultActionGroupId: 'default',
executor: jest.fn(),
category: 'test',
producer: 'alerts',
minimumLicenseRequired: 'gold',
isExportable: true,

View file

@ -46,6 +46,7 @@ const sampleRuleType: RuleType<never, never, {}, never, never, 'default', 'recov
isExportable: true,
actionGroups: [],
defaultActionGroupId: 'default',
category: 'test',
producer: 'test',
async executor() {
return { state: {} };

View file

@ -45,6 +45,7 @@ const ruleTypes = [
context: [],
state: [],
},
category: 'test',
producer: 'test',
enabledInLicense: true,
minimumScheduleInterval: '1m',

View file

@ -50,6 +50,7 @@ const ruleTypes = [
context: [],
state: [],
},
category: 'test',
producer: 'test',
enabledInLicense: true,
defaultScheduleInterval: '10m',

View file

@ -60,6 +60,7 @@ describe('listAlertTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'test',
enabledInLicense: true,
hasAlertsMappings: false,
@ -86,6 +87,7 @@ describe('listAlertTypesRoute', () => {
"state": Array [],
},
"authorizedConsumers": Object {},
"category": "test",
"defaultActionGroupId": "default",
"enabledInLicense": true,
"hasAlertsMappings": false,
@ -141,6 +143,7 @@ describe('listAlertTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,
@ -197,6 +200,7 @@ describe('listAlertTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,

View file

@ -37,6 +37,7 @@ const createParams = {
title: 'test-title',
duration: 1000,
r_rule: mockMaintenanceWindow.rRule,
category_ids: ['observability'],
} as CreateMaintenanceWindowRequestBody;
describe('createMaintenanceWindowRoute', () => {

View file

@ -15,5 +15,6 @@ export const transformCreateBody = (
title: createBody.title,
duration: createBody.duration,
rRule: createBody.r_rule,
categoryIds: createBody.category_ids,
};
};

View file

@ -11,11 +11,12 @@ import { UpdateMaintenanceWindowParams } from '../../../../../../application/mai
export const transformUpdateBody = (
updateBody: UpdateMaintenanceWindowRequestBodyV1
): UpdateMaintenanceWindowParams['data'] => {
const { title, enabled, duration, r_rule: rRule } = updateBody;
const { title, enabled, duration, r_rule: rRule, category_ids: categoryIds } = updateBody;
return {
...(title !== undefined ? { title } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(duration !== undefined ? { duration } : {}),
...(rRule !== undefined ? { rRule } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
};
};

View file

@ -14,6 +14,7 @@ import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/te
import { MaintenanceWindowStatus } from '../../../../../common';
import { transformUpdateBody } from './transforms';
import { rewritePartialMaintenanceBodyRes } from '../../../lib';
import { UpdateMaintenanceWindowRequestBody } from '../../../../../common/routes/maintenance_window/apis/update';
const maintenanceWindowClient = maintenanceWindowClientMock.create();
@ -29,7 +30,7 @@ const mockMaintenanceWindow = {
id: 'test-id',
};
const updateParams = {
const updateParams: UpdateMaintenanceWindowRequestBody = {
title: 'new-title',
duration: 5000,
enabled: false,
@ -39,6 +40,7 @@ const updateParams = {
freq: 2 as const,
count: 10,
},
category_ids: ['observability'],
};
describe('updateMaintenanceWindowRoute', () => {

View file

@ -26,5 +26,8 @@ export const transformMaintenanceWindowToResponse = (
event_start_time: maintenanceWindow.eventStartTime,
event_end_time: maintenanceWindow.eventEndTime,
status: maintenanceWindow.status,
...(maintenanceWindow.categoryIds !== undefined
? { category_ids: maintenanceWindow.categoryIds }
: {}),
};
};

View file

@ -56,6 +56,7 @@ describe('ruleTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'test',
enabledInLicense: true,
defaultScheduleInterval: '10m',
@ -89,6 +90,7 @@ describe('ruleTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'test',
enabled_in_license: true,
has_alerts_mappings: true,
@ -114,6 +116,7 @@ describe('ruleTypesRoute', () => {
"state": Array [],
},
"authorized_consumers": Object {},
"category": "test",
"default_action_group_id": "default",
"default_schedule_interval": "10m",
"does_set_recovery_context": false,
@ -171,6 +174,7 @@ describe('ruleTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,
@ -227,6 +231,7 @@ describe('ruleTypesRoute', () => {
context: [],
state: [],
},
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,

View file

@ -63,6 +63,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),
@ -87,6 +88,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -125,6 +127,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -152,6 +155,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -181,6 +185,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
defaultScheduleInterval: 'foobar',
validate: {
@ -210,6 +215,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
defaultScheduleInterval: '10s',
validate: {
@ -239,6 +245,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
defaultScheduleInterval: '10s',
validate: {
@ -288,6 +295,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -319,6 +327,7 @@ describe('Create Lifecycle', () => {
name: 'Back To Awesome',
},
executor: jest.fn(),
category: 'test',
producer: 'alerts',
minimumLicenseRequired: 'basic',
isExportable: true,
@ -356,6 +365,7 @@ describe('Create Lifecycle', () => {
defaultActionGroupId: 'default',
ruleTaskTimeout: '13m',
executor: jest.fn(),
category: 'test',
producer: 'alerts',
minimumLicenseRequired: 'basic',
isExportable: true,
@ -399,6 +409,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -427,6 +438,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
ruleTaskTimeout: '20m',
validate: {
@ -458,6 +470,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -484,6 +497,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -503,6 +517,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -526,6 +541,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
alerts: {
context: 'test',
@ -557,6 +573,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),
@ -583,6 +600,7 @@ describe('Create Lifecycle', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -606,6 +624,7 @@ describe('Create Lifecycle', () => {
"params": Array [],
"state": Array [],
},
"category": "test",
"defaultActionGroupId": "default",
"executor": [MockFunction],
"id": "test",
@ -659,6 +678,7 @@ describe('Create Lifecycle', () => {
ruleTaskTimeout: '20m',
minimumLicenseRequired: 'basic',
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),
@ -698,6 +718,7 @@ describe('Create Lifecycle', () => {
},
},
},
"category": "test",
"defaultActionGroupId": "testActionGroup",
"defaultScheduleInterval": undefined,
"doesSetRecoveryContext": false,
@ -779,6 +800,7 @@ describe('Create Lifecycle', () => {
ruleTaskTimeout: '20m',
minimumLicenseRequired: 'basic',
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),
@ -805,6 +827,7 @@ describe('Create Lifecycle', () => {
],
defaultActionGroupId: 'default',
executor: jest.fn(),
category: 'test',
producer: 'alerts',
isExportable: true,
minimumLicenseRequired: 'basic',
@ -855,6 +878,7 @@ function ruleTypeWithVariables<ActionGroupIds extends string>(
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },

View file

@ -59,6 +59,7 @@ export interface RegistryRuleType
| 'recoveryActionGroup'
| 'defaultActionGroupId'
| 'actionVariables'
| 'category'
| 'producer'
| 'minimumLicenseRequired'
| 'isExportable'
@ -381,6 +382,7 @@ export class RuleTypeRegistry {
recoveryActionGroup,
defaultActionGroupId,
actionVariables,
category,
producer,
minimumLicenseRequired,
isExportable,
@ -400,6 +402,7 @@ export class RuleTypeRegistry {
recoveryActionGroup,
defaultActionGroupId,
actionVariables,
category,
producer,
minimumLicenseRequired,
isExportable,

View file

@ -40,6 +40,7 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -22,6 +22,7 @@ describe('validateActions', () => {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -89,6 +89,7 @@ describe('find()', () => {
isExportable: true,
id: 'myType',
name: 'myType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -149,6 +150,7 @@ describe('find()', () => {
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
category: 'test',
producer: 'alerts',
authorizedConsumers: {
myApp: { read: true, all: true },
@ -466,6 +468,7 @@ describe('find()', () => {
isExportable: true,
id: '123',
name: 'myType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -485,6 +488,7 @@ describe('find()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'myApp',
validate: {
params: schema.any(),
@ -502,6 +506,7 @@ describe('find()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -677,6 +682,7 @@ describe('find()', () => {
isExportable: true,
id: '123',
name: 'myType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -696,6 +702,7 @@ describe('find()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'myApp',
validate: {
params: schema.any(),
@ -713,6 +720,7 @@ describe('find()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),

View file

@ -313,6 +313,7 @@ describe('get()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -440,6 +441,7 @@ describe('get()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),

View file

@ -68,6 +68,7 @@ const listedTypes = new Set<RegistryRuleType>([
recoveryActionGroup: RecoveredActionGroup,
id: 'myType',
name: 'myType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -119,6 +120,7 @@ describe('getTags()', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
category: 'test',
producer: 'alerts',
authorizedConsumers: {
myApp: { read: true, all: true },

View file

@ -118,6 +118,7 @@ export function getBeforeSetup(
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },

View file

@ -74,6 +74,7 @@ describe('listRuleTypes', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'alertingAlertType',
name: 'alertingAlertType',
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,
@ -89,6 +90,7 @@ describe('listRuleTypes', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myAppAlertType',
name: 'myAppAlertType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -134,6 +136,7 @@ describe('listRuleTypes', () => {
recoveryActionGroup: RecoveredActionGroup,
id: 'myType',
name: 'myType',
category: 'test',
producer: 'myApp',
enabledInLicense: true,
hasAlertsMappings: false,
@ -148,6 +151,7 @@ describe('listRuleTypes', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
category: 'test',
producer: 'alerts',
enabledInLicense: true,
hasAlertsMappings: false,
@ -169,6 +173,7 @@ describe('listRuleTypes', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
category: 'test',
producer: 'alerts',
authorizedConsumers: {
myApp: { read: true, all: true },

View file

@ -288,6 +288,7 @@ describe('resolve()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
@ -425,6 +426,7 @@ describe('resolve()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),

View file

@ -184,6 +184,7 @@ describe('update()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -1003,6 +1004,7 @@ describe('update()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: extractReferencesFn,
@ -1526,6 +1528,7 @@ describe('update()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validLegacyConsumers: [],
});
@ -1908,6 +1911,7 @@ describe('update()', () => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },

View file

@ -371,6 +371,7 @@ beforeEach(() => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -389,6 +390,7 @@ beforeEach(() => {
async executor() {
return { state: {} };
},
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },

View file

@ -54,6 +54,7 @@ describe('isRuleExportable', () => {
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -113,6 +114,7 @@ describe('isRuleExportable', () => {
minimumLicenseRequired: 'basic',
isExportable: false,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },
@ -175,6 +177,7 @@ describe('isRuleExportable', () => {
minimumLicenseRequired: 'basic',
isExportable: false,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: { validate: (params) => params },

View file

@ -72,6 +72,7 @@ const ruleType: NormalizedRuleType<
name: 'Recovered',
},
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),

View file

@ -141,6 +141,7 @@ export const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
category: 'test',
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',

View file

@ -663,6 +663,141 @@ describe('Task Runner', () => {
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
});
test('skips alert notification if active maintenance window contains the rule type category', async () => {
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
ruleType.executor.mockImplementation(
async ({
services: executorServices,
}: RuleExecutorOptions<
RuleTypeParams,
RuleTypeState,
AlertInstanceState,
AlertInstanceContext,
string,
RuleAlertData
>) => {
executorServices.alertFactory.create('1').scheduleActions('default');
return { state: {} };
}
);
const taskRunner = new TaskRunner({
ruleType,
taskInstance: mockedTaskInstance,
context: taskRunnerFactoryInitializerParams,
inMemoryMetrics,
});
expect(AlertingEventLogger).toHaveBeenCalledTimes(1);
rulesClient.getAlertFromRaw.mockReturnValue(mockedRuleTypeSavedObject as Rule);
maintenanceWindowClient.getActiveMaintenanceWindows.mockResolvedValueOnce([
{
...getMockMaintenanceWindow(),
categoryIds: ['test'] as unknown as MaintenanceWindow['categoryIds'],
id: 'test-id-1',
} as MaintenanceWindow,
]);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(mockedRawRuleSO);
await taskRunner.run();
expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0);
const maintenanceWindowIds = ['test-id-1'];
testAlertingEventLogCalls({
activeAlerts: 1,
newAlerts: 1,
status: 'active',
logAlert: 2,
maintenanceWindowIds,
});
expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(
1,
generateAlertOpts({
action: EVENT_LOG_ACTIONS.newInstance,
group: 'default',
state: { start: DATE_1970, duration: '0' },
maintenanceWindowIds,
})
);
expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(
2,
generateAlertOpts({
action: EVENT_LOG_ACTIONS.activeInstance,
group: 'default',
state: { start: DATE_1970, duration: '0' },
maintenanceWindowIds,
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
});
test('allows alert notification if active maintenance window does not contain the rule type category', async () => {
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
ruleType.executor.mockImplementation(
async ({
services: executorServices,
}: RuleExecutorOptions<
RuleTypeParams,
RuleTypeState,
AlertInstanceState,
AlertInstanceContext,
string,
RuleAlertData
>) => {
executorServices.alertFactory.create('1').scheduleActions('default');
return { state: {} };
}
);
const taskRunner = new TaskRunner({
ruleType,
taskInstance: mockedTaskInstance,
context: taskRunnerFactoryInitializerParams,
inMemoryMetrics,
});
expect(AlertingEventLogger).toHaveBeenCalledTimes(1);
rulesClient.getAlertFromRaw.mockReturnValue(mockedRuleTypeSavedObject as Rule);
maintenanceWindowClient.getActiveMaintenanceWindows.mockResolvedValueOnce([
{
...getMockMaintenanceWindow(),
categoryIds: ['something-else'] as unknown as MaintenanceWindow['categoryIds'],
id: 'test-id-1',
} as MaintenanceWindow,
]);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(mockedRawRuleSO);
await taskRunner.run();
expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0);
testAlertingEventLogCalls({
activeAlerts: 1,
generatedActions: 1,
newAlerts: 1,
triggeredActions: 1,
status: 'active',
logAlert: 2,
logAction: 1,
});
expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(
1,
generateAlertOpts({
action: EVENT_LOG_ACTIONS.newInstance,
group: 'default',
state: { start: DATE_1970, duration: '0' },
})
);
expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(
2,
generateAlertOpts({
action: EVENT_LOG_ACTIONS.activeInstance,
group: 'default',
state: { start: DATE_1970, duration: '0' },
})
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
});
test.each(ephemeralTestParams)(
'skips firing actions for active alert if alert is muted %s',
async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => {

View file

@ -414,9 +414,20 @@ export class TaskRunner<
);
}
const maintenanceWindowIds = activeMaintenanceWindows.map(
(maintenanceWindow) => maintenanceWindow.id
);
const maintenanceWindowIds = activeMaintenanceWindows
.filter(({ categoryIds }) => {
// If category IDs array doesn't exist: allow all
if (!Array.isArray(categoryIds)) {
return true;
}
// If category IDs array exist: check category
if ((categoryIds as string[]).includes(ruleType.category)) {
return true;
}
return false;
})
.map(({ id }) => id);
if (maintenanceWindowIds.length) {
this.alertingEventLogger.setMaintenanceWindowIds(maintenanceWindowIds);
}

View file

@ -59,6 +59,7 @@ const ruleType: UntypedNormalizedRuleType = {
name: 'Recovered',
},
executor: jest.fn(),
category: 'test',
producer: 'alerts',
validate: {
params: schema.any(),

View file

@ -288,6 +288,7 @@ export interface RuleType<
WithoutReservedActionGroups<ActionGroupIds, RecoveryActionGroupId>,
AlertData
>;
category: string;
producer: string;
actionVariables?: {
context?: ActionVariable[];

View file

@ -58,6 +58,7 @@
"@kbn/core-http-server-mocks",
"@kbn/serverless",
"@kbn/core-http-router-server-mocks",
"@kbn/core-application-common",
],
"exclude": ["target/**/*"]
}

View file

@ -7,7 +7,7 @@
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server';
import { KibanaRequest } from '@kbn/core/server';
import { KibanaRequest, DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import datemath from '@kbn/datemath';
import type { ESSearchResponse } from '@kbn/es-types';
import {
@ -93,6 +93,7 @@ export function registerAnomalyRuleType({
apmActionVariables.viewInAppUrl,
],
},
category: DEFAULT_APP_CATEGORIES.observability.id,
producer: 'apm',
minimumLicenseRequired: 'basic',
isExportable: true,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server';
import {
formatDurationFromTimeUnitChar,
@ -94,6 +95,7 @@ export function registerErrorCountRuleType({
actionVariables: {
context: errorCountActionVariables,
},
category: DEFAULT_APP_CATEGORIES.observability.id,
producer: APM_SERVER_FEATURE_ID,
minimumLicenseRequired: 'basic',
isExportable: true,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server';
import {
@ -106,6 +107,7 @@ export function registerTransactionDurationRuleType({
actionVariables: {
context: transactionDurationActionVariables,
},
category: DEFAULT_APP_CATEGORIES.observability.id,
producer: APM_SERVER_FEATURE_ID,
minimumLicenseRequired: 'basic',
isExportable: true,

Some files were not shown because too many files have changed in this diff Show more