diff --git a/packages/kbn-alerting-types/index.ts b/packages/kbn-alerting-types/index.ts index 6d63366f4343d..fcfccebbae425 100644 --- a/packages/kbn-alerting-types/index.ts +++ b/packages/kbn-alerting-types/index.ts @@ -7,6 +7,9 @@ */ export * from './builtin_action_groups_types'; -export * from './rule_type'; +export * from './rule_type_types'; export * from './action_group_types'; export * from './alert_type'; +export * from './rule_notify_when_type'; +export * from './r_rule_types'; +export * from './rule_types'; diff --git a/packages/kbn-alerting-types/r_rule_types.ts b/packages/kbn-alerting-types/r_rule_types.ts new file mode 100644 index 0000000000000..104d3784f085e --- /dev/null +++ b/packages/kbn-alerting-types/r_rule_types.ts @@ -0,0 +1,19 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { WeekdayStr, Options } from '@kbn/rrule'; + +export type RRuleParams = Partial & Pick; + +// An iCal RRULE to define a recurrence schedule, see https://github.com/jakubroztocil/rrule for the spec +export type RRuleRecord = Omit & { + dtstart: string; + byweekday?: Array; + wkst?: WeekdayStr; + until?: string; +}; diff --git a/packages/kbn-alerting-types/rule_notify_when_type.ts b/packages/kbn-alerting-types/rule_notify_when_type.ts new file mode 100644 index 0000000000000..f078a9d0d0b28 --- /dev/null +++ b/packages/kbn-alerting-types/rule_notify_when_type.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const RuleNotifyWhenTypeValues = [ + 'onActionGroupChange', + 'onActiveAlert', + 'onThrottleInterval', +] as const; + +export type RuleNotifyWhenType = typeof RuleNotifyWhenTypeValues[number]; + +export enum RuleNotifyWhen { + CHANGE = 'onActionGroupChange', + ACTIVE = 'onActiveAlert', + THROTTLE = 'onThrottleInterval', +} diff --git a/packages/kbn-alerting-types/rule_type.ts b/packages/kbn-alerting-types/rule_type_types.ts similarity index 100% rename from packages/kbn-alerting-types/rule_type.ts rename to packages/kbn-alerting-types/rule_type_types.ts diff --git a/packages/kbn-alerting-types/rule_types.ts b/packages/kbn-alerting-types/rule_types.ts new file mode 100644 index 0000000000000..c6fc66788cb0d --- /dev/null +++ b/packages/kbn-alerting-types/rule_types.ts @@ -0,0 +1,241 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectAttributes } from '@kbn/core/server'; +import type { Filter } from '@kbn/es-query'; +import type { RuleNotifyWhenType, RRuleParams } from '.'; + +export type RuleTypeParams = Record; +export type RuleActionParams = SavedObjectAttributes; + +export const ISO_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const; +export type IsoWeekday = typeof ISO_WEEKDAYS[number]; + +export interface IntervalSchedule extends SavedObjectAttributes { + interval: string; +} + +export interface RuleActionFrequency extends SavedObjectAttributes { + summary: boolean; + notifyWhen: RuleNotifyWhenType; + throttle: string | null; +} + +export interface AlertsFilterTimeframe extends SavedObjectAttributes { + days: IsoWeekday[]; + timezone: string; + hours: { + start: string; + end: string; + }; +} + +export interface AlertsFilter extends SavedObjectAttributes { + query?: { + kql: string; + filters: Filter[]; + dsl?: string; // This fields is generated in the code by using "kql", therefore it's not optional but defined as optional to avoid modifying a lot of files in different plugins + }; + timeframe?: AlertsFilterTimeframe; +} + +export interface RuleAction { + uuid?: string; + group: string; + id: string; + actionTypeId: string; + params: RuleActionParams; + frequency?: RuleActionFrequency; + alertsFilter?: AlertsFilter; + useAlertDataForTemplate?: boolean; +} + +export interface RuleSystemAction { + uuid?: string; + id: string; + actionTypeId: string; + params: RuleActionParams; +} + +export interface MappedParamsProperties { + risk_score?: number; + severity?: string; +} + +export type MappedParams = SavedObjectAttributes & MappedParamsProperties; + +// for the `typeof ThingValues[number]` types below, become string types that +// only accept the values in the associated string arrays +export const RuleExecutionStatusValues = [ + 'ok', + 'active', + 'error', + 'pending', + 'unknown', + 'warning', +] as const; + +export const RuleLastRunOutcomeValues = ['succeeded', 'warning', 'failed'] as const; + +export enum RuleExecutionStatusErrorReasons { + Read = 'read', + Decrypt = 'decrypt', + Execute = 'execute', + Unknown = 'unknown', + License = 'license', + Timeout = 'timeout', + Disabled = 'disabled', + Validate = 'validate', +} + +export enum RuleExecutionStatusWarningReasons { + MAX_EXECUTABLE_ACTIONS = 'maxExecutableActions', + MAX_ALERTS = 'maxAlerts', + MAX_QUEUED_ACTIONS = 'maxQueuedActions', +} + +export type RuleExecutionStatuses = typeof RuleExecutionStatusValues[number]; +export type RuleLastRunOutcomes = typeof RuleLastRunOutcomeValues[number]; + +export interface RuleExecutionStatus { + status: RuleExecutionStatuses; + lastExecutionDate: Date; + lastDuration?: number; + error?: { + reason: RuleExecutionStatusErrorReasons; + message: string; + }; + warning?: { + reason: RuleExecutionStatusWarningReasons; + message: string; + }; +} + +export interface RuleMonitoringHistory extends SavedObjectAttributes { + success: boolean; + timestamp: number; + duration?: number; + outcome?: RuleLastRunOutcomes; +} + +export interface RuleMonitoringCalculatedMetrics extends SavedObjectAttributes { + p50?: number; + p95?: number; + p99?: number; + success_ratio: number; +} + +export interface RuleMonitoringLastRunMetrics extends SavedObjectAttributes { + duration?: number; + total_search_duration_ms?: number | null; + total_indexing_duration_ms?: number | null; + total_alerts_detected?: number | null; + total_alerts_created?: number | null; + gap_duration_s?: number | null; +} + +export interface RuleMonitoringLastRun extends SavedObjectAttributes { + timestamp: string; + metrics: RuleMonitoringLastRunMetrics; +} + +export interface RuleMonitoring { + run: { + history: RuleMonitoringHistory[]; + calculated_metrics: RuleMonitoringCalculatedMetrics; + last_run: RuleMonitoringLastRun; + }; +} + +export interface RuleSnoozeSchedule { + duration: number; + rRule: RRuleParams; + // For scheduled/recurring snoozes, `id` uniquely identifies them so that they can be displayed, modified, and deleted individually + id?: string; + skipRecurrences?: string[]; +} + +// Type signature of has to be repeated here to avoid issues with SavedObject compatibility +// RuleSnooze = RuleSnoozeSchedule[] throws typescript errors across the whole lib +export type RuleSnooze = Array<{ + duration: number; + rRule: RRuleParams; + id?: string; + skipRecurrences?: string[]; +}>; + +export interface RuleLastRun { + outcome: RuleLastRunOutcomes; + outcomeOrder?: number; + warning?: RuleExecutionStatusErrorReasons | RuleExecutionStatusWarningReasons | null; + outcomeMsg?: string[] | null; + alertsCount: { + active?: number | null; + new?: number | null; + recovered?: number | null; + ignored?: number | null; + }; +} + +export interface AlertDelay extends SavedObjectAttributes { + active: number; +} + +export interface SanitizedAlertsFilter extends AlertsFilter { + query?: { + kql: string; + filters: Filter[]; + }; + timeframe?: AlertsFilterTimeframe; +} + +export type SanitizedRuleAction = Omit & { + alertsFilter?: SanitizedAlertsFilter; +}; + +export interface Rule { + id: string; + enabled: boolean; + name: string; + tags: string[]; + alertTypeId: string; // this is persisted in the Rule saved object so we would need a migration to change this to ruleTypeId + consumer: string; + schedule: IntervalSchedule; + actions: RuleAction[]; + systemActions?: RuleSystemAction[]; + params: Params; + mapped_params?: MappedParams; + scheduledTaskId?: string | null; + createdBy: string | null; + updatedBy: string | null; + createdAt: Date; + updatedAt: Date; + apiKey: string | null; + apiKeyOwner: string | null; + apiKeyCreatedByUser?: boolean | null; + throttle?: string | null; + muteAll: boolean; + notifyWhen?: RuleNotifyWhenType | null; + mutedInstanceIds: string[]; + executionStatus: RuleExecutionStatus; + monitoring?: RuleMonitoring; + snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API + activeSnoozes?: string[]; + isSnoozedUntil?: Date | null; + lastRun?: RuleLastRun | null; + nextRun?: Date | null; + revision: number; + running?: boolean | null; + viewInAppRelativeUrl?: string; + alertDelay?: AlertDelay; +} + +export type SanitizedRule = Omit< + Rule, + 'apiKey' | 'actions' +> & { actions: SanitizedRuleAction[] }; diff --git a/packages/kbn-alerting-types/tsconfig.json b/packages/kbn-alerting-types/tsconfig.json index 911e35551bbbd..195502cd5a729 100644 --- a/packages/kbn-alerting-types/tsconfig.json +++ b/packages/kbn-alerting-types/tsconfig.json @@ -18,6 +18,9 @@ "kbn_references": [ "@kbn/i18n", "@kbn/licensing-plugin", - "@kbn/rule-data-utils" + "@kbn/rule-data-utils", + "@kbn/rrule", + "@kbn/core", + "@kbn/es-query" ] } diff --git a/packages/kbn-alerts-ui-shared/index.ts b/packages/kbn-alerts-ui-shared/index.ts index 2e766387acf64..45724a0a4f87d 100644 --- a/packages/kbn-alerts-ui-shared/index.ts +++ b/packages/kbn-alerts-ui-shared/index.ts @@ -20,3 +20,5 @@ export * from './src/alert_fields_table'; export * from './src/rule_type_modal'; export * from './src/alert_filter_controls/types'; +export * from './src/rule_form'; +export * from './src/common/hooks'; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts index d5c66e5c502fc..c89837cee6f68 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts @@ -20,16 +20,20 @@ import { RuleTypeIndexWithDescriptions, RuleTypeWithDescription } from '../types export interface UseRuleTypesProps { http: HttpStart; toasts: ToastsStart; - filteredRuleTypes: string[]; + filteredRuleTypes?: string[]; registeredRuleTypes?: Array<{ id: string; description: string }>; enabled?: boolean; } -const getFilteredIndex = ( - data: Array>, - filteredRuleTypes: string[], - registeredRuleTypes: UseRuleTypesProps['registeredRuleTypes'] -) => { +const getFilteredIndex = ({ + data, + filteredRuleTypes, + registeredRuleTypes, +}: { + data: Array>; + filteredRuleTypes?: string[]; + registeredRuleTypes: UseRuleTypesProps['registeredRuleTypes']; +}) => { const index: RuleTypeIndexWithDescriptions = new Map(); const registeredRuleTypesDictionary = registeredRuleTypes ? keyBy(registeredRuleTypes, 'id') : {}; for (const ruleType of data) { @@ -88,7 +92,7 @@ export const useLoadRuleTypesQuery = ({ const filteredIndex = useMemo( () => data - ? getFilteredIndex(data, filteredRuleTypes, registeredRuleTypes) + ? getFilteredIndex({ data, filteredRuleTypes, registeredRuleTypes }) : new Map(), [data, filteredRuleTypes, registeredRuleTypes] ); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/index.ts new file mode 100644 index 0000000000000..dbdcd4efa464f --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './rule_definition'; +export * from './utils'; +export * from './types'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/index.ts new file mode 100644 index 0000000000000..6e81a156ec42b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './rule_definition'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx new file mode 100644 index 0000000000000..0613fa616de2c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RuleAlertDelay } from './rule_alert_delay'; + +const mockOnChange = jest.fn(); + +describe('RuleAlertDelay', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('Renders correctly', () => { + render( + + ); + + expect(screen.getByTestId('alertDelay')).toBeInTheDocument(); + }); + + test('Should handle input change', () => { + render( + + ); + + fireEvent.change(screen.getByTestId('alertDelayInput'), { + target: { + value: '3', + }, + }); + + expect(mockOnChange).toHaveBeenCalledWith('alertDelay', { active: 3 }); + }); + + test('Should only allow integers as inputs', async () => { + render(); + + ['-', '+', 'e', 'E', '.', 'a', '01'].forEach((char) => { + fireEvent.change(screen.getByTestId('alertDelayInput'), { + target: { + value: char, + }, + }); + }); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + test('Should call onChange with null if empty string is typed', () => { + render( + + ); + + fireEvent.change(screen.getByTestId('alertDelayInput'), { + target: { + value: '', + }, + }); + expect(mockOnChange).toHaveBeenCalledWith('alertDelay', null); + }); + + test('Should display error when input is invalid', () => { + render( + + ); + + expect(screen.getByText('Alert delay must be greater than 1.')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx new file mode 100644 index 0000000000000..7418215c71755 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx @@ -0,0 +1,68 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import type { SanitizedRule, RuleTypeParams } from '@kbn/alerting-types'; +import { ALERT_DELAY_TITLE_PREFIX, ALERT_DELAY_TITLE_SUFFIX } from '../translations'; +import { RuleFormErrors } from '../types'; + +const INTEGER_REGEX = /^[1-9][0-9]*$/; +const INVALID_KEYS = ['-', '+', '.', 'e', 'E']; + +export interface RuleAlertDelayProps { + alertDelay?: SanitizedRule['alertDelay'] | null; + errors?: RuleFormErrors; + onChange: (property: string, value: unknown) => void; +} + +export const RuleAlertDelay = (props: RuleAlertDelayProps) => { + const { alertDelay, errors = {}, onChange } = props; + + const onAlertDelayChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value.trim(); + if (value === '') { + onChange('alertDelay', null); + } else if (INTEGER_REGEX.test(value)) { + const parsedValue = parseInt(value, 10); + onChange('alertDelay', { active: parsedValue }); + } + }, + [onChange] + ); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (INVALID_KEYS.includes(e.key)) { + e.preventDefault(); + } + }, []); + + return ( + 0} + error={errors.alertDelay} + data-test-subj="alertDelay" + display="rowCompressed" + > + 0} + append={ALERT_DELAY_TITLE_SUFFIX} + onChange={onAlertDelayChange} + onKeyDown={onKeyDown} + /> + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx new file mode 100644 index 0000000000000..1a1a577d5d684 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { RuleConsumerSelection } from './rule_consumer_selection'; + +const mockOnChange = jest.fn(); +const mockConsumers: RuleCreationValidConsumer[] = ['logs', 'infrastructure', 'stackAlerts']; + +describe('RuleConsumerSelection', () => { + test('Renders correctly', () => { + render( + + ); + + expect(screen.getByTestId('ruleConsumerSelection')).toBeInTheDocument(); + }); + + test('Should default to the selected consumer', () => { + render( + + ); + + expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue('Stack Rules'); + }); + + it('Should not display the initial selected consumer if it is not a selectable option', () => { + render( + + ); + expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue(''); + }); + + it('should display nothing if there is only 1 consumer to select', () => { + render( + + ); + + expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); + }); + + it('should be able to select logs and call onChange', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('comboBoxToggleListButton')); + fireEvent.click(screen.getByTestId('ruleConsumerSelectionOption-logs')); + expect(mockOnChange).toHaveBeenLastCalledWith('consumer', 'logs'); + }); + + it('should be able to show errors when there is one', () => { + render( + + ); + expect(screen.queryAllByText('Scope is required')).toHaveLength(1); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx new file mode 100644 index 0000000000000..957d5c0152220 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx @@ -0,0 +1,111 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; +import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { FEATURE_NAME_MAP, CONSUMER_SELECT_COMBO_BOX_TITLE } from '../translations'; +import { RuleFormErrors } from '../types'; + +export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [ + AlertConsumers.LOGS, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.STACK_ALERTS, + 'alerts', +]; + +export interface RuleConsumerSelectionProps { + consumers: RuleCreationValidConsumer[]; + selectedConsumer?: RuleCreationValidConsumer | null; + errors?: RuleFormErrors; + onChange: (property: string, value: unknown) => void; +} + +const SINGLE_SELECTION = { asPlainText: true }; + +type ComboBoxOption = EuiComboBoxOptionOption; + +export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => { + const { consumers, selectedConsumer, errors = {}, onChange } = props; + + const isInvalid = (errors.consumer?.length || 0) > 0; + + const validatedSelectedConsumer = useMemo(() => { + if ( + selectedConsumer && + consumers.includes(selectedConsumer) && + FEATURE_NAME_MAP[selectedConsumer] + ) { + return selectedConsumer; + } + return null; + }, [selectedConsumer, consumers]); + + const selectedOptions = useMemo(() => { + if (validatedSelectedConsumer) { + return [ + { + value: validatedSelectedConsumer, + label: FEATURE_NAME_MAP[validatedSelectedConsumer], + }, + ]; + } + return []; + }, [validatedSelectedConsumer]); + + const formattedSelectOptions = useMemo(() => { + return consumers + .reduce((result, consumer) => { + if (FEATURE_NAME_MAP[consumer]) { + result.push({ + value: consumer, + 'data-test-subj': `ruleConsumerSelectionOption-${consumer}`, + label: FEATURE_NAME_MAP[consumer], + }); + } + return result; + }, []) + .sort((a, b) => a.value!.localeCompare(b.value!)); + }, [consumers]); + + const onConsumerChange = useCallback( + (selected: ComboBoxOption[]) => { + if (selected.length > 0) { + const newSelectedConsumer = selected[0]; + onChange('consumer', newSelectedConsumer.value); + } else { + onChange('consumer', null); + } + }, + [onChange] + ); + + if (consumers.length <= 1 || consumers.includes(AlertConsumers.OBSERVABILITY)) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx new file mode 100644 index 0000000000000..9ff8a704a728a --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx @@ -0,0 +1,219 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { DocLinksStart } from '@kbn/core-doc-links-browser'; + +import { RuleDefinition } from './rule_definition'; +import { RuleTypeModel } from '../types'; +import { RuleType } from '@kbn/alerting-types'; +import { ALERT_DELAY_TITLE } from '../translations'; + +const ruleType = { + id: '.es-query', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + { + id: 'recovered', + name: 'Recovered', + }, + ], + defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', + recoveryActionGroup: 'recovered', + producer: 'logs', + authorizedConsumers: { + alerting: { read: true, all: true }, + test: { read: true, all: true }, + }, + actionVariables: { + params: [], + state: [], + }, + enabledInLicense: true, +} as unknown as RuleType; + +const ruleModel: RuleTypeModel = { + id: '.es-query', + description: 'Sample rule type model', + iconClass: 'sampleIconClass', + documentationUrl: 'testurl', + validate: (params, isServerless) => ({ errors: {} }), + ruleParamsExpression: () =>
Expression
, + defaultActionMessage: 'Sample default action message', + defaultRecoveryMessage: 'Sample default recovery message', + requiresAppContext: false, +}; + +const requiredPlugins = { + charts: {} as ChartsPluginSetup, + data: {} as DataPublicPluginStart, + dataViews: {} as DataViewsPublicPluginStart, + unifiedSearch: {} as UnifiedSearchPublicPluginStart, + docLinks: {} as DocLinksStart, +}; + +const mockOnChange = jest.fn(); + +describe('Rule Definition', () => { + test('Renders correctly', () => { + render( + + ); + expect(screen.getByTestId('ruleDefinition')).toBeInTheDocument(); + expect(screen.getByTestId('ruleSchedule')).toBeInTheDocument(); + expect(screen.getByTestId('ruleConsumerSelection')).toBeInTheDocument(); + expect(screen.getByTestId('ruleDefinitionHeaderDocsLink')).toBeInTheDocument(); + + expect(screen.getByText(ALERT_DELAY_TITLE)).not.toBeVisible(); + expect(screen.getByText('Expression')).toBeInTheDocument(); + }); + + test('Hides doc link if not provided', () => { + render( + + ); + + expect(screen.queryByTestId('ruleDefinitionHeaderDocsLink')).not.toBeInTheDocument(); + }); + + test('Hides consumer selection if canShowConsumerSelection is false', () => { + render( + + ); + + expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); + }); + + test('Can toggle advanced options', async () => { + render( + + ); + + fireEvent.click(screen.getByTestId('advancedOptionsAccordionButton')); + expect(screen.getByText(ALERT_DELAY_TITLE)).toBeVisible(); + }); + + test('Calls onChange when inputs are modified', () => { + render( + + ); + + fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), { + target: { + value: '10', + }, + }); + expect(mockOnChange).toHaveBeenCalledWith('interval', '10m'); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx new file mode 100644 index 0000000000000..adec6e0966cbd --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx @@ -0,0 +1,292 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { Suspense, useMemo, useState, useCallback } from 'react'; +import { + EuiEmptyPrompt, + EuiLoadingSpinner, + EuiSplitPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiLink, + EuiDescribedFormGroup, + EuiAccordion, + EuiPanel, + EuiSpacer, + EuiErrorBoundary, + EuiIconTip, +} from '@elastic/eui'; +import { + RuleCreationValidConsumer, + ES_QUERY_ID, + OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + ML_ANOMALY_DETECTION_RULE_TYPE_ID, +} from '@kbn/rule-data-utils'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { DocLinksStart } from '@kbn/core-doc-links-browser'; +import type { SanitizedRule, RuleTypeParams } from '@kbn/alerting-types'; +import type { RuleType } from '@kbn/triggers-actions-ui-types'; +import type { RuleTypeModel, RuleFormErrors, MinimumScheduleInterval } from '../types'; +import { + DOC_LINK_TITLE, + LOADING_RULE_TYPE_PARAMS_TITLE, + SCHEDULE_TITLE, + SCHEDULE_DESCRIPTION_TEXT, + ALERT_DELAY_TITLE, + SCOPE_TITLE, + SCOPE_DESCRIPTION_TEXT, + ADVANCED_OPTIONS_TITLE, + ALERT_DELAY_DESCRIPTION_TEXT, + SCHEDULE_TOOLTIP_TEXT, + ALERT_DELAY_HELP_TEXT, +} from '../translations'; +import { RuleAlertDelay } from './rule_alert_delay'; +import { RuleConsumerSelection } from './rule_consumer_selection'; +import { RuleSchedule } from './rule_schedule'; + +const MULTI_CONSUMER_RULE_TYPE_IDS = [ + OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + ES_QUERY_ID, + ML_ANOMALY_DETECTION_RULE_TYPE_ID, +]; + +interface RuleDefinitionProps { + requiredPlugins: { + charts: ChartsPluginSetup; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; + docLinks: DocLinksStart; + }; + formValues: { + id?: SanitizedRule['id']; + params: SanitizedRule['params']; + schedule: SanitizedRule['schedule']; + alertDelay?: SanitizedRule['alertDelay']; + notifyWhen?: SanitizedRule['notifyWhen']; + consumer?: SanitizedRule['consumer']; + }; + minimumScheduleInterval?: MinimumScheduleInterval; + errors?: RuleFormErrors; + canShowConsumerSelection?: boolean; + authorizedConsumers?: RuleCreationValidConsumer[]; + selectedRuleTypeModel: RuleTypeModel; + selectedRuleType: RuleType; + validConsumers?: RuleCreationValidConsumer[]; + onChange: (property: string, value: unknown) => void; +} + +export const RuleDefinition = (props: RuleDefinitionProps) => { + const { + requiredPlugins, + formValues, + errors = {}, + canShowConsumerSelection = false, + authorizedConsumers = [], + selectedRuleTypeModel, + selectedRuleType, + minimumScheduleInterval, + onChange, + } = props; + + const { charts, data, dataViews, unifiedSearch, docLinks } = requiredPlugins; + + const { id, params, schedule, alertDelay, notifyWhen, consumer = 'alerts' } = formValues; + + const [metadata, setMetadata] = useState>(); + const [isAdvancedOptionsVisible, setIsAdvancedOptionsVisible] = useState(false); + + const shouldShowConsumerSelect = useMemo(() => { + if (!canShowConsumerSelection) { + return false; + } + if (!authorizedConsumers.length) { + return false; + } + return ( + selectedRuleTypeModel.id && MULTI_CONSUMER_RULE_TYPE_IDS.includes(selectedRuleTypeModel.id) + ); + }, [authorizedConsumers, selectedRuleTypeModel, canShowConsumerSelection]); + + const RuleParamsExpressionComponent = selectedRuleTypeModel.ruleParamsExpression ?? null; + + const docsUrl = useMemo(() => { + const { documentationUrl } = selectedRuleTypeModel; + if (typeof documentationUrl === 'function') { + return documentationUrl(docLinks); + } + return documentationUrl; + }, [selectedRuleTypeModel, docLinks]); + + const onSetRuleParams = useCallback( + (property: string, value: unknown) => { + onChange('params', { + ...params, + [property]: value, + }); + }, + [onChange, params] + ); + + const onSetRule = useCallback( + (property: string, value: unknown) => { + onChange(property, value); + }, + [onChange] + ); + + return ( + + + + + + {selectedRuleType.name} + + + + +

{selectedRuleTypeModel.description}

+
+
+ {docsUrl && ( + + + + {DOC_LINK_TITLE} + + + + )} +
+
+ + {RuleParamsExpressionComponent && ( + } + body={LOADING_RULE_TYPE_PARAMS_TITLE} + /> + } + > + + + + + + + + + )} + + + {SCHEDULE_TITLE}} + description={ + +

+ {SCHEDULE_DESCRIPTION_TEXT}  + +

+
+ } + > + +
+ {shouldShowConsumerSelect && ( + {SCOPE_TITLE}} + description={

{SCOPE_DESCRIPTION_TEXT}

} + > + +
+ )} + + +

