diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 2db89d9ffb229..4e448f853f18a 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -9,6 +9,7 @@ export const storybookAliases = { ai_assistant: 'x-pack/platform/packages/shared/kbn-ai-assistant/.storybook', + alerting_v2: 'x-pack/platform/plugins/shared/alerting_v2/.storybook', alerting_v2_rule_form: 'x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/.storybook', apm: 'x-pack/solutions/observability/plugins/apm/.storybook', diff --git a/x-pack/platform/plugins/shared/alerting_v2/.storybook/main.js b/x-pack/platform/plugins/shared/alerting_v2/.storybook/main.js new file mode 100644 index 0000000000000..86b48c32f103e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/.storybook/main.js @@ -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. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx index 7d68e7a1a40a7..bcf3319f82a29 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/app.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { Route, Routes } from '@kbn/shared-ux-router'; import { CreateRulePage } from './create_rule_page'; import { RulesListPage } from './rules_list_page'; +import { ListNotificationPoliciesPage } from '../pages/list_notification_policies_page/list_notification_policies_page'; +import { NotificationPolicyFormPage } from '../pages/notification_policy_form_page/notification_policy_form_page'; export const App = () => { return ( @@ -19,6 +21,15 @@ export const App = () => { + + + + + + + + + diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/delete_confirmation_modal.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..a4443e5865589 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/delete_confirmation_modal.tsx @@ -0,0 +1,55 @@ +/* + * 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 { EuiConfirmModal, useGeneratedHtmlId } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; + +interface DeleteNotificationPolicyConfirmModalProps { + policyName: string; + onCancel: () => void; + onConfirm: () => void; + isLoading?: boolean; +} + +export const DeleteNotificationPolicyConfirmModal = ({ + policyName, + onCancel, + onConfirm, + isLoading = false, +}: DeleteNotificationPolicyConfirmModalProps) => { + const titleId = useGeneratedHtmlId(); + return ( + + {policyName} }} + /> + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts new file mode 100644 index 0000000000000..be70fd860143c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts @@ -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 { i18n } from '@kbn/i18n'; +import type { NotificationPolicyFormState } from './types'; + +export const WORKFLOW_OPTIONS = [ + { value: 'workflow-1', label: 'Slack notification workflow' }, + { value: 'workflow-2', label: 'PagerDuty escalation workflow' }, + { value: 'workflow-3', label: 'Email digest workflow' }, +]; + +export const FREQUENCY_OPTIONS = [ + { + value: 'immediate', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.frequency.immediate', { + defaultMessage: 'Immediate', + }), + }, + { + value: 'throttle', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.frequency.throttle', { + defaultMessage: 'Throttle', + }), + }, +]; + +export const THROTTLE_INTERVAL_PATTERN = /^[1-9][0-9]*[dhms]$/; + +export const DEFAULT_FORM_STATE: NotificationPolicyFormState = { + name: '', + description: '', + matcher: '', + groupBy: [], + frequency: { type: 'immediate' }, + destinations: [{ type: 'workflow', id: 'workflow-1' }], +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts new file mode 100644 index 0000000000000..5d20f152ad0a3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts @@ -0,0 +1,51 @@ +/* + * 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 { + CreateNotificationPolicyData, + NotificationPolicyResponse, + UpdateNotificationPolicyBody, +} from '@kbn/alerting-v2-schemas'; +import type { NotificationPolicyFormState } from './types'; + +export const toFormState = (response: NotificationPolicyResponse): NotificationPolicyFormState => { + return { + name: response.name, + description: response.description, + matcher: response.matcher ?? '', + groupBy: response.group_by ?? [], + frequency: response.throttle + ? { type: 'throttle', interval: response.throttle.interval } + : { type: 'immediate' }, + destinations: response.destinations.map((d) => ({ type: d.type, id: d.id })), + }; +}; + +export const toCreatePayload = ( + state: NotificationPolicyFormState +): CreateNotificationPolicyData => { + return { + name: state.name, + description: state.description, + ...(state.matcher ? { matcher: state.matcher } : {}), + ...(state.groupBy.length > 0 ? { group_by: state.groupBy } : {}), + ...(state.frequency.type === 'throttle' + ? { throttle: { interval: state.frequency.interval } } + : {}), + destinations: state.destinations.map((d) => ({ type: d.type, id: d.id })), + }; +}; + +export const toUpdatePayload = ( + state: NotificationPolicyFormState, + version: string +): UpdateNotificationPolicyBody => { + return { + ...toCreatePayload(state), + version, + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/index.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/index.ts new file mode 100644 index 0000000000000..6d07e46b8ad73 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/index.ts @@ -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 { NotificationPolicyFormFlyout } from '../form_flyout/notification_policy_form_flyout'; +export { toFormState, toCreatePayload, toUpdatePayload } from './form_utils'; +export { useNotificationPolicyForm } from './use_notification_policy_form'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx new file mode 100644 index 0000000000000..2331c5c9aeec5 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx @@ -0,0 +1,66 @@ +/* + * 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 type { Meta, StoryObj } from '@storybook/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { DEFAULT_FORM_STATE } from './constants'; +import { NotificationPolicyForm } from './notification_policy_form'; +import type { NotificationPolicyFormState } from './types'; + +interface NotificationPolicyFormStoryProps { + defaultValues: NotificationPolicyFormState; +} + +const NotificationPolicyFormStory = ({ defaultValues }: NotificationPolicyFormStoryProps) => { + const methods = useForm({ + mode: 'onBlur', + defaultValues, + }); + + return ( + + + + ); +}; + +const meta: Meta = { + title: 'Alerting V2/Notification Policy/Form', + component: NotificationPolicyFormStory, + parameters: { + layout: 'padded', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const CreateMode: Story = { + args: { + defaultValues: { + ...DEFAULT_FORM_STATE, + groupBy: [...DEFAULT_FORM_STATE.groupBy], + frequency: { ...DEFAULT_FORM_STATE.frequency }, + destinations: DEFAULT_FORM_STATE.destinations.map((destination) => ({ ...destination })), + }, + }, +}; + +export const EditMode: Story = { + args: { + defaultValues: { + name: 'Critical production alerts', + description: 'Routes critical production alerts to escalation workflows', + matcher: 'data.severity : "critical" and data.env : "prod"', + groupBy: ['host.name', 'service.name'], + frequency: { type: 'throttle', interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx new file mode 100644 index 0000000000000..dc8ede338fa79 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@kbn/i18n-react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { DEFAULT_FORM_STATE } from './constants'; +import { NotificationPolicyForm } from './notification_policy_form'; +import type { NotificationPolicyFormState } from './types'; + +const renderForm = (defaultValues: NotificationPolicyFormState = DEFAULT_FORM_STATE) => { + const TestComponent = () => { + const methods = useForm({ + mode: 'onBlur', + defaultValues, + }); + + return ( + + + + + + ); + }; + + return render(); +}; + +const TEST_SUBJ = { + nameInput: 'nameInput', + descriptionInput: 'descriptionInput', + frequencySelect: 'frequencySelect', + throttleIntervalInput: 'throttleIntervalInput', +} as const; + +describe('NotificationPolicyForm', () => { + it('shows required errors for name on blur', async () => { + const user = userEvent.setup(); + renderForm(); + + await user.click(screen.getByTestId(TEST_SUBJ.nameInput)); + await user.tab(); + expect(await screen.findByText('Name is required.')).toBeInTheDocument(); + }); + + it('shows throttle interval input only when throttle frequency is selected', async () => { + const user = userEvent.setup(); + renderForm(); + + expect(screen.queryByTestId(TEST_SUBJ.throttleIntervalInput)).not.toBeInTheDocument(); + + await user.selectOptions(screen.getByTestId(TEST_SUBJ.frequencySelect), 'throttle'); + + expect(screen.getByTestId(TEST_SUBJ.throttleIntervalInput)).toBeInTheDocument(); + }); + + it('validates throttle interval format when in throttle mode', async () => { + const user = userEvent.setup(); + renderForm(); + + await user.selectOptions(screen.getByTestId(TEST_SUBJ.frequencySelect), 'throttle'); + + const intervalInput = screen.getByTestId(TEST_SUBJ.throttleIntervalInput); + await user.clear(intervalInput); + await user.type(intervalInput, '10x'); + await user.tab(); + + expect( + await screen.findByText('Invalid throttle interval. Must be in the format of 1h, 5m, 30s') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx new file mode 100644 index 0000000000000..ab6f7315be625 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx @@ -0,0 +1,358 @@ +/* + * 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 { + EuiComboBox, + EuiFieldText, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiSplitPanel, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { FREQUENCY_OPTIONS, THROTTLE_INTERVAL_PATTERN, WORKFLOW_OPTIONS } from './constants'; +import type { NotificationPolicyDestination, NotificationPolicyFormState } from './types'; + +export const NotificationPolicyForm = () => { + const { control } = useFormContext(); + const frequency = useWatch({ control, name: 'frequency' }); + + return ( + <> + + + +

+ +

+
+ + + +
+ + ( + + + + )} + /> + ( + + + + )} + /> + +
+ + + + + + +

+ +

+
+ + + +
+ + ( + + + + )} + /> + +
+ + + + + + +

+ +

+
+ + + +
+ + ( + + ({ label: g }))} + onCreateOption={(value) => { + field.onChange([...field.value, value]); + }} + onChange={(options) => { + field.onChange(options.map((o) => o.label)); + }} + noSuggestions + /> + + )} + /> + +
+ + + + + + +

+ +

+
+ + + +
+ + ( + + + + )} + /> + {frequency.type === 'throttle' && ( + ( + + + + )} + /> + )} + +
+ + + + + + +

+ +

+
+
+ + + value.length > 0 + ? true + : i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.destination.required', + { defaultMessage: 'At least one destination is required' } + ), + }} + render={({ field, fieldState: { error } }) => ( + + { + const workflow = WORKFLOW_OPTIONS.find((w) => w.value === d.id); + return { + label: workflow?.label ?? '', + value: workflow?.value ?? '', + }; + })} + onChange={(options) => { + field.onChange(options.map((o) => ({ type: 'workflow', id: o.value }))); + }} + options={WORKFLOW_OPTIONS} + /> + + )} + /> + +
+ + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts new file mode 100644 index 0000000000000..5ab6430639ab7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts @@ -0,0 +1,31 @@ +/* + * 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 interface ThrottleFrequency { + type: 'throttle'; + interval: string; // e.g. '1h', '30m', '5m' +} + +export interface ImmediateFrequency { + type: 'immediate'; +} + +export type NotificationPolicyFrequency = ThrottleFrequency | ImmediateFrequency; + +export interface NotificationPolicyDestination { + type: 'workflow'; + id: string; +} + +export interface NotificationPolicyFormState { + name: string; + description: string; + matcher: string; + groupBy: string[]; + frequency: NotificationPolicyFrequency; + destinations: NotificationPolicyDestination[]; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts new file mode 100644 index 0000000000000..0960e23e5640c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { renderHook, act } from '@testing-library/react'; +import type { NotificationPolicyResponse } from '@kbn/alerting-v2-schemas'; +import { useNotificationPolicyForm } from './use_notification_policy_form'; +import { DEFAULT_FORM_STATE } from './constants'; + +const EXISTING_POLICY: NotificationPolicyResponse = { + id: 'policy-1', + version: 'WzEsMV0=', + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + group_by: ['host.name', 'service.name'], + throttle: { interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + createdBy: 'elastic', + createdAt: '2026-03-01T10:00:00.000Z', + updatedBy: 'elastic', + updatedAt: '2026-03-01T10:00:00.000Z', +}; + +describe('useNotificationPolicyForm', () => { + describe('create mode (no initialValues)', () => { + it('returns isEditMode as false', () => { + const { result } = renderHook(() => + useNotificationPolicyForm({ + onSubmitCreate: jest.fn(), + onSubmitUpdate: jest.fn(), + }) + ); + + expect(result.current.isEditMode).toBe(false); + }); + + it('initializes form with DEFAULT_FORM_STATE', () => { + const { result } = renderHook(() => + useNotificationPolicyForm({ + onSubmitCreate: jest.fn(), + onSubmitUpdate: jest.fn(), + }) + ); + + expect(result.current.methods.getValues()).toEqual(DEFAULT_FORM_STATE); + }); + + it('calls onSubmitCreate with the create payload on submit', async () => { + const onSubmitCreate = jest.fn(); + const { result } = renderHook(() => + useNotificationPolicyForm({ + onSubmitCreate, + onSubmitUpdate: jest.fn(), + }) + ); + + await act(async () => { + result.current.methods.setValue('name', 'My policy'); + result.current.methods.setValue('description', 'A description'); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(onSubmitCreate).toHaveBeenCalledTimes(1); + expect(onSubmitCreate).toHaveBeenCalledWith({ + name: 'My policy', + description: 'A description', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + }); + + it('omits optional empty fields from the create payload', async () => { + const onSubmitCreate = jest.fn(); + const { result } = renderHook(() => + useNotificationPolicyForm({ + onSubmitCreate, + onSubmitUpdate: jest.fn(), + }) + ); + + await act(async () => { + result.current.methods.setValue('name', 'Minimal'); + result.current.methods.setValue('description', 'Desc'); + result.current.methods.setValue('matcher', ''); + result.current.methods.setValue('groupBy', []); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + const payload = onSubmitCreate.mock.calls[0][0]; + expect(payload).not.toHaveProperty('matcher'); + expect(payload).not.toHaveProperty('group_by'); + expect(payload).not.toHaveProperty('throttle'); + }); + }); + + describe('edit mode (with initialValues)', () => { + it('returns isEditMode as true', () => { + const { result } = renderHook(() => + useNotificationPolicyForm({ + initialValues: EXISTING_POLICY, + onSubmitCreate: jest.fn(), + onSubmitUpdate: jest.fn(), + }) + ); + + expect(result.current.isEditMode).toBe(true); + }); + + it('initializes form with values derived from the existing policy', () => { + const { result } = renderHook(() => + useNotificationPolicyForm({ + initialValues: EXISTING_POLICY, + onSubmitCreate: jest.fn(), + onSubmitUpdate: jest.fn(), + }) + ); + + expect(result.current.methods.getValues()).toEqual({ + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + groupBy: ['host.name', 'service.name'], + frequency: { type: 'throttle', interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }); + }); + + it('maps immediate frequency when no throttle is present', () => { + const policyWithoutThrottle: NotificationPolicyResponse = { + ...EXISTING_POLICY, + throttle: undefined, + }; + const { result } = renderHook(() => + useNotificationPolicyForm({ + initialValues: policyWithoutThrottle, + onSubmitCreate: jest.fn(), + onSubmitUpdate: jest.fn(), + }) + ); + + expect(result.current.methods.getValues().frequency).toEqual({ type: 'immediate' }); + }); + + it('calls onSubmitUpdate with id, payload, and version on submit', async () => { + const onSubmitUpdate = jest.fn(); + const { result } = renderHook(() => + useNotificationPolicyForm({ + initialValues: EXISTING_POLICY, + onSubmitCreate: jest.fn(), + onSubmitUpdate, + }) + ); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(onSubmitUpdate).toHaveBeenCalledTimes(1); + expect(onSubmitUpdate).toHaveBeenCalledWith('policy-1', { + version: 'WzEsMV0=', + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + group_by: ['host.name', 'service.name'], + throttle: { interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }); + }); + + it('does not call onSubmitCreate in edit mode', async () => { + const onSubmitCreate = jest.fn(); + const { result } = renderHook(() => + useNotificationPolicyForm({ + initialValues: EXISTING_POLICY, + onSubmitCreate, + onSubmitUpdate: jest.fn(), + }) + ); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(onSubmitCreate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts new file mode 100644 index 0000000000000..f9e77a443c2c8 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts @@ -0,0 +1,60 @@ +/* + * 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 { + CreateNotificationPolicyData, + NotificationPolicyResponse, + UpdateNotificationPolicyBody, +} from '@kbn/alerting-v2-schemas'; +import { useCallback, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { DEFAULT_FORM_STATE } from './constants'; +import { toCreatePayload, toFormState, toUpdatePayload } from './form_utils'; +import type { NotificationPolicyFormState } from './types'; + +interface UseNotificationPolicyFormParams { + initialValues?: NotificationPolicyResponse; + onSubmitCreate: (data: CreateNotificationPolicyData) => void; + onSubmitUpdate: (id: string, data: UpdateNotificationPolicyBody) => void; +} + +export const useNotificationPolicyForm = ({ + initialValues, + onSubmitCreate, + onSubmitUpdate, +}: UseNotificationPolicyFormParams) => { + const isEditMode = !!initialValues; + + const defaultValues = useMemo( + () => (initialValues ? toFormState(initialValues) : DEFAULT_FORM_STATE), + [initialValues] + ); + + const methods = useForm({ + mode: 'onBlur', + defaultValues, + }); + + const onSubmitValid = useCallback( + (values: NotificationPolicyFormState) => { + if (isEditMode && initialValues?.version) { + onSubmitUpdate(initialValues.id, toUpdatePayload(values, initialValues.version)); + } else { + onSubmitCreate(toCreatePayload(values)); + } + }, + [isEditMode, initialValues, onSubmitCreate, onSubmitUpdate] + ); + + const handleSubmit = useMemo(() => methods.handleSubmit(onSubmitValid), [methods, onSubmitValid]); + + return { + methods, + isEditMode, + handleSubmit, + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx new file mode 100644 index 0000000000000..fe6de9a8a14f7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx @@ -0,0 +1,126 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { NotificationPolicyResponse } from '@kbn/alerting-v2-schemas'; +import { I18nProvider } from '@kbn/i18n-react'; +import { NotificationPolicyFormFlyout } from './notification_policy_form_flyout'; + +const TEST_SUBJ = { + title: 'title', + cancelButton: 'cancelButton', + submitButton: 'submitButton', + nameInput: 'nameInput', + descriptionInput: 'descriptionInput', +} as const; + +const renderFlyout = ({ + onClose = jest.fn(), + onSave, + onUpdate, + initialValues, +}: { + onClose?: jest.Mock; + onSave?: jest.Mock; + onUpdate?: jest.Mock; + initialValues?: NotificationPolicyResponse; +}) => { + return render( + + + + ); +}; + +describe('NotificationPolicyFormFlyout', () => { + it('renders create mode and closes on cancel', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + renderFlyout({ onClose, onSave: jest.fn() }); + + expect(screen.getByTestId(TEST_SUBJ.title)).toHaveTextContent('Create notification policy'); + expect(screen.getByTestId(TEST_SUBJ.submitButton)).toHaveTextContent('Save'); + + await user.click(screen.getByTestId(TEST_SUBJ.cancelButton)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('submits create payload and omits optional empty fields', async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + + renderFlyout({ onClose: jest.fn(), onSave }); + + await user.type(screen.getByTestId(TEST_SUBJ.nameInput), 'Policy from test'); + await user.tab(); + await user.type(screen.getByTestId(TEST_SUBJ.descriptionInput), 'Description from test'); + await user.tab(); + + const saveButton = screen.getByTestId(TEST_SUBJ.submitButton); + await waitFor(() => expect(saveButton).toBeEnabled()); + await user.click(saveButton); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith({ + name: 'Policy from test', + description: 'Description from test', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + }); + + it('renders edit mode and submits update payload with optional fields and version', async () => { + const user = userEvent.setup(); + const onUpdate = jest.fn(); + const initialValues: NotificationPolicyResponse = { + id: 'policy-1', + version: 'WzEsMV0=', + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + group_by: ['host.name', 'service.name'], + throttle: { interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + createdBy: 'elastic', + createdAt: '2026-03-01T10:00:00.000Z', + updatedBy: 'elastic', + updatedAt: '2026-03-01T10:00:00.000Z', + }; + + renderFlyout({ onClose: jest.fn(), onUpdate, initialValues }); + + expect(screen.getByTestId(TEST_SUBJ.title)).toHaveTextContent('Edit notification policy'); + expect(screen.getByTestId(TEST_SUBJ.submitButton)).toHaveTextContent('Update'); + + await user.click(screen.getByTestId(TEST_SUBJ.nameInput)); + await user.tab(); + await user.click(screen.getByTestId(TEST_SUBJ.descriptionInput)); + await user.tab(); + + const updateButton = screen.getByTestId(TEST_SUBJ.submitButton); + await waitFor(() => expect(updateButton).toBeEnabled()); + await user.click(updateButton); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith('policy-1', { + version: 'WzEsMV0=', + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + group_by: ['host.name', 'service.name'], + throttle: { interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.tsx new file mode 100644 index 0000000000000..32014b9f47685 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.tsx @@ -0,0 +1,117 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import type { + CreateNotificationPolicyData, + NotificationPolicyResponse, + UpdateNotificationPolicyBody, +} from '@kbn/alerting-v2-schemas'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { FormProvider } from 'react-hook-form'; +import { NotificationPolicyForm } from '../form/notification_policy_form'; +import { useNotificationPolicyForm } from '../form/use_notification_policy_form'; + +const FLYOUT_TITLE_ID = 'notificationPolicyFlyoutTitle'; + +const noop = () => {}; + +interface NotificationPolicyFormFlyoutProps { + onClose: () => void; + onSave?: (data: CreateNotificationPolicyData) => void; + onUpdate?: (id: string, data: UpdateNotificationPolicyBody) => void; + isLoading?: boolean; + initialValues?: NotificationPolicyResponse; +} + +export const NotificationPolicyFormFlyout = ({ + onClose, + onSave, + onUpdate, + isLoading = false, + initialValues, +}: NotificationPolicyFormFlyoutProps) => { + const onSubmitCreate = (data: CreateNotificationPolicyData) => onSave?.(data); + const onSubmitUpdate = (id: string, data: UpdateNotificationPolicyBody) => onUpdate?.(id, data); + + const { methods, isEditMode, handleSubmit } = useNotificationPolicyForm({ + initialValues, + onSubmitCreate: onSave ? onSubmitCreate : noop, + onSubmitUpdate: onUpdate ? onSubmitUpdate : noop, + }); + + return ( + + + +

+ {isEditMode ? ( + + ) : ( + + )} +

+
+
+ + + + + + + + + + + + + + + {isEditMode ? ( + + ) : ( + + )} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_destination_badge.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_destination_badge.test.tsx new file mode 100644 index 0000000000000..e14829789d344 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_destination_badge.test.tsx @@ -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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import { NotificationPolicyDestinationBadge } from './notification_policy_destination_badge'; + +describe('NotificationPolicyDestinationBadge', () => { + it('renders a workflow destination badge with the destination id', () => { + render( + + ); + + expect(screen.getByText('my-workflow-id')).toBeInTheDocument(); + }); + + it('renders the badge with primary color and workflow icon', () => { + const { container } = render( + + ); + + const badge = container.querySelector('.euiBadge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('test-id'); + }); + + it('renders nothing for an unknown destination type', () => { + render( + + ); + expect(screen.queryByText('test-id')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_destination_badge.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_destination_badge.tsx new file mode 100644 index 0000000000000..97de9656ca455 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_destination_badge.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import type { NotificationPolicyDestination } from '@kbn/alerting-v2-schemas'; +import React from 'react'; + +export const NotificationPolicyDestinationBadge = ({ + destination, +}: { + destination: NotificationPolicyDestination; +}) => { + switch (destination.type) { + case 'workflow': + return ( + + {destination.id} + + ); + default: + return null; + } +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts index 431a3b9cd9089..714f9e69694f1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/constants.ts @@ -5,6 +5,17 @@ * 2.0. */ +export const ALERTING_V2_BASE_PATH = '/app/management/insightsAndAlerting/alerting_v2'; export const ALERTING_V2_APP_ID = 'alerting_v2'; export const ALERTING_V2_APP_ROUTE = '/alerting_v2'; +export const ALERTING_V2_NOTIFICATION_POLICIES_PATH = `${ALERTING_V2_BASE_PATH}/notification_policies`; export const INTERNAL_ALERTING_V2_RULE_API_PATH = '/internal/alerting/v2/rule' as const; +export const INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH = + '/internal/alerting/v2/notification_policies' as const; + +export const paths = { + notificationPolicyCreate: `${ALERTING_V2_NOTIFICATION_POLICIES_PATH}/create`, + notificationPolicyEdit: (id: string) => + `${ALERTING_V2_NOTIFICATION_POLICIES_PATH}/edit/${encodeURIComponent(id)}`, + notificationPolicyList: ALERTING_V2_NOTIFICATION_POLICIES_PATH, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts new file mode 100644 index 0000000000000..d441f3081110f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts @@ -0,0 +1,17 @@ +/* + * 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 notificationPolicyKeys = { + all: ['notificationPolicy'] as const, + create: () => [...notificationPolicyKeys.all, 'create'] as const, + update: () => [...notificationPolicyKeys.all, 'update'] as const, + delete: () => [...notificationPolicyKeys.all, 'delete'] as const, + detail: (id: string) => [...notificationPolicyKeys.all, 'detail', id] as const, + lists: () => [...notificationPolicyKeys.all, 'list'] as const, + list: (filters: { page: number; perPage: number }) => + [...notificationPolicyKeys.lists(), filters] as const, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_notification_policy.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_notification_policy.ts new file mode 100644 index 0000000000000..7441f8992bdda --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_create_notification_policy.ts @@ -0,0 +1,42 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { i18n } from '@kbn/i18n'; +import type { + CreateNotificationPolicyData, + NotificationPolicyResponse, +} from '@kbn/alerting-v2-schemas'; +import { NotificationPoliciesApi } from '../services/notification_policies_api'; +import { notificationPolicyKeys } from './query_key_factory'; + +export const useCreateNotificationPolicy = () => { + const notificationPoliciesApi = useService(NotificationPoliciesApi); + const { toasts } = useService(CoreStart('notifications')); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: notificationPolicyKeys.create(), + mutationFn: (data) => notificationPoliciesApi.createNotificationPolicy(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationPolicyKeys.lists(), exact: false }); + toasts.addSuccess( + i18n.translate('xpack.alertingV2.notificationPolicy.createSuccess', { + defaultMessage: 'Notification policy created successfully', + }) + ); + }, + onError: (error) => { + toasts.addError(error, { + title: i18n.translate('xpack.alertingV2.notificationPolicy.createError', { + defaultMessage: 'Failed to create notification policy', + }), + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_delete_notification_policy.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_delete_notification_policy.ts new file mode 100644 index 0000000000000..3e897a210c2b3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_delete_notification_policy.ts @@ -0,0 +1,38 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { i18n } from '@kbn/i18n'; +import { NotificationPoliciesApi } from '../services/notification_policies_api'; +import { notificationPolicyKeys } from './query_key_factory'; + +export const useDeleteNotificationPolicy = () => { + const notificationPoliciesApi = useService(NotificationPoliciesApi); + const { toasts } = useService(CoreStart('notifications')); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: notificationPolicyKeys.delete(), + mutationFn: (id) => notificationPoliciesApi.deleteNotificationPolicy(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationPolicyKeys.lists(), exact: false }); + toasts.addSuccess( + i18n.translate('xpack.alertingV2.notificationPolicy.deleteSuccess', { + defaultMessage: 'Notification policy deleted successfully', + }) + ); + }, + onError: (error) => { + toasts.addError(error, { + title: i18n.translate('xpack.alertingV2.notificationPolicy.deleteError', { + defaultMessage: 'Failed to delete notification policy', + }), + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_notification_policies.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_notification_policies.ts new file mode 100644 index 0000000000000..1eb88732bd9e6 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_notification_policies.ts @@ -0,0 +1,39 @@ +/* + * 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 '@kbn/react-query'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { i18n } from '@kbn/i18n'; +import type { FindNotificationPoliciesResponse } from '../services/notification_policies_api'; +import { NotificationPoliciesApi } from '../services/notification_policies_api'; +import { notificationPolicyKeys } from './query_key_factory'; + +interface UseFetchNotificationPoliciesParams { + page: number; + perPage: number; +} + +export const useFetchNotificationPolicies = ({ + page, + perPage, +}: UseFetchNotificationPoliciesParams) => { + const notificationPoliciesApi = useService(NotificationPoliciesApi); + const { toasts } = useService(CoreStart('notifications')); + + return useQuery({ + queryKey: notificationPolicyKeys.list({ page, perPage }), + queryFn: () => notificationPoliciesApi.listNotificationPolicies({ page, perPage }), + refetchOnWindowFocus: false, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.alertingV2.notificationPolicies.fetchError', { + defaultMessage: 'Failed to load notification policies', + }), + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_notification_policy.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_notification_policy.ts new file mode 100644 index 0000000000000..9fe1f0ba74121 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_notification_policy.ts @@ -0,0 +1,32 @@ +/* + * 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 '@kbn/react-query'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { i18n } from '@kbn/i18n'; +import type { NotificationPolicyResponse } from '@kbn/alerting-v2-schemas'; +import { NotificationPoliciesApi } from '../services/notification_policies_api'; +import { notificationPolicyKeys } from './query_key_factory'; + +export const useFetchNotificationPolicy = (id: string | undefined) => { + const notificationPoliciesApi = useService(NotificationPoliciesApi); + const { toasts } = useService(CoreStart('notifications')); + + return useQuery({ + queryKey: notificationPolicyKeys.detail(id!), + queryFn: () => notificationPoliciesApi.getNotificationPolicy(id!), + enabled: !!id, + refetchOnWindowFocus: false, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.alertingV2.notificationPolicy.fetchError', { + defaultMessage: 'Failed to load notification policy', + }), + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_update_notification_policy.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_update_notification_policy.ts new file mode 100644 index 0000000000000..12cf33bc1e516 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_update_notification_policy.ts @@ -0,0 +1,47 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { i18n } from '@kbn/i18n'; +import type { + NotificationPolicyResponse, + UpdateNotificationPolicyBody, +} from '@kbn/alerting-v2-schemas'; +import { NotificationPoliciesApi } from '../services/notification_policies_api'; +import { notificationPolicyKeys } from './query_key_factory'; + +interface UpdateNotificationPolicyVariables { + id: string; + data: UpdateNotificationPolicyBody; +} + +export const useUpdateNotificationPolicy = () => { + const notificationPoliciesApi = useService(NotificationPoliciesApi); + const { toasts } = useService(CoreStart('notifications')); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: notificationPolicyKeys.update(), + mutationFn: ({ id, data }) => notificationPoliciesApi.updateNotificationPolicy(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationPolicyKeys.lists(), exact: false }); + toasts.addSuccess( + i18n.translate('xpack.alertingV2.notificationPolicy.updateSuccess', { + defaultMessage: 'Notification policy updated successfully', + }) + ); + }, + onError: (error) => { + toasts.addError(error, { + title: i18n.translate('xpack.alertingV2.notificationPolicy.updateError', { + defaultMessage: 'Failed to update notification policy', + }), + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/index.ts b/x-pack/platform/plugins/shared/alerting_v2/public/index.ts index 037d363a1e205..a743493bb857e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/index.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/index.ts @@ -11,10 +11,12 @@ import { CoreSetup, PluginInitializer } from '@kbn/core-di-browser'; import type { ManagementSetup } from '@kbn/management-plugin/public'; import { mountAlertingV2App } from './main'; import { ALERTING_V2_APP_ID } from './constants'; +import { NotificationPoliciesApi } from './services/notification_policies_api'; import { RulesApi } from './services/rules_api'; export const module = new ContainerModule(({ bind }) => { bind(RulesApi).toSelf().inSingletonScope(); + bind(NotificationPoliciesApi).toSelf().inSingletonScope(); bind(OnSetup).toConstantValue((container) => { const getStartServices = container.get(CoreSetup('getStartServices')); @@ -30,6 +32,7 @@ export const module = new ContainerModule(({ bind }) => { management.sections.section.insightsAndAlerting.registerApp({ id: ALERTING_V2_APP_ID, title: 'Rules V2', + order: 1, async mount(params) { const [coreStart] = await getStartServices(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx index ac7fe03993132..145b19d1f262a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/main.tsx @@ -19,6 +19,7 @@ import type { CoreDiServiceStart } from '@kbn/core-di'; import { ApplicationParameters, Context, CoreStart } from '@kbn/core-di-browser'; import { Router } from '@kbn/shared-ux-router'; import { I18nProvider } from '@kbn/i18n-react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { ALERTING_V2_APP_ID, ALERTING_V2_APP_ROUTE } from './constants'; import { App } from './components/app'; @@ -51,13 +52,17 @@ export const mountAlertingV2App = ({ }): AppUnmount => { const { element, history } = params; + const queryClient = new QueryClient(); + ReactDOM.render( - - - - - + + + + + + + , element ); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.tsx new file mode 100644 index 0000000000000..26f54144653a6 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.tsx @@ -0,0 +1,248 @@ +/* + * 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 { + EuiBadge, + EuiBasicTable, + EuiButton, + EuiCallOut, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiPageHeader, + EuiSpacer, + type CriteriaWithPagination, + type EuiBasicTableColumn, +} from '@elastic/eui'; +import type { NotificationPolicyResponse } from '@kbn/alerting-v2-schemas'; +import { CoreStart, useService } from '@kbn/core-di-browser'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useState } from 'react'; +import { DeleteNotificationPolicyConfirmModal } from '../../components/notification_policy/delete_confirmation_modal'; +import { NotificationPolicyDestinationBadge } from '../../components/notification_policy/notification_policy_destination_badge'; +import { paths } from '../../constants'; +import { useDeleteNotificationPolicy } from '../../hooks/use_delete_notification_policy'; +import { useFetchNotificationPolicies } from '../../hooks/use_fetch_notification_policies'; + +const DEFAULT_PER_PAGE = 20; + +export const ListNotificationPoliciesPage = () => { + const [page, setPage] = useState(0); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); + const [policyToDelete, setPolicyToDelete] = useState(null); + + const { navigateToUrl } = useService(CoreStart('application')); + const { basePath } = useService(CoreStart('http')); + + const { mutate: deleteNotificationPolicy, isLoading: isDeleting } = useDeleteNotificationPolicy(); + + const navigateToCreate = () => { + navigateToUrl(basePath.prepend(paths.notificationPolicyCreate)); + }; + + const navigateToEdit = (id: string) => { + navigateToUrl(basePath.prepend(paths.notificationPolicyEdit(id))); + }; + + const { data, isLoading, isError, error } = useFetchNotificationPolicies({ + page: page + 1, + perPage, + }); + + const items = data?.items ?? []; + const total = data?.total ?? 0; + + const onTableChange = ({ + page: tablePage, + }: CriteriaWithPagination) => { + setPage(tablePage.index); + setPerPage(tablePage.size); + }; + + const pagination = { + pageIndex: page, + pageSize: perPage, + totalItemCount: total, + pageSizeOptions: [1, 10, 20, 50], + }; + + const columns: Array> = [ + { + field: 'name', + name: ( + + ), + }, + { + field: 'destinations', + name: ( + + ), + render: (destinations: NotificationPolicyResponse['destinations']) => ( + + {destinations?.map((destination) => ( + + + + ))} + {destinations?.length === 0 ? '-' : null} + + ), + }, + { + field: 'matcher', + name: ( + + ), + render: (matcher: NotificationPolicyResponse['matcher']) => + matcher ? ( + + {matcher} + + ) : ( + '-' + ), + }, + { + field: 'group_by', + name: ( + + ), + render: (groupBy: string[]) => ( + + {groupBy?.map((group) => ( + + {group} + + ))} + {groupBy?.length === 0 ? '-' : null} + + ), + }, + + { + field: 'updatedAt', + name: ( + + ), + render: (updatedAt: string) => new Date(updatedAt).toLocaleString(), + }, + { + name: i18n.translate('xpack.alertingV2.notificationPoliciesList.column.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('xpack.alertingV2.notificationPoliciesList.action.edit', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'xpack.alertingV2.notificationPoliciesList.action.edit.description', + { defaultMessage: 'Edit this notification policy' } + ), + icon: 'pencil', + type: 'icon', + onClick: (item: NotificationPolicyResponse) => navigateToEdit(item.id), + }, + { + name: i18n.translate('xpack.alertingV2.notificationPoliciesList.action.delete', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'xpack.alertingV2.notificationPoliciesList.action.delete.description', + { defaultMessage: 'Delete this notification policy' } + ), + icon: 'trash', + type: 'icon', + color: 'danger', + onClick: (item: NotificationPolicyResponse) => setPolicyToDelete(item), + }, + ], + }, + ]; + + const errorMessage = isError && error ? error.message : null; + + return ( + <> + + } + rightSideItems={[ + + + , + ]} + /> + + {errorMessage ? ( + <> + + } + color="danger" + iconType="error" + > + {errorMessage} + + + + ) : null} + + {policyToDelete && ( + setPolicyToDelete(null)} + onConfirm={() => { + deleteNotificationPolicy(policyToDelete.id, { + onSuccess: () => setPolicyToDelete(null), + }); + }} + isLoading={isDeleting} + /> + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx new file mode 100644 index 0000000000000..b433914891f98 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx @@ -0,0 +1,256 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { NotificationPolicyResponse } from '@kbn/alerting-v2-schemas'; +import { I18nProvider } from '@kbn/i18n-react'; +import { NotificationPolicyFormPage } from './notification_policy_form_page'; + +const mockNavigateToUrl = jest.fn(); +const mockBasePath = { prepend: jest.fn((path: string) => `/mock${path}`) }; + +jest.mock('@kbn/core-di-browser', () => ({ + useService: jest.fn((token: unknown) => { + const tokenStr = String(token); + if (tokenStr.includes('application')) { + return { navigateToUrl: mockNavigateToUrl }; + } + if (tokenStr.includes('http')) { + return { basePath: mockBasePath }; + } + return {}; + }), + CoreStart: jest.fn((name: string) => `CoreStart(${name})`), +})); + +const mockCreateMutate = jest.fn(); +const mockUpdateMutate = jest.fn(); + +jest.mock('../../hooks/use_create_notification_policy', () => ({ + useCreateNotificationPolicy: () => ({ + mutate: mockCreateMutate, + isLoading: false, + }), +})); + +jest.mock('../../hooks/use_update_notification_policy', () => ({ + useUpdateNotificationPolicy: () => ({ + mutate: mockUpdateMutate, + isLoading: false, + }), +})); + +const mockUseFetchNotificationPolicy = jest.fn(); +jest.mock('../../hooks/use_fetch_notification_policy', () => ({ + useFetchNotificationPolicy: (...args: unknown[]) => mockUseFetchNotificationPolicy(...args), +})); + +const mockUseParams = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => mockUseParams(), +})); + +const TEST_SUBJ = { + pageTitle: 'pageTitle', + cancelButton: 'cancelButton', + submitButton: 'submitButton', + nameInput: 'nameInput', + descriptionInput: 'descriptionInput', + loadingSpinner: 'loadingSpinner', + fetchErrorCallout: 'fetchErrorCallout', +} as const; + +const EXISTING_POLICY: NotificationPolicyResponse = { + id: 'policy-1', + version: 'WzEsMV0=', + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + group_by: ['host.name', 'service.name'], + throttle: { interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + createdBy: 'elastic', + createdAt: '2026-03-01T10:00:00.000Z', + updatedBy: 'elastic', + updatedAt: '2026-03-01T10:00:00.000Z', +}; + +const renderPage = () => { + return render( + + + + ); +}; + +describe('NotificationPolicyFormPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseFetchNotificationPolicy.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + }); + }); + + describe('create mode', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({}); + }); + + it('renders create title and save button', () => { + renderPage(); + + expect(screen.getByTestId(TEST_SUBJ.pageTitle)).toHaveTextContent( + 'Create notification policy' + ); + expect(screen.getByTestId(TEST_SUBJ.submitButton)).toHaveTextContent('Save'); + }); + + it('submits create payload on save', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByTestId(TEST_SUBJ.nameInput), 'Policy from test'); + await user.tab(); + await user.type(screen.getByTestId(TEST_SUBJ.descriptionInput), 'Description from test'); + await user.tab(); + + const saveButton = screen.getByTestId(TEST_SUBJ.submitButton); + await waitFor(() => expect(saveButton).toBeEnabled()); + await user.click(saveButton); + + expect(mockCreateMutate).toHaveBeenCalledTimes(1); + expect(mockCreateMutate).toHaveBeenCalledWith( + { + name: 'Policy from test', + description: 'Description from test', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); + + it('navigates to listing page on cancel', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByTestId(TEST_SUBJ.cancelButton)); + + expect(mockNavigateToUrl).toHaveBeenCalledWith( + expect.stringContaining('/notification_policies') + ); + }); + }); + + describe('edit mode', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ id: 'policy-1' }); + }); + + it('renders edit title and update button when policy is loaded', () => { + mockUseFetchNotificationPolicy.mockReturnValue({ + data: EXISTING_POLICY, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + expect(screen.getByTestId(TEST_SUBJ.pageTitle)).toHaveTextContent('Edit notification policy'); + expect(screen.getByTestId(TEST_SUBJ.submitButton)).toHaveTextContent('Update'); + }); + + it('shows loading state while fetching', () => { + mockUseFetchNotificationPolicy.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + }); + + renderPage(); + + expect(screen.getByTestId(TEST_SUBJ.loadingSpinner)).toBeInTheDocument(); + }); + + it('shows error callout when fetch fails', () => { + mockUseFetchNotificationPolicy.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Not found'), + }); + + renderPage(); + + expect(screen.getByTestId(TEST_SUBJ.fetchErrorCallout)).toBeInTheDocument(); + expect(screen.getByText('Not found')).toBeInTheDocument(); + }); + + it('submits update payload on save', async () => { + const user = userEvent.setup(); + mockUseFetchNotificationPolicy.mockReturnValue({ + data: EXISTING_POLICY, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + await user.click(screen.getByTestId(TEST_SUBJ.nameInput)); + await user.tab(); + await user.click(screen.getByTestId(TEST_SUBJ.descriptionInput)); + await user.tab(); + + const updateButton = screen.getByTestId(TEST_SUBJ.submitButton); + await waitFor(() => expect(updateButton).toBeEnabled()); + await user.click(updateButton); + + expect(mockUpdateMutate).toHaveBeenCalledTimes(1); + expect(mockUpdateMutate).toHaveBeenCalledWith( + { + id: 'policy-1', + data: { + version: 'WzEsMV0=', + name: 'Critical production alerts', + description: 'Routes critical alerts', + matcher: 'data.severity : "critical"', + group_by: ['host.name', 'service.name'], + throttle: { interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }, + }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); + + it('navigates to listing page on cancel', async () => { + const user = userEvent.setup(); + mockUseFetchNotificationPolicy.mockReturnValue({ + data: EXISTING_POLICY, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + await user.click(screen.getByTestId(TEST_SUBJ.cancelButton)); + + expect(mockNavigateToUrl).toHaveBeenCalledWith( + expect.stringContaining('/notification_policies') + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.tsx new file mode 100644 index 0000000000000..c1601282e7ce5 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.tsx @@ -0,0 +1,198 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPageHeader, + EuiSpacer, +} from '@elastic/eui'; +import type { + CreateNotificationPolicyData, + NotificationPolicyResponse, + UpdateNotificationPolicyBody, +} from '@kbn/alerting-v2-schemas'; +import { CoreStart, useService } from '@kbn/core-di-browser'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback } from 'react'; +import { FormProvider } from 'react-hook-form'; +import { useParams } from 'react-router-dom'; +import { NotificationPolicyForm } from '../../components/notification_policy/form/notification_policy_form'; +import { useNotificationPolicyForm } from '../../components/notification_policy/form/use_notification_policy_form'; +import { paths } from '../../constants'; +import { useCreateNotificationPolicy } from '../../hooks/use_create_notification_policy'; +import { useFetchNotificationPolicy } from '../../hooks/use_fetch_notification_policy'; +import { useUpdateNotificationPolicy } from '../../hooks/use_update_notification_policy'; + +export const NotificationPolicyFormPage = () => { + const { id: policyId } = useParams<{ id?: string }>(); + const { navigateToUrl } = useService(CoreStart('application')); + const { basePath } = useService(CoreStart('http')); + + const { + data: existingPolicy, + isLoading: isFetchingPolicy, + isError: isFetchError, + error: fetchError, + } = useFetchNotificationPolicy(policyId); + + const isEditMode = !!policyId; + const isReady = !isEditMode || !!existingPolicy; + + const navigateToList = useCallback(() => { + navigateToUrl(basePath.prepend(paths.notificationPolicyList)); + }, [navigateToUrl, basePath]); + + if (isEditMode && isFetchingPolicy) { + return ( + <> + + } + /> + + + + + + ); + } + + if (isEditMode && isFetchError) { + return ( + <> + + } + /> + + + } + color="danger" + iconType="error" + data-test-subj="fetchErrorCallout" + > + {fetchError?.message} + + + ); + } + + if (!isReady) { + return null; + } + + return ( + + ); +}; + +const NotificationPolicyFormPageContent = ({ + initialPolicy, + onCancel, + onSuccess, +}: { + initialPolicy?: NotificationPolicyResponse; + onCancel: () => void; + onSuccess: () => void; +}) => { + const { mutate: createPolicy, isLoading: isCreating } = useCreateNotificationPolicy(); + const { mutate: updatePolicy, isLoading: isUpdating } = useUpdateNotificationPolicy(); + + const onSubmitCreate = (data: CreateNotificationPolicyData) => createPolicy(data, { onSuccess }); + const onSubmitUpdate = (id: string, data: UpdateNotificationPolicyBody) => + updatePolicy({ id, data }, { onSuccess }); + + const { methods, isEditMode, handleSubmit } = useNotificationPolicyForm({ + initialValues: initialPolicy, + onSubmitCreate, + onSubmitUpdate, + }); + + const isLoading = isCreating || isUpdating; + + return ( + <> + + ) : ( + + ) + } + data-test-subj="pageTitle" + /> + +
+ + + + + + + + {isEditMode ? ( + + ) : ( + + )} + + + + + + + + +
+ + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts b/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts new file mode 100644 index 0000000000000..02bf77ad6c83c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { inject, injectable } from 'inversify'; +import type { HttpStart } from '@kbn/core/public'; +import { CoreStart } from '@kbn/core-di-browser'; +import type { + CreateNotificationPolicyData, + NotificationPolicyResponse, + UpdateNotificationPolicyBody, +} from '@kbn/alerting-v2-schemas'; +import { INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH } from '../constants'; + +export interface FindNotificationPoliciesResponse { + items: NotificationPolicyResponse[]; + total: number; + page: number; + perPage: number; +} + +@injectable() +export class NotificationPoliciesApi { + constructor(@inject(CoreStart('http')) private readonly http: HttpStart) {} + + public async getNotificationPolicy(id: string) { + return this.http.get( + `${INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH}/${id}` + ); + } + + public async listNotificationPolicies(params: { page?: number; perPage?: number }) { + return this.http.get( + INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH, + { query: { page: params.page, perPage: params.perPage } } + ); + } + + public async createNotificationPolicy(data: CreateNotificationPolicyData) { + return this.http.post( + INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH, + { body: JSON.stringify(data) } + ); + } + + public async updateNotificationPolicy(id: string, data: UpdateNotificationPolicyBody) { + return this.http.put( + `${INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH}/${id}`, + { body: JSON.stringify(data) } + ); + } + + public async deleteNotificationPolicy(id: string) { + await this.http.delete(`${INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH}/${id}`); + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/index.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/index.ts index 291235d5cd7b5..855dd06f63a4a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/index.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/index.ts @@ -6,4 +6,9 @@ */ export { NotificationPolicyClient } from './notification_policy_client'; -export type { CreateNotificationPolicyParams, UpdateNotificationPolicyParams } from './types'; +export type { + CreateNotificationPolicyParams, + FindNotificationPoliciesParams, + FindNotificationPoliciesResponse, + UpdateNotificationPolicyParams, +} from './types'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts index dadfaaa8c4f68..5064acf51f595 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts @@ -22,7 +22,12 @@ import type { NotificationPolicySavedObjectServiceContract } from '../services/n import { NotificationPolicySavedObjectServiceScopedToken } from '../services/notification_policy_saved_object_service/tokens'; import type { UserServiceContract } from '../services/user_service/user_service'; import { UserService } from '../services/user_service/user_service'; -import type { CreateNotificationPolicyParams, UpdateNotificationPolicyParams } from './types'; +import type { + CreateNotificationPolicyParams, + FindNotificationPoliciesParams, + FindNotificationPoliciesResponse, + UpdateNotificationPolicyParams, +} from './types'; const toAuthResponse = ( auth: NotificationPolicySavedObjectAttributes['auth'] @@ -175,6 +180,26 @@ export class NotificationPolicyClient { } } + public async findNotificationPolicies( + params: FindNotificationPoliciesParams = {} + ): Promise { + const page = params.page ?? 1; + const perPage = params.perPage ?? 20; + + const res = await this.notificationPolicySavedObjectService.find({ page, perPage }); + + return { + items: res.saved_objects.map((so) => ({ + id: so.id, + version: so.version, + ...so.attributes, + })), + total: res.total, + page, + perPage, + }; + } + public async deleteNotificationPolicy({ id }: { id: string }): Promise { await this.getNotificationPolicy({ id }); await this.notificationPolicySavedObjectService.delete({ id }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/types.ts index 8465df2209cf7..1512720393bdb 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/types.ts @@ -7,6 +7,7 @@ import type { CreateNotificationPolicyData, + NotificationPolicyResponse, UpdateNotificationPolicyData, } from '@kbn/alerting-v2-schemas'; @@ -19,3 +20,15 @@ export interface CreateNotificationPolicyParams { data: CreateNotificationPolicyData; options?: { id?: string }; } + +export interface FindNotificationPoliciesParams { + page?: number; + perPage?: number; +} + +export interface FindNotificationPoliciesResponse { + items: NotificationPolicyResponse[]; + total: number; + page: number; + perPage: number; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts index 6b36fe1d38d0c..43ba392677e76 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts @@ -48,6 +48,14 @@ export interface NotificationPolicySavedObjectServiceContract { version: string; }): Promise<{ id: string; version?: string }>; delete(params: { id: string }): Promise; + find(params: { page: number; perPage: number }): Promise<{ + saved_objects: Array<{ + id: string; + attributes: NotificationPolicySavedObjectAttributes; + version?: string; + }>; + total: number; + }>; } @injectable() @@ -153,4 +161,14 @@ export class NotificationPolicySavedObjectService public async delete({ id }: { id: string }): Promise { await this.client.delete(NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, id); } + + public async find({ page, perPage }: { page: number; perPage: number }) { + return this.client.find({ + type: NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, + page, + perPage, + sortField: 'updatedAt', + sortOrder: 'desc', + }); + } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/notification_policies/list_notification_policies_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/notification_policies/list_notification_policies_route.ts new file mode 100644 index 0000000000000..f5a03595bddbf --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/notification_policies/list_notification_policies_route.ts @@ -0,0 +1,66 @@ +/* + * 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 Boom from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import type { TypeOf } from '@kbn/config-schema'; +import { Request, Response } from '@kbn/core-di-server'; +import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; +import { inject, injectable } from 'inversify'; +import { NotificationPolicyClient } from '../../lib/notification_policy_client'; +import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; +import { INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH } from '../constants'; + +const listNotificationPoliciesQuerySchema = schema.object({ + page: schema.maybe(schema.number({ min: 1 })), + perPage: schema.maybe(schema.number({ min: 1, max: 100 })), +}); + +@injectable() +export class ListNotificationPoliciesRoute { + static method = 'get' as const; + static path = `${INTERNAL_ALERTING_V2_NOTIFICATION_POLICY_API_PATH}`; + static security: RouteSecurity = { + authz: { + requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.notificationPolicies.read], + }, + }; + static options = { access: 'internal' } as const; + static validate = { + request: { + query: listNotificationPoliciesQuerySchema, + }, + } as const; + + constructor( + @inject(Request) + private readonly request: KibanaRequest< + unknown, + TypeOf, + unknown + >, + @inject(Response) private readonly response: KibanaResponseFactory, + @inject(NotificationPolicyClient) + private readonly notificationPolicyClient: NotificationPolicyClient + ) {} + + async handle() { + try { + const result = await this.notificationPolicyClient.findNotificationPolicies({ + page: this.request.query?.page, + perPage: this.request.query?.perPage, + }); + return this.response.ok({ body: result }); + } catch (e) { + const boom = Boom.isBoom(e) ? e : Boom.boomify(e); + return this.response.customError({ + statusCode: boom.output.statusCode, + body: boom.output.payload, + }); + } + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts index b7fa405258e9f..4c31ea7d12321 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts @@ -18,6 +18,7 @@ import { CreateNotificationPolicyRoute } from '../routes/notification_policies/c import { GetNotificationPolicyRoute } from '../routes/notification_policies/get_notification_policy_route'; import { UpdateNotificationPolicyRoute } from '../routes/notification_policies/update_notification_policy_route'; import { DeleteNotificationPolicyRoute } from '../routes/notification_policies/delete_notification_policy_route'; +import { ListNotificationPoliciesRoute } from '../routes/notification_policies/list_notification_policies_route'; export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(CreateRuleRoute); @@ -31,4 +32,5 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(GetNotificationPolicyRoute); bind(Route).toConstantValue(UpdateNotificationPolicyRoute); bind(Route).toConstantValue(DeleteNotificationPolicyRoute); + bind(Route).toConstantValue(ListNotificationPoliciesRoute); } diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 755b18167506f..5a6d62a1e9acd 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -54,9 +54,10 @@ "@kbn/core-user-profile-server", "@kbn/es-mappings", "@kbn/std", + "@kbn/eval-kql", + "@kbn/react-query", "@kbn/core-security-server", - "@kbn/encrypted-saved-objects-plugin", - "@kbn/eval-kql" + "@kbn/encrypted-saved-objects-plugin" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/index.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/index.ts index a9e37dab6b752..907158541e1fb 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/index.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/index.ts @@ -11,6 +11,7 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) describe('notification_policy', () => { loadTestFile(require.resolve('./create_notification_policy')); loadTestFile(require.resolve('./get_notification_policy')); + loadTestFile(require.resolve('./list_notification_policies')); loadTestFile(require.resolve('./update_notification_policy')); loadTestFile(require.resolve('./delete_notification_policy')); }); diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/list_notification_policies.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/list_notification_policies.ts new file mode 100644 index 0000000000000..7d01cf342a26b --- /dev/null +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/list_notification_policies.ts @@ -0,0 +1,118 @@ +/* + * 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 expect from '@kbn/expect'; +import type { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; +import type { RoleCredentials } from '../../../services'; + +const NOTIFICATION_POLICY_API_PATH = '/internal/alerting/v2/notification_policies'; +const NOTIFICATION_POLICY_SO_TYPE = 'alerting_notification_policy'; + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + const samlAuth = getService('samlAuth'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const kibanaServer = getService('kibanaServer'); + + async function createPolicy(roleAuthc: RoleCredentials, name: string) { + return supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name, + description: `${name} description`, + destinations: [{ type: 'workflow', id: `${name}-workflow-id` }], + }); + } + + describe('List Notification Policies API', function () { + let roleAuthc: RoleCredentials; + + before(async () => { + await kibanaServer.savedObjects.clean({ types: [NOTIFICATION_POLICY_SO_TYPE] }); + roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin'); + }); + + after(async () => { + await kibanaServer.savedObjects.clean({ types: [NOTIFICATION_POLICY_SO_TYPE] }); + await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); + }); + + it('should return empty list when no policies exist', async () => { + const response = await supertestWithoutAuth + .get(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()); + + expect(response.status).to.be(200); + expect(response.body.items).to.be.an('array'); + expect(response.body.items.length).to.be(0); + expect(response.body.total).to.be(0); + expect(response.body.page).to.be(1); + expect(response.body.perPage).to.be(20); + }); + + it('should return created notification policies', async () => { + const createResponse1 = await createPolicy(roleAuthc, 'policy-1'); + expect(createResponse1.status).to.be(200); + + const createResponse2 = await createPolicy(roleAuthc, 'policy-2'); + expect(createResponse2.status).to.be(200); + + const response = await supertestWithoutAuth + .get(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()); + + expect(response.status).to.be(200); + expect(response.body.items).to.be.an('array'); + expect(response.body.items.length).to.be(2); + expect(response.body.total).to.be(2); + + const names = response.body.items.map((item: { name: string }) => item.name); + expect(names).to.contain('policy-1'); + expect(names).to.contain('policy-2'); + + for (const item of response.body.items) { + expect(item.id).to.be.a('string'); + expect(item.name).to.be.a('string'); + expect(item.description).to.be.a('string'); + expect(item.destinations).to.be.an('array'); + expect(item.createdAt).to.be.a('string'); + expect(item.updatedAt).to.be.a('string'); + } + }); + + it('should paginate results', async () => { + await createPolicy(roleAuthc, 'policy-3'); + + const firstPage = await supertestWithoutAuth + .get(NOTIFICATION_POLICY_API_PATH) + .query({ page: 1, perPage: 2 }) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()); + + expect(firstPage.status).to.be(200); + expect(firstPage.body.items.length).to.be(2); + expect(firstPage.body.total).to.be(3); + expect(firstPage.body.page).to.be(1); + expect(firstPage.body.perPage).to.be(2); + + const secondPage = await supertestWithoutAuth + .get(NOTIFICATION_POLICY_API_PATH) + .query({ page: 2, perPage: 2 }) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()); + + expect(secondPage.status).to.be(200); + expect(secondPage.body.items.length).to.be(1); + expect(secondPage.body.total).to.be(3); + expect(secondPage.body.page).to.be(2); + expect(secondPage.body.perPage).to.be(2); + }); + }); +}