{ADVANCED_OPTIONS_TITLE}

+ + } + > + + + {ALERT_DELAY_TITLE}} + description={ + +

+ {ALERT_DELAY_DESCRIPTION_TEXT}  + +

+
+ } + > + +
+
+
+
+
+
+ ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx new file mode 100644 index 0000000000000..e0f2f3c9ab5a1 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RuleSchedule } from './rule_schedule'; + +const mockOnChange = jest.fn(); + +describe('RuleSchedule', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('Renders correctly', () => { + render(); + + expect(screen.getByTestId('ruleSchedule')).toBeInTheDocument(); + }); + + test('Should allow interval number to be changed', () => { + render(); + + fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), { + target: { + value: '10', + }, + }); + expect(mockOnChange).toHaveBeenCalledWith('interval', '10m'); + }); + + test('Should allow interval unit to be changed', () => { + render(); + + userEvent.selectOptions(screen.getByTestId('ruleScheduleUnitInput'), 'hours'); + expect(mockOnChange).toHaveBeenCalledWith('interval', '5h'); + }); + + test('Should only allow integers as inputs', async () => { + render(); + + ['-', '+', 'e', 'E', '.', 'a', '01'].forEach((char) => { + fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), { + target: { + value: char, + }, + }); + }); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + test('Should display error properly', () => { + render( + + ); + + expect(screen.getByText('something went wrong!')).toBeInTheDocument(); + }); + + test('Should enforce minimum schedule interval', () => { + render( + + ); + + expect(screen.getByText('Interval must be at least 1 minute.')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx new file mode 100644 index 0000000000000..5af00de31b695 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx @@ -0,0 +1,130 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexItem, EuiFormRow, EuiFlexGroup, EuiSelect, EuiFieldNumber } from '@elastic/eui'; +import { + parseDuration, + formatDuration, + getDurationUnitValue, + getDurationNumberInItsUnit, +} from '../utils/parse_duration'; +import { getTimeOptions } from '../utils/get_time_options'; +import { MinimumScheduleInterval, RuleFormErrors } from '../types'; +import { + SCHEDULE_TITLE_PREFIX, + INTERVAL_MINIMUM_TEXT, + INTERVAL_WARNING_TEXT, +} from '../translations'; + +const INTEGER_REGEX = /^[1-9][0-9]*$/; +const INVALID_KEYS = ['-', '+', '.', 'e', 'E']; + +const getHelpTextForInterval = ( + currentInterval: string, + minimumScheduleInterval: MinimumScheduleInterval +) => { + if (!minimumScheduleInterval) { + return ''; + } + + if (minimumScheduleInterval.enforce) { + // Always show help text if minimum is enforced + return INTERVAL_MINIMUM_TEXT(formatDuration(minimumScheduleInterval.value, true)); + } else if ( + currentInterval && + parseDuration(currentInterval) < parseDuration(minimumScheduleInterval.value) + ) { + // Only show help text if current interval is less than suggested + return INTERVAL_WARNING_TEXT(formatDuration(minimumScheduleInterval.value, true)); + } else { + return ''; + } +}; + +export interface RuleScheduleProps { + interval: string; + minimumScheduleInterval?: MinimumScheduleInterval; + errors?: RuleFormErrors; + onChange: (property: string, value: unknown) => void; +} + +export const RuleSchedule = (props: RuleScheduleProps) => { + const { interval, minimumScheduleInterval, errors = {}, onChange } = props; + + const hasIntervalError = errors.interval?.length > 0; + + const intervalNumber = getDurationNumberInItsUnit(interval); + + const intervalUnit = getDurationUnitValue(interval); + + // No help text if there is an error + const helpText = + minimumScheduleInterval && !hasIntervalError + ? getHelpTextForInterval(interval, minimumScheduleInterval) + : ''; + + const onIntervalNumberChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value.trim(); + if (INTEGER_REGEX.test(value)) { + const parsedValue = parseInt(value, 10); + onChange('interval', `${parsedValue}${intervalUnit}`); + } + }, + [intervalUnit, onChange] + ); + + const onIntervalUnitChange = useCallback( + (e: React.ChangeEvent) => { + onChange('interval', `${intervalNumber}${e.target.value}`); + }, + [intervalNumber, onChange] + ); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (INVALID_KEYS.includes(e.key)) { + e.preventDefault(); + } + }, []); + + return ( + 0} + error={errors.interval} + > + + + 0} + value={intervalNumber} + name="interval" + data-test-subj="ruleScheduleNumberInput" + onChange={onIntervalNumberChange} + onKeyDown={onKeyDown} + /> + + + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts new file mode 100644 index 0000000000000..4ae1cbfd206c4 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/translations.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { AlertConsumers } from '@kbn/rule-data-utils'; + +export const DOC_LINK_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.docLinkTitle', + { + defaultMessage: 'View documentation', + } +); + +export const LOADING_RULE_TYPE_PARAMS_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.loadingRuleTypeParamsTitle', + { + defaultMessage: 'Loading rule type params', + } +); + +export const SCHEDULE_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.scheduleTitle', + { + defaultMessage: 'Rule schedule', + } +); + +export const SCHEDULE_DESCRIPTION_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.scheduleDescriptionText', + { + defaultMessage: 'Set the frequency to check the alert conditions', + } +); + +export const SCHEDULE_TOOLTIP_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.scheduleTooltipText', + { + defaultMessage: 'Checks are queued; they run as close to the defined value as capacity allows.', + } +); + +export const ALERT_DELAY_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.alertDelayTitle', + { + defaultMessage: 'Alert delay', + } +); + +export const SCOPE_TITLE = i18n.translate('alertsUIShared.ruleForm.ruleDefinition.scopeTitle', { + defaultMessage: 'Rule scope', +}); + +export const SCOPE_DESCRIPTION_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.scopeDescriptionText', + { + defaultMessage: 'Select the applications to associate the corresponding role privilege', + } +); + +export const ADVANCED_OPTIONS_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.advancedOptionsTitle', + { + defaultMessage: 'Advanced options', + } +); + +export const ALERT_DELAY_DESCRIPTION_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.alertDelayDescription', + { + defaultMessage: + 'Set the number of consecutive runs for which this rule must meet the alert conditions before an alert occurs', + } +); + +export const ALERT_DELAY_TITLE_PREFIX = i18n.translate( + 'alertsUIShared.ruleForm.ruleAlertDelay.alertDelayTitlePrefix', + { + defaultMessage: 'Alert after', + } +); + +export const SCHEDULE_TITLE_PREFIX = i18n.translate( + 'alertsUIShared.ruleForm.ruleSchedule.scheduleTitlePrefix', + { + defaultMessage: 'Every', + } +); + +export const ALERT_DELAY_TITLE_SUFFIX = i18n.translate( + 'alertsUIShared.ruleForm.ruleAlertDelay.alertDelayTitleSuffix', + { + defaultMessage: 'consecutive matches', + } +); + +export const ALERT_DELAY_HELP_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleAlertDelay.alertDelayHelpText', + { + defaultMessage: + 'An alert occurs only when the specified number of consecutive runs meet the rule conditions.', + } +); + +export const CONSUMER_SELECT_TITLE: string = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormConsumerSelection.consumerSelectTitle', + { + defaultMessage: 'Role visibility', + } +); + +export const FEATURE_NAME_MAP: Record = { + [AlertConsumers.LOGS]: i18n.translate('alertsUIShared.ruleForm.ruleFormConsumerSelection.logs', { + defaultMessage: 'Logs', + }), + [AlertConsumers.INFRASTRUCTURE]: i18n.translate( + 'alertsUIShared.ruleForm.ruleFormConsumerSelection.infrastructure', + { + defaultMessage: 'Metrics', + } + ), + [AlertConsumers.APM]: i18n.translate('alertsUIShared.ruleForm.ruleFormConsumerSelection.apm', { + defaultMessage: 'APM and User Experience', + }), + [AlertConsumers.UPTIME]: i18n.translate( + 'alertsUIShared.ruleForm.ruleFormConsumerSelection.uptime', + { + defaultMessage: 'Synthetics and Uptime', + } + ), + [AlertConsumers.SLO]: i18n.translate('alertsUIShared.ruleForm.ruleFormConsumerSelection.slo', { + defaultMessage: 'SLOs', + }), + [AlertConsumers.STACK_ALERTS]: i18n.translate( + 'alertsUIShared.ruleForm.ruleFormConsumerSelection.stackAlerts', + { + defaultMessage: 'Stack Rules', + } + ), +}; + +export const CONSUMER_SELECT_COMBO_BOX_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormConsumerSelection.consumerSelectComboBoxTitle', + { + defaultMessage: 'Select a scope', + } +); + +export const NAME_REQUIRED_TEXT = i18n.translate('alertsUIShared.ruleForm.error.requiredNameText', { + defaultMessage: 'Name is required.', +}); + +export const CONSUMER_REQUIRED_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.error.requiredConsumerText', + { + defaultMessage: 'Scope is required.', + } +); + +export const INTERVAL_REQUIRED_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.error.requiredIntervalText', + { + defaultMessage: 'Check interval is required.', + } +); + +export const RULE_TYPE_REQUIRED_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.error.requiredRuleTypeIdText', + { + defaultMessage: 'Rule type is required.', + } +); + +export const RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.error.belowMinimumAlertDelayText', + { + defaultMessage: 'Alert delay must be greater than 1.', + } +); + +export const INTERVAL_MINIMUM_TEXT = (minimum: string) => + i18n.translate('alertsUIShared.ruleForm.error.belowMinimumText', { + defaultMessage: 'Interval must be at least {minimum}.', + values: { minimum }, + }); + +export const INTERVAL_WARNING_TEXT = (minimum: string) => + i18n.translate('alertsUIShared.ruleForm.intervalWarningText', { + defaultMessage: + 'Intervals less than {minimum} are not recommended due to performance considerations.', + values: { minimum }, + }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/types/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/types/index.ts new file mode 100644 index 0000000000000..ed3241974fa79 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/types/index.ts @@ -0,0 +1,97 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DocLinksStart } from '@kbn/core-doc-links-browser'; +import type { ComponentType } from 'react'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { + RuleNotifyWhenType, + ActionGroup, + SanitizedRule as AlertingSanitizedRule, + RuleAction, + RuleSystemAction, +} from '@kbn/alerting-types'; + +export type RuleTypeParams = Record; + +export interface RuleFormErrors { + [key: string]: string | string[] | RuleFormErrors; +} + +export interface MinimumScheduleInterval { + value: string; + enforce: boolean; +} + +export interface ValidationResult { + errors: Record; +} + +type RuleUiAction = RuleAction | RuleSystemAction; + +// In Triggers and Actions we treat all `Alert`s as `SanitizedRule` +// so the `Params` is a black-box of Record +type SanitizedRule = Omit< + AlertingSanitizedRule, + 'alertTypeId' | 'actions' | 'systemActions' +> & { + ruleTypeId: AlertingSanitizedRule['alertTypeId']; + actions: RuleUiAction[]; +}; + +type Rule = SanitizedRule; + +export type InitialRule = Partial & + Pick; + +export interface RuleTypeParamsExpressionProps< + Params extends RuleTypeParams = RuleTypeParams, + MetaData = Record, + ActionGroupIds extends string = string +> { + id?: string; + ruleParams: Params; + ruleInterval: string; + ruleThrottle: string; + alertNotifyWhen: RuleNotifyWhenType; + setRuleParams: (property: Key, value: Params[Key] | undefined) => void; + setRuleProperty: ( + key: Prop, + value: SanitizedRule[Prop] | null + ) => void; + onChangeMetaData: (metadata: MetaData) => void; + errors: RuleFormErrors; + defaultActionGroupId: string; + actionGroups: Array>; + metadata?: MetaData; + charts: ChartsPluginSetup; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; +} + +export interface RuleTypeModel { + id: string; + description: string; + iconClass: string; + documentationUrl: string | ((docLinks: DocLinksStart) => string) | null; + validate: (ruleParams: Params, isServerless?: boolean) => ValidationResult; + ruleParamsExpression: + | React.FunctionComponent + | React.LazyExoticComponent>>; + requiresAppContext: boolean; + defaultActionMessage?: string; + defaultRecoveryMessage?: string; + defaultSummaryMessage?: string; + alertDetailsAppSection?: + | React.FunctionComponent + | React.LazyExoticComponent>; +} diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_errors.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_errors.ts new file mode 100644 index 0000000000000..445d900d3f56c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_errors.ts @@ -0,0 +1,107 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + InitialRule, + RuleTypeModel, + RuleFormErrors, + ValidationResult, + MinimumScheduleInterval, +} from '../types'; +import { parseDuration, formatDuration } from './parse_duration'; +import { + NAME_REQUIRED_TEXT, + CONSUMER_REQUIRED_TEXT, + RULE_TYPE_REQUIRED_TEXT, + INTERVAL_REQUIRED_TEXT, + INTERVAL_MINIMUM_TEXT, + RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT, +} from '../translations'; + +export function validateBaseProperties({ + rule, + minimumScheduleInterval, +}: { + rule: InitialRule; + minimumScheduleInterval?: MinimumScheduleInterval; +}): ValidationResult { + const validationResult = { errors: {} }; + + const errors = { + name: new Array(), + interval: new Array(), + consumer: new Array(), + ruleTypeId: new Array(), + actionConnectors: new Array(), + alertDelay: new Array(), + }; + + validationResult.errors = errors; + + if (!rule.name) { + errors.name.push(NAME_REQUIRED_TEXT); + } + + if (rule.consumer === null) { + errors.consumer.push(CONSUMER_REQUIRED_TEXT); + } + + if (rule.schedule.interval.length < 2) { + errors.interval.push(INTERVAL_REQUIRED_TEXT); + } else if (minimumScheduleInterval && minimumScheduleInterval.enforce) { + const duration = parseDuration(rule.schedule.interval); + const minimumDuration = parseDuration(minimumScheduleInterval.value); + if (duration < minimumDuration) { + errors.interval.push( + INTERVAL_MINIMUM_TEXT(formatDuration(minimumScheduleInterval.value, true)) + ); + } + } + + if (!rule.ruleTypeId) { + errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT); + } + + if (rule.alertDelay?.active && rule.alertDelay?.active < 1) { + errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT); + } + + return validationResult; +} + +export function getRuleErrors({ + rule, + ruleTypeModel, + minimumScheduleInterval, + isServerless, +}: { + rule: InitialRule; + ruleTypeModel: RuleTypeModel | null; + minimumScheduleInterval?: MinimumScheduleInterval; + isServerless?: boolean; +}) { + const ruleParamsErrors: RuleFormErrors = ruleTypeModel + ? ruleTypeModel.validate(rule.params, isServerless).errors + : {}; + + const ruleBaseErrors = validateBaseProperties({ + rule, + minimumScheduleInterval, + }).errors as RuleFormErrors; + + const ruleErrors = { + ...ruleParamsErrors, + ...ruleBaseErrors, + } as RuleFormErrors; + + return { + ruleParamsErrors, + ruleBaseErrors, + ruleErrors, + }; +} diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_time_options.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_time_options.test.ts new file mode 100644 index 0000000000000..07c06020fea1e --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_time_options.test.ts @@ -0,0 +1,43 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getTimeOptions, getTimeFieldOptions } from './get_time_options'; + +describe('get_time_options', () => { + test('if getTimeOptions return single unit time options', () => { + const timeUnitValue = getTimeOptions(1); + expect(timeUnitValue).toMatchObject([ + { text: 'second', value: 's' }, + { text: 'minute', value: 'm' }, + { text: 'hour', value: 'h' }, + { text: 'day', value: 'd' }, + ]); + }); + + test('if getTimeOptions return multiple unit time options', () => { + const timeUnitValue = getTimeOptions(10); + expect(timeUnitValue).toMatchObject([ + { text: 'seconds', value: 's' }, + { text: 'minutes', value: 'm' }, + { text: 'hours', value: 'h' }, + { text: 'days', value: 'd' }, + ]); + }); + + test('if getTimeFieldOptions return only date type fields', () => { + const timeOnlyTypeFields = getTimeFieldOptions([ + { type: 'date', name: 'order_date' }, + { type: 'date_nanos', name: 'order_date_nanos' }, + { type: 'number', name: 'sum' }, + ]); + expect(timeOnlyTypeFields).toMatchObject([ + { text: 'order_date', value: 'order_date' }, + { text: 'order_date_nanos', value: 'order_date_nanos' }, + ]); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_time_options.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_time_options.ts new file mode 100644 index 0000000000000..5fcaa295e89dc --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_time_options.ts @@ -0,0 +1,69 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export enum TIME_UNITS { + SECOND = 's', + MINUTE = 'm', + HOUR = 'h', + DAY = 'd', +} + +export const getTimeUnitLabel = (timeUnit = TIME_UNITS.SECOND, timeValue = '0') => { + switch (timeUnit) { + case TIME_UNITS.SECOND: + return i18n.translate('alertsUIShared.timeUnits.secondLabel', { + defaultMessage: '{timeValue, plural, one {second} other {seconds}}', + values: { timeValue }, + }); + case TIME_UNITS.MINUTE: + return i18n.translate('alertsUIShared.timeUnits.minuteLabel', { + defaultMessage: '{timeValue, plural, one {minute} other {minutes}}', + values: { timeValue }, + }); + case TIME_UNITS.HOUR: + return i18n.translate('alertsUIShared.timeUnits.hourLabel', { + defaultMessage: '{timeValue, plural, one {hour} other {hours}}', + values: { timeValue }, + }); + case TIME_UNITS.DAY: + return i18n.translate('alertsUIShared.timeUnits.dayLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + } +}; + +export const getTimeOptions = (unitSize: number) => { + return Object.entries(TIME_UNITS).map(([_, value]) => { + return { + text: getTimeUnitLabel(value, unitSize.toString()), + value, + }; + }); +}; + +interface TimeFieldOptions { + text: string; + value: string; +} + +export const getTimeFieldOptions = ( + fields: Array<{ type: string; name: string }> +): TimeFieldOptions[] => { + return fields.reduce((result, field: { type: string; name: string }) => { + if (field.type === 'date' || field.type === 'date_nanos') { + result.push({ + text: field.name, + value: field.name, + }); + } + return result; + }, []); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts new file mode 100644 index 0000000000000..b25e2f561a86a --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './get_errors'; +export * from './get_time_options'; +export * from './parse_duration'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.test.ts new file mode 100644 index 0000000000000..296c8e2bc4900 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.test.ts @@ -0,0 +1,221 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + parseDuration, + formatDuration, + getDurationNumberInItsUnit, + getDurationUnitValue, +} from './parse_duration'; + +const MS_PER_MINUTE = 60 * 1000; + +export function convertDurationToFrequency( + duration: string, + denomination: number = MS_PER_MINUTE +): number { + const durationInMs = parseDuration(duration); + if (denomination === 0) { + throw new Error(`Invalid denomination value: value cannot be 0`); + } + + const intervalInDenominationUnits = durationInMs / denomination; + return 1 / intervalInDenominationUnits; +} + +test('parses seconds', () => { + const result = parseDuration('10s'); + expect(result).toEqual(10000); +}); + +test('parses minutes', () => { + const result = parseDuration('10m'); + expect(result).toEqual(600000); +}); + +test('parses hours', () => { + const result = parseDuration('10h'); + expect(result).toEqual(36000000); +}); + +test('parses days', () => { + const result = parseDuration('10d'); + expect(result).toEqual(864000000); +}); + +test('throws error when the format is invalid', () => { + expect(() => parseDuration('10x')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"10x\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); +}); + +test('throws error when suffix is missing', () => { + expect(() => parseDuration('1000')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"1000\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); +}); + +test('formats single second', () => { + const result = formatDuration('1s'); + expect(result).toEqual('1 sec'); +}); + +test('formats single second with full unit', () => { + const result = formatDuration('1s', true); + expect(result).toEqual('1 second'); +}); + +test('formats seconds', () => { + const result = formatDuration('10s'); + expect(result).toEqual('10 sec'); +}); + +test('formats seconds with full unit', () => { + const result = formatDuration('10s', true); + expect(result).toEqual('10 seconds'); +}); + +test('formats single minute', () => { + const result = formatDuration('1m'); + expect(result).toEqual('1 min'); +}); + +test('formats single minute with full unit', () => { + const result = formatDuration('1m', true); + expect(result).toEqual('1 minute'); +}); + +test('formats minutes', () => { + const result = formatDuration('10m'); + expect(result).toEqual('10 min'); +}); + +test('formats minutes with full unit', () => { + const result = formatDuration('10m', true); + expect(result).toEqual('10 minutes'); +}); + +test('formats single hour', () => { + const result = formatDuration('1h'); + expect(result).toEqual('1 hr'); +}); + +test('formats single hour with full unit', () => { + const result = formatDuration('1h', true); + expect(result).toEqual('1 hour'); +}); + +test('formats hours', () => { + const result = formatDuration('10h'); + expect(result).toEqual('10 hr'); +}); + +test('formats hours with full unit', () => { + const result = formatDuration('10h', true); + expect(result).toEqual('10 hours'); +}); + +test('formats single day', () => { + const result = formatDuration('1d'); + expect(result).toEqual('1 day'); +}); + +test('formats single day with full unit', () => { + const result = formatDuration('1d', true); + expect(result).toEqual('1 day'); +}); + +test('formats days', () => { + const result = formatDuration('10d'); + expect(result).toEqual('10 days'); +}); + +test('formats days with full unit', () => { + const result = formatDuration('10d', true); + expect(result).toEqual('10 days'); +}); + +test('format throws error when the format is invalid', () => { + expect(() => formatDuration('10x')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"10x\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); +}); + +test('format throws error when suffix is missing', () => { + expect(() => formatDuration('1000')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"1000\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); +}); + +test('throws error when 0 based', () => { + expect(() => parseDuration('0s')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"0s\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); + expect(() => parseDuration('0m')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"0m\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); + expect(() => parseDuration('0h')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"0h\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); + expect(() => parseDuration('0d')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"0d\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); +}); + +test('getDurationNumberInItsUnit days', () => { + const result = getDurationNumberInItsUnit('10d'); + expect(result).toEqual(10); +}); + +test('getDurationNumberInItsUnit minutes', () => { + const result = getDurationNumberInItsUnit('1m'); + expect(result).toEqual(1); +}); + +test('getDurationNumberInItsUnit seconds', () => { + const result = getDurationNumberInItsUnit('123s'); + expect(result).toEqual(123); +}); + +test('getDurationUnitValue minutes', () => { + const result = getDurationUnitValue('1m'); + expect(result).toEqual('m'); +}); + +test('getDurationUnitValue days', () => { + const result = getDurationUnitValue('23d'); + expect(result).toEqual('d'); +}); + +test('getDurationUnitValue hours', () => { + const result = getDurationUnitValue('100h'); + expect(result).toEqual('h'); +}); + +test('convertDurationToFrequency converts duration', () => { + let result = convertDurationToFrequency('1m'); + expect(result).toEqual(1); + result = convertDurationToFrequency('5m'); + expect(result).toEqual(0.2); + result = convertDurationToFrequency('10s'); + expect(result).toEqual(6); + result = convertDurationToFrequency('1s'); + expect(result).toEqual(60); +}); + +test('convertDurationToFrequency throws when duration is invalid', () => { + expect(() => convertDurationToFrequency('0d')).toThrowErrorMatchingInlineSnapshot( + `"Invalid duration \\"0d\\". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d\\""` + ); +}); + +test('convertDurationToFrequency throws when denomination is 0', () => { + expect(() => convertDurationToFrequency('1s', 0)).toThrowErrorMatchingInlineSnapshot( + `"Invalid denomination value: value cannot be 0"` + ); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts new file mode 100644 index 0000000000000..81578eb5ae71b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts @@ -0,0 +1,82 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const DEFAULT_RULE_INTERVAL = '1m'; + +const SECONDS_REGEX = /^[1-9][0-9]*s$/; +const MINUTES_REGEX = /^[1-9][0-9]*m$/; +const HOURS_REGEX = /^[1-9][0-9]*h$/; +const DAYS_REGEX = /^[1-9][0-9]*d$/; + +const isSeconds = (duration: string) => { + return SECONDS_REGEX.test(duration); +}; + +const isMinutes = (duration: string) => { + return MINUTES_REGEX.test(duration); +}; + +const isHours = (duration: string) => { + return HOURS_REGEX.test(duration); +}; + +const isDays = (duration: string) => { + return DAYS_REGEX.test(duration); +}; + +// parse an interval string '{digit*}{s|m|h|d}' into milliseconds +export const parseDuration = (duration: string): number => { + const parsed = parseInt(duration, 10); + if (isSeconds(duration)) { + return parsed * 1000; + } else if (isMinutes(duration)) { + return parsed * 60 * 1000; + } else if (isHours(duration)) { + return parsed * 60 * 60 * 1000; + } else if (isDays(duration)) { + return parsed * 24 * 60 * 60 * 1000; + } + throw new Error( + `Invalid duration "${duration}". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d"` + ); +}; + +export const formatDuration = (duration: string, fullUnit?: boolean): string => { + const parsed = parseInt(duration, 10); + if (isSeconds(duration)) { + return `${parsed} ${fullUnit ? (parsed > 1 ? 'seconds' : 'second') : 'sec'}`; + } else if (isMinutes(duration)) { + return `${parsed} ${fullUnit ? (parsed > 1 ? 'minutes' : 'minute') : 'min'}`; + } else if (isHours(duration)) { + return `${parsed} ${fullUnit ? (parsed > 1 ? 'hours' : 'hour') : 'hr'}`; + } else if (isDays(duration)) { + return `${parsed} ${parsed > 1 ? 'days' : 'day'}`; + } + throw new Error( + `Invalid duration "${duration}". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d"` + ); +}; + +export const getDurationNumberInItsUnit = (duration: string): number => { + return parseInt(duration.replace(/[^0-9.]/g, ''), 10); +}; + +export const getDurationUnitValue = (duration: string): string => { + const durationNumber = getDurationNumberInItsUnit(duration); + return duration.replace(durationNumber.toString(), ''); +}; + +export const getInitialInterval = (minimumScheduleInterval?: string): string => { + if (minimumScheduleInterval) { + // return minimum schedule interval if it is larger than the default + if (parseDuration(minimumScheduleInterval) > parseDuration(DEFAULT_RULE_INTERVAL)) { + return minimumScheduleInterval; + } + } + return DEFAULT_RULE_INTERVAL; +}; diff --git a/packages/kbn-alerts-ui-shared/tsconfig.json b/packages/kbn-alerts-ui-shared/tsconfig.json index 0b3a0b3361326..dc2e78cbd49bb 100644 --- a/packages/kbn-alerts-ui-shared/tsconfig.json +++ b/packages/kbn-alerts-ui-shared/tsconfig.json @@ -34,5 +34,8 @@ "@kbn/core-http-browser", "@kbn/core-notifications-browser", "@kbn/kibana-utils-plugin", + "@kbn/core-doc-links-browser", + "@kbn/charts-plugin", + "@kbn/data-plugin", ] } diff --git a/x-pack/examples/triggers_actions_ui_example/kibana.jsonc b/x-pack/examples/triggers_actions_ui_example/kibana.jsonc index e3e6c8adc4a97..e3d9a82a2c368 100644 --- a/x-pack/examples/triggers_actions_ui_example/kibana.jsonc +++ b/x-pack/examples/triggers_actions_ui_example/kibana.jsonc @@ -13,7 +13,11 @@ "developerExamples", "kibanaReact", "cases", - "actions" + "actions", + "charts", + "dataViews", + "dataViewEditor", + "unifiedSearch" ], "optionalPlugins": ["spaces"] } diff --git a/x-pack/examples/triggers_actions_ui_example/public/application.tsx b/x-pack/examples/triggers_actions_ui_example/public/application.tsx index d36d4a91ddd87..6b1dfe98c22b2 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/application.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/application.tsx @@ -8,13 +8,19 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter as Router } from 'react-router-dom'; +import { QueryClient } from '@tanstack/react-query'; import { Route } from '@kbn/shared-ux-router'; import { EuiPage, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; import { AppMountParameters, CoreStart } from '@kbn/core/public'; import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { QueryClientProvider } from '@tanstack/react-query'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { TriggersActionsUiExamplePublicStartDeps } from './plugin'; @@ -32,16 +38,26 @@ import { RuleStatusFilterSandbox } from './components/rule_status_filter_sandbox import { AlertsTableSandbox } from './components/alerts_table_sandbox'; import { RulesSettingsLinkSandbox } from './components/rules_settings_link_sandbox'; +import { RuleDefinitionSandbox } from './components/rule_form/rule_definition_sandbox'; + export interface TriggersActionsUiExampleComponentParams { http: CoreStart['http']; basename: string; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; + charts: ChartsPluginSetup; + dataViews: DataViewsPublicPluginStart; + dataViewsEditor: DataViewEditorStart; + unifiedSearch: UnifiedSearchPublicPluginStart; } const TriggersActionsUiExampleApp = ({ basename, triggersActionsUi, + data, + charts, + dataViews, + unifiedSearch, }: TriggersActionsUiExampleComponentParams) => { return ( @@ -144,11 +160,27 @@ const TriggersActionsUiExampleApp = ({ )} /> + ( + + + + )} + /> ); }; +export const queryClient = new QueryClient(); + export const renderApp = ( core: CoreStart, deps: TriggersActionsUiExamplePublicStartDeps, @@ -168,12 +200,18 @@ export const renderApp = ( }} > - + + + , diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_definition_sandbox.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_definition_sandbox.tsx new file mode 100644 index 0000000000000..55dbfab6ddc4c --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_definition_sandbox.tsx @@ -0,0 +1,171 @@ +/* + * 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, useState, useCallback } from 'react'; +import { EuiLoadingSpinner, EuiCodeBlock, EuiTitle, EuiButton } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { DocLinksStart } from '@kbn/core/public'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { ToastsStart } from '@kbn/core-notifications-browser'; +import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { + RuleDefinition, + useLoadRuleTypesQuery, + getRuleErrors, + InitialRule, +} from '@kbn/alerts-ui-shared'; + +interface RuleDefinitionSandboxProps { + data: DataPublicPluginStart; + charts: ChartsPluginSetup; + dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; +} + +export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [ + AlertConsumers.LOGS, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.STACK_ALERTS, +]; + +const DEFAULT_FORM_VALUES = (ruleTypeId: string) => ({ + id: 'test-id', + name: 'test', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + enabled: true, + tags: [], + actions: [], + ruleTypeId, +}); + +export const RuleDefinitionSandbox = (props: RuleDefinitionSandboxProps) => { + const { data, charts, dataViews, unifiedSearch, triggersActionsUi } = props; + + const [ruleTypeId, setRuleTypeId] = useState('.es-query'); + + const [formValue, setFormValue] = useState(DEFAULT_FORM_VALUES(ruleTypeId)); + + const onChange = useCallback( + (property: string, value: unknown) => { + if (property === 'interval') { + setFormValue({ + ...formValue, + schedule: { + interval: value as string, + }, + }); + return; + } + if (property === 'params') { + setFormValue({ + ...formValue, + params: value as Record, + }); + return; + } + setFormValue({ + ...formValue, + [property]: value, + }); + }, + [formValue] + ); + + const onRuleTypeChange = useCallback((newRuleTypeId: string) => { + setRuleTypeId(newRuleTypeId); + setFormValue(DEFAULT_FORM_VALUES(newRuleTypeId)); + }, []); + + const { docLinks, http, toasts } = useKibana<{ + docLinks: DocLinksStart; + http: HttpStart; + toasts: ToastsStart; + }>().services; + + const { + ruleTypesState: { data: ruleTypeIndex, isLoading }, + } = useLoadRuleTypesQuery({ + http, + toasts, + }); + + const ruleTypes = useMemo(() => [...ruleTypeIndex.values()], [ruleTypeIndex]); + const selectedRuleType = ruleTypes.find((ruleType) => ruleType.id === ruleTypeId); + const selectedRuleTypeModel = triggersActionsUi.ruleTypeRegistry.get(ruleTypeId); + + const errors = useMemo(() => { + if (!selectedRuleType || !selectedRuleTypeModel) { + return {}; + } + + return getRuleErrors({ + rule: formValue, + minimumScheduleInterval: { + value: '1m', + enforce: true, + }, + ruleTypeModel: selectedRuleTypeModel, + }).ruleErrors; + }, [formValue, selectedRuleType, selectedRuleTypeModel]); + + if (isLoading || !selectedRuleType) { + return ; + } + + return ( + <> +
+ +

Form State

+
+ {JSON.stringify(formValue, null, 2)} +
+
+ +

Switch Rule Types:

+
+ onRuleTypeChange('.es-query')}>Es Query + onRuleTypeChange('metrics.alert.threshold')}> + Metric Threshold + + onRuleTypeChange('observability.rules.custom_threshold')}> + Custom Threshold + +
+ + + ); +}; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx index 90a44353c646e..caaad858b4cc4 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx @@ -76,6 +76,17 @@ export const Sidebar = () => { }, ], }, + { + name: 'Rule Form Components', + id: 'rule-form-components', + items: [ + { + id: 'rule-definition', + name: 'Rule Definition', + onClick: () => history.push('/rule_definition'), + }, + ], + }, ]} /> diff --git a/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx b/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx index 5077795b620f7..ca932cb81bd6c 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx @@ -8,7 +8,11 @@ import React from 'react'; import { Plugin, CoreSetup, AppMountParameters, CoreStart } from '@kbn/core/public'; import { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; import { get } from 'lodash'; import { @@ -35,6 +39,10 @@ export interface TriggersActionsUiExamplePublicStartDeps { alerting: AlertingSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; + charts: ChartsPluginSetup; + dataViews: DataViewsPublicPluginStart; + dataViewsEditor: DataViewEditorStart; + unifiedSearch: UnifiedSearchPublicPluginStart; } export class TriggersActionsUiExamplePlugin diff --git a/x-pack/examples/triggers_actions_ui_example/tsconfig.json b/x-pack/examples/triggers_actions_ui_example/tsconfig.json index 193feffd2d5ee..0bb226e46c8a9 100644 --- a/x-pack/examples/triggers_actions_ui_example/tsconfig.json +++ b/x-pack/examples/triggers_actions_ui_example/tsconfig.json @@ -26,6 +26,13 @@ "@kbn/i18n", "@kbn/actions-plugin", "@kbn/config-schema", + "@kbn/charts-plugin", + "@kbn/data-views-plugin", + "@kbn/unified-search-plugin", + "@kbn/alerts-ui-shared", + "@kbn/data-view-editor-plugin", + "@kbn/core-http-browser", + "@kbn/core-notifications-browser", "@kbn/react-kibana-context-render", ] } diff --git a/x-pack/plugins/alerting/common/iso_weekdays.ts b/x-pack/plugins/alerting/common/iso_weekdays.ts index 1277370b0e6fc..15b2b5cb3d492 100644 --- a/x-pack/plugins/alerting/common/iso_weekdays.ts +++ b/x-pack/plugins/alerting/common/iso_weekdays.ts @@ -5,5 +5,5 @@ * 2.0. */ -export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7; -export const ISO_WEEKDAYS: IsoWeekday[] = [1, 2, 3, 4, 5, 6, 7]; +export type { IsoWeekday } from '@kbn/alerting-types'; +export { ISO_WEEKDAYS } from '@kbn/alerting-types'; diff --git a/x-pack/plugins/alerting/common/rrule_type.ts b/x-pack/plugins/alerting/common/rrule_type.ts index 7d250a0302317..8b65ec192d316 100644 --- a/x-pack/plugins/alerting/common/rrule_type.ts +++ b/x-pack/plugins/alerting/common/rrule_type.ts @@ -5,14 +5,4 @@ * 2.0. */ -import type { WeekdayStr, Options } from '@kbn/rrule'; - -export type RRuleParams = Partial & Pick; - -// An iCal RRULE to define a recurrence schedule, see https://github.com/jakubroztocil/rrule for the spec -export type RRuleRecord = Omit & { - dtstart: string; - byweekday?: Array; - wkst?: WeekdayStr; - until?: string; -}; +export type { RRuleParams, RRuleRecord } from '@kbn/alerting-types'; diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index bc6c60fd75a53..7d28c5fce8b54 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -10,206 +10,71 @@ import type { SavedObjectAttributes, SavedObjectsResolveResponse, } from '@kbn/core/server'; -import type { Filter } from '@kbn/es-query'; -import { IsoWeekday } from './iso_weekdays'; -import { RuleNotifyWhenType } from './rule_notify_when_type'; -import { RuleSnooze } from './rule_snooze_type'; -export type { ActionVariable } from '@kbn/alerting-types'; +import type { + SanitizedRule, + RuleLastRunOutcomes, + AlertsFilterTimeframe, + RuleAction, + RuleSystemAction, + RuleTypeParams, +} from '@kbn/alerting-types'; + +export type { + ActionVariable, + Rule, + SanitizedRule, + RuleTypeParams, + RuleActionParams, + IntervalSchedule, + RuleActionFrequency, + AlertsFilterTimeframe, + AlertsFilter, + RuleAction, + RuleSystemAction, + MappedParamsProperties, + MappedParams, + RuleExecutionStatuses, + RuleLastRunOutcomes, + RuleExecutionStatus, + RuleMonitoringHistory, + RuleMonitoringCalculatedMetrics, + RuleMonitoringLastRun, + RuleMonitoring, + RuleLastRun, + AlertDelay, + SanitizedAlertsFilter, + SanitizedRuleAction, +} from '@kbn/alerting-types'; + +export { + RuleExecutionStatusValues, + RuleLastRunOutcomeValues, + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '@kbn/alerting-types'; export type RuleTypeState = Record; -export type RuleTypeParams = Record; export type RuleTypeMetaData = Record; // rule type defined alert fields to persist in alerts index export type RuleAlertData = Record; -export interface IntervalSchedule extends SavedObjectAttributes { - interval: string; -} - -// for the `typeof ThingValues[number]` types below, become string types that -// only accept the values in the associated string arrays -export const RuleExecutionStatusValues = [ - 'ok', - 'active', - 'error', - 'pending', - 'unknown', - 'warning', -] as const; -export type RuleExecutionStatuses = typeof RuleExecutionStatusValues[number]; - -export const RuleLastRunOutcomeValues = ['succeeded', 'warning', 'failed'] as const; -export type RuleLastRunOutcomes = typeof RuleLastRunOutcomeValues[number]; - export const RuleLastRunOutcomeOrderMap: Record = { succeeded: 0, warning: 10, failed: 20, }; -export enum RuleExecutionStatusErrorReasons { - Read = 'read', - Decrypt = 'decrypt', - Execute = 'execute', - Unknown = 'unknown', - License = 'license', - Timeout = 'timeout', - Disabled = 'disabled', - Validate = 'validate', -} - -export enum RuleExecutionStatusWarningReasons { - MAX_EXECUTABLE_ACTIONS = 'maxExecutableActions', - MAX_ALERTS = 'maxAlerts', - MAX_QUEUED_ACTIONS = 'maxQueuedActions', -} - export type RuleAlertingOutcome = 'failure' | 'success' | 'unknown' | 'warning'; -export interface RuleExecutionStatus { - status: RuleExecutionStatuses; - lastExecutionDate: Date; - lastDuration?: number; - error?: { - reason: RuleExecutionStatusErrorReasons; - message: string; - }; - warning?: { - reason: RuleExecutionStatusWarningReasons; - message: string; - }; -} - -export type RuleActionParams = SavedObjectAttributes; export type RuleActionParam = SavedObjectAttribute; -export interface RuleActionFrequency extends SavedObjectAttributes { - summary: boolean; - notifyWhen: RuleNotifyWhenType; - throttle: string | null; -} - -export interface AlertsFilterTimeframe extends SavedObjectAttributes { - days: IsoWeekday[]; - timezone: string; - hours: { - start: string; - end: string; - }; -} - -export interface AlertsFilter extends SavedObjectAttributes { - query?: { - kql: string; - filters: Filter[]; - dsl?: string; // This fields is generated in the code by using "kql", therefore it's not optional but defined as optional to avoid modifying a lot of files in different plugins - }; - timeframe?: AlertsFilterTimeframe; -} - export type RuleActionAlertsFilterProperty = AlertsFilterTimeframe | RuleActionParam; -export interface RuleAction { - uuid?: string; - group: string; - id: string; - actionTypeId: string; - params: RuleActionParams; - frequency?: RuleActionFrequency; - alertsFilter?: AlertsFilter; - useAlertDataForTemplate?: boolean; -} - -export interface RuleSystemAction { - uuid?: string; - id: string; - actionTypeId: string; - params: RuleActionParams; -} - export type RuleActionKey = keyof RuleAction; export type RuleSystemActionKey = keyof RuleSystemAction; -export interface RuleLastRun { - outcome: RuleLastRunOutcomes; - outcomeOrder?: number; - warning?: RuleExecutionStatusErrorReasons | RuleExecutionStatusWarningReasons | null; - outcomeMsg?: string[] | null; - alertsCount: { - active?: number | null; - new?: number | null; - recovered?: number | null; - ignored?: number | null; - }; -} - -export interface MappedParamsProperties { - risk_score?: number; - severity?: string; -} - -export type MappedParams = SavedObjectAttributes & MappedParamsProperties; - -export interface AlertDelay extends SavedObjectAttributes { - active: number; -} - -export interface Rule { - id: string; - enabled: boolean; - name: string; - tags: string[]; - alertTypeId: string; // this is persisted in the Rule saved object so we would need a migration to change this to ruleTypeId - consumer: string; - schedule: IntervalSchedule; - actions: RuleAction[]; - systemActions?: RuleSystemAction[]; - params: Params; - mapped_params?: MappedParams; - scheduledTaskId?: string | null; - createdBy: string | null; - updatedBy: string | null; - createdAt: Date; - updatedAt: Date; - apiKey: string | null; - apiKeyOwner: string | null; - apiKeyCreatedByUser?: boolean | null; - throttle?: string | null; - muteAll: boolean; - notifyWhen?: RuleNotifyWhenType | null; - mutedInstanceIds: string[]; - executionStatus: RuleExecutionStatus; - monitoring?: RuleMonitoring; - snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API - activeSnoozes?: string[]; - isSnoozedUntil?: Date | null; - lastRun?: RuleLastRun | null; - nextRun?: Date | null; - revision: number; - running?: boolean | null; - viewInAppRelativeUrl?: string; - alertDelay?: AlertDelay; -} - -export interface SanitizedAlertsFilter extends AlertsFilter { - query?: { - kql: string; - filters: Filter[]; - }; - timeframe?: AlertsFilterTimeframe; -} - -export type SanitizedRuleAction = Omit & { - alertsFilter?: SanitizedAlertsFilter; -}; - -export type SanitizedRule = Omit< - Rule, - 'apiKey' | 'actions' -> & { actions: SanitizedRuleAction[] }; - export type ResolvedSanitizedRule = SanitizedRule & Omit & { outcome: string; @@ -262,20 +127,6 @@ export interface AlertsHealth { }; } -export interface RuleMonitoringHistory extends SavedObjectAttributes { - success: boolean; - timestamp: number; - duration?: number; - outcome?: RuleLastRunOutcomes; -} - -export interface RuleMonitoringCalculatedMetrics extends SavedObjectAttributes { - p50?: number; - p95?: number; - p99?: number; - success_ratio: number; -} - export interface RuleMonitoringLastRunMetrics extends SavedObjectAttributes { duration?: number; total_search_duration_ms?: number | null; @@ -284,16 +135,3 @@ export interface RuleMonitoringLastRunMetrics extends SavedObjectAttributes { total_alerts_created?: number | null; gap_duration_s?: number | null; } - -export interface RuleMonitoringLastRun extends SavedObjectAttributes { - timestamp: string; - metrics: RuleMonitoringLastRunMetrics; -} - -export interface RuleMonitoring { - run: { - history: RuleMonitoringHistory[]; - calculated_metrics: RuleMonitoringCalculatedMetrics; - last_run: RuleMonitoringLastRun; - }; -} diff --git a/x-pack/plugins/alerting/common/rule_notify_when_type.ts b/x-pack/plugins/alerting/common/rule_notify_when_type.ts index 76182636e9f71..272e458ade9d9 100644 --- a/x-pack/plugins/alerting/common/rule_notify_when_type.ts +++ b/x-pack/plugins/alerting/common/rule_notify_when_type.ts @@ -5,18 +5,8 @@ * 2.0. */ -export const RuleNotifyWhenTypeValues = [ - 'onActionGroupChange', - 'onActiveAlert', - 'onThrottleInterval', -] as const; -export type RuleNotifyWhenType = typeof RuleNotifyWhenTypeValues[number]; - -export enum RuleNotifyWhen { - CHANGE = 'onActionGroupChange', - ACTIVE = 'onActiveAlert', - THROTTLE = 'onThrottleInterval', -} +import type { RuleNotifyWhenType } from '@kbn/alerting-types'; +import { RuleNotifyWhenTypeValues } from '@kbn/alerting-types'; export function validateNotifyWhenType(notifyWhen: string) { if (RuleNotifyWhenTypeValues.includes(notifyWhen as RuleNotifyWhenType)) { @@ -24,3 +14,6 @@ export function validateNotifyWhenType(notifyWhen: string) { } return `string is not a valid RuleNotifyWhenType: ${notifyWhen}`; } + +export type { RuleNotifyWhenType } from '@kbn/alerting-types'; +export { RuleNotifyWhenTypeValues, RuleNotifyWhen } from '@kbn/alerting-types'; diff --git a/x-pack/plugins/alerting/common/rule_snooze_type.ts b/x-pack/plugins/alerting/common/rule_snooze_type.ts index 17e4d0eabb02a..b3b8b8b030a61 100644 --- a/x-pack/plugins/alerting/common/rule_snooze_type.ts +++ b/x-pack/plugins/alerting/common/rule_snooze_type.ts @@ -5,21 +5,4 @@ * 2.0. */ -import { RRuleParams } from './rrule_type'; - -export interface RuleSnoozeSchedule { - duration: number; - rRule: RRuleParams; - // For scheduled/recurring snoozes, `id` uniquely identifies them so that they can be displayed, modified, and deleted individually - id?: string; - skipRecurrences?: string[]; -} - -// Type signature of has to be repeated here to avoid issues with SavedObject compatibility -// RuleSnooze = RuleSnoozeSchedule[] throws typescript errors across the whole lib -export type RuleSnooze = Array<{ - duration: number; - rRule: RRuleParams; - id?: string; - skipRecurrences?: string[]; -}>; +export type { RuleSnoozeSchedule, RuleSnooze } from '@kbn/alerting-types';