diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx b/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx index e8303a8bf..417a58859 100644 --- a/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx +++ b/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx @@ -14,27 +14,28 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiHorizontalRule, - EuiPanel, EuiSpacer, EuiText, - EuiTitle, + EuiTextArea, } from '@elastic/eui'; import { Detector } from '../../../../../../../models/interfaces'; import { AlertCondition } from '../../../../../../../models/interfaces'; -import { createSelectedOptions, parseAlertSeverityListToOptions } from '../../utils/helpers'; -import { ALERT_SEVERITY_OPTIONS, RULE_SEVERITY_OPTIONS } from '../../utils/constants'; -import { parseStringsToOptions } from '../../../../../../utils/helpers'; +import { createSelectedOptions, parseAlertSeverityToOption } from '../../utils/helpers'; +import { ALERT_SEVERITY_OPTIONS, EMPTY_DEFAULT_ALERT_CONDITION } from '../../utils/constants'; +import { CreateDetectorRulesOptions } from '../../../../../../models/types'; +import { NotificationChannelOption, NotificationChannelTypeOptions } from '../../models/interfaces'; +import { NOTIFICATIONS_HREF } from '../../../../../../utils/constants'; interface AlertConditionPanelProps extends RouteComponentProps { alertCondition: AlertCondition; - allNotificationChannels: string[]; // TODO: Notification channels will likely be more complex objects - allRuleTypes: string[]; + allNotificationChannels: NotificationChannelTypeOptions[]; + rulesOptions: CreateDetectorRulesOptions; detector: Detector; indexNum: number; isEdit: boolean; loadingNotifications: boolean; onAlertTriggerChanged: (newDetector: Detector) => void; + refreshNotificationChannels: () => void; } interface AlertConditionPanelState {} @@ -50,6 +51,20 @@ export default class AlertConditionPanel extends Component< }; } + componentDidMount() { + this.prepareMessage(); + } + + prepareMessage = async () => { + const { alertCondition, detector, indexNum } = this.props; + if (!alertCondition.actions[0]?.subject_template.source) + await this.onMessageSubjectChange(`${detector.name} alert condition number ${indexNum + 1}.`); + if (!alertCondition.actions[0]?.message_template.source) + await this.onMessageBodyChange( + `Alert condition number ${indexNum + 1} for detector "${detector.name}" has been triggered.` + ); + }; + updateTrigger(trigger: Partial) { const { alertCondition, @@ -77,8 +92,10 @@ export default class AlertConditionPanel extends Component< }; onAlertSeverityChange = (selectedOptions: EuiComboBoxOptionOption[]) => { - const severitySelections = selectedOptions.map((option) => option.label); - this.updateTrigger({ actions: severitySelections }); + const severitySelections = selectedOptions.map((option) => option.value); + if (severitySelections.length > 0) { + this.updateTrigger({ severity: severitySelections[0] }); + } }; onCreateTag = (value: string) => { @@ -96,7 +113,6 @@ export default class AlertConditionPanel extends Component< }; onNotificationChannelsChange = (selectedOptions: EuiComboBoxOptionOption[]) => { - // const channelIds = selectedOptions.map((channel) => channel.label); const { alertCondition, onAlertTriggerChanged, @@ -104,12 +120,34 @@ export default class AlertConditionPanel extends Component< detector: { triggers }, indexNum, } = this.props; + + const actions = alertCondition.actions; + actions[0].destination_id = selectedOptions.length > 0 ? selectedOptions[0].value! : ''; + triggers.splice(indexNum, 1, { ...alertCondition, + actions: actions, }); onAlertTriggerChanged({ ...detector, triggers: triggers }); }; + onMessageSubjectChange = (subject: string) => { + const { + alertCondition: { actions }, + } = this.props; + actions[0].name = subject; + actions[0].subject_template.source = subject; + this.updateTrigger({ actions: actions }); + }; + + onMessageBodyChange = (message: string) => { + const { + alertCondition: { actions }, + } = this.props; + actions[0].message_template.source = message; + this.updateTrigger({ actions: actions }); + }; + onDelete = () => { const { onAlertTriggerChanged, @@ -122,151 +160,237 @@ export default class AlertConditionPanel extends Component< onAlertTriggerChanged({ ...detector, triggers: newTriggers }); }; + onRuleNamesChange = (selectedOptions: EuiComboBoxOptionOption[]) => { + const ids = selectedOptions.map((nameOption) => nameOption.value as string); + this.updateTrigger({ ids }); + }; + render() { - const { alertCondition, allNotificationChannels, indexNum, loadingNotifications } = this.props; - const { name, sev_levels: ruleSeverityLevels, tags } = alertCondition; - const alertSeverityLevels: string[] = []; + const { + alertCondition = EMPTY_DEFAULT_ALERT_CONDITION, + allNotificationChannels, + indexNum, + loadingNotifications, + refreshNotificationChannels, + rulesOptions, + } = this.props; + const { name, sev_levels: ruleSeverityLevels, tags, severity, ids } = alertCondition; + const uniqueTagsOptions = new Set( + rulesOptions.map((option) => option.tags).reduce((prev, current) => prev.concat(current), []) + ); + const tagsOptions: { label: string }[] = []; + uniqueTagsOptions.forEach((tag) => { + tagsOptions.push({ + label: tag, + }); + }); + + const uniqueRuleSeverityOptions = new Set(rulesOptions.map((option) => option.severity)); + const ruleSeverityOptions: { label: string }[] = []; + uniqueRuleSeverityOptions.forEach((severity) => { + ruleSeverityOptions.push({ + label: severity, + }); + }); + const namesOptions: EuiComboBoxOptionOption[] = rulesOptions.map((option) => ({ + label: option.name, + value: option.id, + })); + const selectedNames: EuiComboBoxOptionOption[] = []; + ids.forEach((ruleId) => { + const option = rulesOptions.find((option) => option.id === ruleId); + if (option) { + selectedNames.push({ label: option.name, value: option.id }); + } + }); + + const channelId = alertCondition.actions[0].destination_id; + const selectedNotificationChannelOption: NotificationChannelOption[] = []; + if (channelId) { + allNotificationChannels.forEach((typeOption) => { + const matchingChannel = typeOption.options.find((option) => option.value === channelId); + if (matchingChannel) selectedNotificationChannelOption.push(matchingChannel); + }); + } + return ( - - -

Alert trigger

- +
+ +

Trigger name

+ } - paddingSize={'none'} - initialIsOpen={true} - extraAction={ - indexNum > 0 && Remove alert condition + > + +
+ + + +

If a detection rule matches

+
+ + + +

Rule names

+ } > - - + +
+ - -

Trigger name

- - } - > - -
+ +

Rule Severities

+ + } + > + +
+ - - -

If a detection rule matches

-
- + +

Tags

+ + } + > + +
- -

Rule Severities

- + + + +

Alert and notify

+
+ + + +

Specify alert severity

+ + } + > + - -
- + onChange={this.onAlertSeverityChange} + singleSelection={{ asPlainText: true }} + isClearable={false} + /> +
+ + + + + + +

Select channel to notify

+ + } + > + []} + selectedOptions={ + selectedNotificationChannelOption as EuiComboBoxOptionOption[] + } + onChange={this.onNotificationChannelsChange} + singleSelection={{ asPlainText: true }} + onBlur={refreshNotificationChannels} + /> +
+
+ + + Manage channels + + +
+ + + +

Show notify message

+ + } + paddingSize={'none'} + initialIsOpen={false} + > + -

Tags

+ +

Message subject

} > - this.onMessageSubjectChange(e.target.value)} + required={true} />
- - - - -

Alert and notify

-
- -

Specify alert severity

+

Message body

} > - this.onMessageBodyChange(e.target.value)} + required={true} />
- - - - - - -

Select channels to notify

- - } - > - -
-
- - Manage channels - -
- - - - -

Show notify message

- - } - paddingSize={'none'} - initialIsOpen={false} - > -

Notification message

- -
- -
- + + +
); } } diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx b/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx index d074c994f..d86586f8d 100644 --- a/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx +++ b/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx @@ -5,28 +5,36 @@ import React, { Component } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiButton, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { createDetectorSteps } from '../../../utils/constants'; import { - EMPTY_DEFAULT_ALERT_CONDITION, - MAX_ALERT_CONDITIONS, - MIN_ALERT_CONDITIONS, -} from '../utils/constants'; + EuiAccordion, + EuiButton, + EuiHorizontalRule, + EuiPanel, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { createDetectorSteps } from '../../../utils/constants'; +import { EMPTY_DEFAULT_ALERT_CONDITION, MAX_ALERT_CONDITIONS } from '../utils/constants'; import AlertConditionPanel from '../components/AlertCondition'; import { Detector } from '../../../../../../models/interfaces'; import { DetectorCreationStep } from '../../../models/types'; +import { CreateDetectorRulesOptions } from '../../../../../models/types'; +import { NotificationChannelTypeOptions } from '../models/interfaces'; +import { getNotificationChannels, parseNotificationChannelsToOptions } from '../utils/helpers'; +import { NotificationsService } from '../../../../../services'; interface ConfigureAlertsProps extends RouteComponentProps { detector: Detector; isEdit: boolean; + rulesOptions: CreateDetectorRulesOptions; changeDetector: (detector: Detector) => void; updateDataValidState: (step: DetectorCreationStep, isValid: boolean) => void; + notificationsService: NotificationsService; } interface ConfigureAlertsState { loading: boolean; - notificationChannels: string[]; - ruleTypes: string[]; + notificationChannels: NotificationChannelTypeOptions[]; } export default class ConfigureAlerts extends Component { @@ -35,7 +43,6 @@ export default class ConfigureAlerts extends Component { this.setState({ loading: true }); - // TODO: fetch notification channels from server. + const channels = await getNotificationChannels(this.props.notificationsService); + this.setState({ notificationChannels: parseNotificationChannelsToOptions(channels) }); this.setState({ loading: false }); }; @@ -67,23 +75,32 @@ export default class ConfigureAlerts extends Component { const isTriggerDataValid = newDetector.triggers.every((trigger) => { - return !!trigger.name && trigger.sev_levels.length > 0; + return !!trigger.name && trigger.severity; }); this.props.changeDetector(newDetector); this.props.updateDataValidState(DetectorCreationStep.CONFIGURE_ALERTS, isTriggerDataValid); }; + onDelete = (index: number) => { + const { + detector, + detector: { triggers }, + } = this.props; + triggers.splice(index, 1); + this.onAlertTriggerChanged({ ...detector, triggers: triggers }); + }; + render() { const { detector: { triggers }, } = this.props; - const { loading, notificationChannels, ruleTypes } = this.state; + const { loading, notificationChannels } = this.state; return (

{createDetectorSteps[DetectorCreationStep.CONFIGURE_ALERTS].title + - ` (${triggers.length}/${MAX_ALERT_CONDITIONS})`} + ` (${triggers.length})`}

@@ -92,22 +109,40 @@ export default class ConfigureAlerts extends Component (
{index > 0 && } - + + +

Alert trigger

+ + } + paddingSize={'none'} + initialIsOpen={true} + extraAction={ + this.onDelete(index)}>Remove alert trigger + } + > + + + +
+
))} = MAX_ALERT_CONDITIONS} onClick={this.addCondition}> - Add another alert condition + {`Add ${triggers.length > 0 ? 'another' : 'an'} alert condition`}
); diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/models/interfaces.ts b/public/pages/CreateDetector/components/ConfigureAlerts/models/interfaces.ts new file mode 100644 index 000000000..fda16e043 --- /dev/null +++ b/public/pages/CreateDetector/components/ConfigureAlerts/models/interfaces.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface NotificationChannelTypeOptions { + label: string; + options: NotificationChannelOption[]; +} + +export interface NotificationChannelOption { + label: string; + value: string; + type: string; + description: string; +} diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/utils/constants.ts b/public/pages/CreateDetector/components/ConfigureAlerts/utils/constants.ts index 58de28d2a..945a34c3c 100644 --- a/public/pages/CreateDetector/components/ConfigureAlerts/utils/constants.ts +++ b/public/pages/CreateDetector/components/ConfigureAlerts/utils/constants.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AlertCondition } from '../../../../../../models/interfaces'; +import { AlertCondition, TriggerAction } from '../../../../../../models/interfaces'; export const MAX_ALERT_CONDITIONS = 10; -export const MIN_ALERT_CONDITIONS = 1; +export const MIN_ALERT_CONDITIONS = 0; // SEVERITY_OPTIONS have the id, value, label, and text fields because some EUI components // (e.g, EuiComboBox) require value/label pairings, while others @@ -27,12 +27,33 @@ export const RULE_SEVERITY_OPTIONS = { INFORMATIONAL: { id: '5', value: 'informational', label: 'Info', text: 'Info' }, }; +export const EMPTY_DEFAULT_TRIGGER_ACTION: TriggerAction = { + id: '', + name: '', + destination_id: '', + subject_template: { + source: '', + lang: 'mustache', + }, + message_template: { + source: '', + lang: 'mustache', + }, + throttle_enabled: false, + throttle: { + value: 10, + unit: 'MINUTES', + }, +}; + export const EMPTY_DEFAULT_ALERT_CONDITION: AlertCondition = { name: '', sev_levels: [], tags: [], - actions: [], + actions: [EMPTY_DEFAULT_TRIGGER_ACTION], types: [], + severity: '1', + ids: [], }; export const MIN_NUM_NOTIFICATION_CHANNELS = 1; @@ -44,3 +65,5 @@ export const MAX_NUM_RULES = 5; // Only allows letters. No spaces, numbers, or special characters. export const MIN_NUM_TAGS = 0; export const MAX_NUM_TAGS = 5; + +export const CHANNEL_TYPES = ['slack', 'email', 'chime', 'webhook', 'ses', 'sns']; diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/utils/helpers.ts b/public/pages/CreateDetector/components/ConfigureAlerts/utils/helpers.ts index d01e2aa8a..fa8f37dcf 100644 --- a/public/pages/CreateDetector/components/ConfigureAlerts/utils/helpers.ts +++ b/public/pages/CreateDetector/components/ConfigureAlerts/utils/helpers.ts @@ -4,20 +4,47 @@ */ import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { ALERT_SEVERITY_OPTIONS } from './constants'; +import { ALERT_SEVERITY_OPTIONS, CHANNEL_TYPES } from './constants'; +import { FeatureChannelList } from '../../../../../../server/models/interfaces/Notifications'; +import { NotificationChannelTypeOptions } from '../models/interfaces'; +import { NotificationsService } from '../../../../../services'; export const parseAlertSeverityToOption = (severity: string): EuiComboBoxOptionOption => { return Object.values(ALERT_SEVERITY_OPTIONS).find( - (option) => option.label === severity + (option) => option.value === severity ) as EuiComboBoxOptionOption; }; -export const parseAlertSeverityListToOptions = ( - severityList: string[] -): EuiComboBoxOptionOption[] => { - return severityList.map((severity) => parseAlertSeverityToOption(severity)); +export function createSelectedOptions(optionNames: string[]): EuiComboBoxOptionOption[] { + return optionNames.map((optionName) => ({ id: optionName, label: optionName })); +} + +export const getNotificationChannels = async (notificationsService: NotificationsService) => { + try { + const response = await notificationsService.getChannels(); + if (response.ok) { + return response.response.channel_list; + } else { + console.error('Failed to retrieve notification channels:', response.error); + } + } catch (e) { + console.error('Failed to retrieve notification channels:', e); + } + return []; }; -export function createSelectedOptions(optionNames: string[]): EuiComboBoxOptionOption[] { - return optionNames.map((optionName) => ({ label: optionName })); +export function parseNotificationChannelsToOptions( + notificationChannels: FeatureChannelList[], + supportedTypes = CHANNEL_TYPES +): NotificationChannelTypeOptions[] { + const allOptions = notificationChannels.map((channel) => ({ + label: `[Channel] ${channel.name}`, + value: channel.config_id, + type: channel.config_type, + description: channel.description, + })); + return supportedTypes.map((type) => ({ + label: type, + options: allOptions.filter((channel) => channel.type === type), + })); } diff --git a/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldMappingsTable.tsx b/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldMappingsTable.tsx index be78dd587..9c445c5d7 100644 --- a/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldMappingsTable.tsx +++ b/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldMappingsTable.tsx @@ -14,9 +14,9 @@ import { } from '@elastic/eui'; import { DEFAULT_EMPTY_DATA } from '../../../../../../utils/constants'; import { STATUS_ICON_PROPS } from '../../utils/constants'; -import SIEMFieldNameSelector from './SIEMFieldName'; +import FieldNameSelector from './FieldNameSelector'; import { FieldMappingsTableItem } from '../../../../models/interfaces'; -import { IndexFieldToAliasMap } from '../../containers/ConfigureFieldMapping'; +import { ruleFieldToIndexFieldMap } from '../../containers/ConfigureFieldMapping'; export enum MappingViewType { Readonly, @@ -29,7 +29,7 @@ export interface MappingProps { }; [MappingViewType.Edit]: { type: MappingViewType.Edit; - createdMappings: IndexFieldToAliasMap; + existingMappings: ruleFieldToIndexFieldMap; invalidMappingFieldNames: string[]; onMappingCreation: (fieldName: string, aliasName: string) => void; }; @@ -38,7 +38,7 @@ export interface MappingProps { interface FieldMappingsTableProps extends RouteComponentProps { loading: boolean; indexFields: string[]; - aliasNames: string[]; + ruleFields: string[]; mappingProps: MappingProps[T]; } @@ -49,31 +49,31 @@ export default class FieldMappingsTable extends Compo FieldMappingsTableState > { render() { - const { loading, indexFields, aliasNames } = this.props; + const { loading, indexFields, ruleFields } = this.props; let items: FieldMappingsTableItem[]; if (this.props.mappingProps.type === MappingViewType.Edit) { - items = indexFields.map((indexField) => ({ - logFieldName: indexField, - siemFieldName: undefined, + items = ruleFields.map((ruleField) => ({ + ruleFieldName: ruleField, + logFieldName: undefined, })); } else { - items = indexFields.map((indexField, idx) => { + items = ruleFields.map((ruleField, idx) => { return { - logFieldName: indexField, - siemFieldName: aliasNames[idx], + logFieldName: indexFields[idx], + ruleFieldName: ruleField, }; }); } const columns: EuiBasicTableColumn[] = [ { - field: 'logFieldName', - name: 'Log field name', + field: 'ruleFieldName', + name: 'Rule field name', sortable: true, dataType: 'string', width: '25%', - render: (log_field_name: string) => log_field_name || DEFAULT_EMPTY_DATA, + render: (ruleFieldName: string) => ruleFieldName || DEFAULT_EMPTY_DATA, }, { field: '', @@ -83,23 +83,23 @@ export default class FieldMappingsTable extends Compo render: () => , }, { - field: 'siemFieldName', - name: 'SIEM field name', + field: 'logFieldName', + name: 'Log field name', sortable: true, dataType: 'string', width: '45%', - render: (siemFieldName: string, entry: FieldMappingsTableItem) => { + render: (logFieldName: string, entry: FieldMappingsTableItem) => { if (this.props.mappingProps.type === MappingViewType.Edit) { - const { onMappingCreation, invalidMappingFieldNames, createdMappings } = this.props + const { onMappingCreation, invalidMappingFieldNames, existingMappings } = this.props .mappingProps as MappingProps[MappingViewType.Edit]; - const onMappingSelected = (selectedAlias: string) => { - onMappingCreation(entry.logFieldName, selectedAlias); + const onMappingSelected = (selectedField: string) => { + onMappingCreation(entry.ruleFieldName, selectedField); }; return ( - ); @@ -107,7 +107,7 @@ export default class FieldMappingsTable extends Compo return ( - {siemFieldName} + {logFieldName} ); }, @@ -123,12 +123,12 @@ export default class FieldMappingsTable extends Compo align: 'center', width: '15%', render: (_status: 'mapped' | 'unmapped', entry: FieldMappingsTableItem) => { - const { createdMappings, invalidMappingFieldNames } = this.props + const { existingMappings: createdMappings, invalidMappingFieldNames } = this.props .mappingProps as MappingProps[MappingViewType.Edit]; let iconProps = STATUS_ICON_PROPS['unmapped']; if ( - createdMappings[entry.logFieldName] && - !invalidMappingFieldNames.includes(entry.logFieldName) + createdMappings[entry.ruleFieldName] && + !invalidMappingFieldNames.includes(entry.ruleFieldName) ) { iconProps = STATUS_ICON_PROPS['mapped']; } @@ -140,7 +140,7 @@ export default class FieldMappingsTable extends Compo const sorting: { sort: { field: string; direction: 'asc' | 'desc' } } = { sort: { - field: 'logFieldName', + field: 'ruleFieldName', direction: 'asc', }, }; diff --git a/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx b/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx new file mode 100644 index 000000000..caf834346 --- /dev/null +++ b/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { ChangeEvent } from 'react'; + +interface SIEMFieldNameProps { + fieldNameOptions: string[]; + isInvalid: boolean; + selectedField: string; + onChange: (option: string) => void; +} + +interface SIEMFieldNameState { + selectedOption?: string; + errorMessage?: string; +} + +export default class FieldNameSelector extends Component { + constructor(props: SIEMFieldNameProps) { + super(props); + this.state = { + selectedOption: props.selectedField, + }; + } + + onChange: React.ChangeEventHandler = ( + event: ChangeEvent + ) => { + this.setState({ selectedOption: event.target.value }); + this.props.onChange(event.target.value); + }; + + render() { + const { isInvalid } = this.props; + return ( + + ({ + value: option, + text: option, + }))} + value={this.state.selectedOption} + onChange={this.onChange} + /> + + ); + } +} diff --git a/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx b/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx index 5704962b7..40910e0b7 100644 --- a/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx +++ b/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx @@ -5,18 +5,18 @@ import React, { Component } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiAccordion, EuiHorizontalRule, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import FieldMappingsTable from '../components/RequiredFieldMapping'; import { createDetectorSteps } from '../../../utils/constants'; import { ContentPanel } from '../../../../../components/ContentPanel'; import { Detector, FieldMapping } from '../../../../../../models/interfaces'; -import { EMPTY_FIELD_MAPPINGS, EXAMPLE_FIELD_MAPPINGS_RESPONSE } from '../utils/dummyData'; +import { EMPTY_FIELD_MAPPINGS } from '../utils/constants'; import { DetectorCreationStep } from '../../../models/types'; import { GetFieldMappingViewResponse } from '../../../../../../server/models/interfaces'; import FieldMappingService from '../../../../../services/FieldMappingService'; import { MappingViewType } from '../components/RequiredFieldMapping/FieldMappingsTable'; -export interface IndexFieldToAliasMap { +export interface ruleFieldToIndexFieldMap { [fieldName: string]: string; } @@ -32,7 +32,7 @@ interface ConfigureFieldMappingProps extends RouteComponentProps { interface ConfigureFieldMappingState { loading: boolean; mappingsData: GetFieldMappingViewResponse; - createdMappings: IndexFieldToAliasMap; + createdMappings: ruleFieldToIndexFieldMap; invalidMappingFieldNames: string[]; } @@ -42,9 +42,9 @@ export default class ConfigureFieldMapping extends Component< > { constructor(props: ConfigureFieldMappingProps) { super(props); - const createdMappings: IndexFieldToAliasMap = {}; + const createdMappings: ruleFieldToIndexFieldMap = {}; props.fieldMappings.forEach((mapping) => { - createdMappings[mapping.fieldName] = mapping.aliasName; + createdMappings[mapping.ruleFieldName] = mapping.indexFieldName; }); this.state = { loading: false, @@ -61,29 +61,29 @@ export default class ConfigureFieldMapping extends Component< getAllMappings = async () => { this.setState({ loading: true }); const mappingsView = await this.props.filedMappingService.getMappingsView( - this.props.detector.inputs[0].input.indices[0], + this.props.detector.inputs[0].detector_input.indices[0], this.props.detector.detector_type ); if (mappingsView.ok) { - this.setState({ mappingsData: mappingsView.response }); + const existingMappings = { ...this.state.createdMappings }; + Object.keys(mappingsView.response.properties).forEach((ruleFieldName) => { + existingMappings[ruleFieldName] = mappingsView.response.properties[ruleFieldName].path; + }); + this.setState({ createdMappings: existingMappings, mappingsData: mappingsView.response }); + this.updateMappingSharedState(existingMappings); } this.setState({ loading: false }); }; - validateMappings(mappings: IndexFieldToAliasMap): boolean { - const allFieldsMapped = this.state.mappingsData.unmappedIndexFields.every( - (fieldName) => !!mappings[fieldName] - ); - const mappedAliases = Object.values(mappings); - const allAliasesUnique = mappedAliases.length === new Set(mappedAliases).size; - - return allFieldsMapped && allAliasesUnique; + validateMappings(mappings: ruleFieldToIndexFieldMap): boolean { + // TODO: Implement validation + return true; //allFieldsMapped; // && allAliasesUnique; } /** * Returns the fieldName(s) that have duplicate alias assigned to them */ - getInvalidMappingFieldNames(mappings: IndexFieldToAliasMap): string[] { + getInvalidMappingFieldNames(mappings: ruleFieldToIndexFieldMap): string[] { const seenAliases = new Set(); const invalidFields: string[] = []; @@ -95,65 +95,71 @@ export default class ConfigureFieldMapping extends Component< seenAliases.add(entry[1]); }); - return invalidFields; + return []; //invalidFields; } - onMappingCreation = (fieldName: string, aliasName: string): void => { - const newMappings: IndexFieldToAliasMap = { + onMappingCreation = (ruleFieldName: string, indxFieldName: string): void => { + const newMappings: ruleFieldToIndexFieldMap = { ...this.state.createdMappings, - [fieldName]: aliasName, + [ruleFieldName]: indxFieldName, }; const invalidMappingFieldNames = this.getInvalidMappingFieldNames(newMappings); this.setState({ createdMappings: newMappings, invalidMappingFieldNames: invalidMappingFieldNames, }); + this.updateMappingSharedState(newMappings); + const mappingsValid = this.validateMappings(newMappings); + this.props.updateDataValidState(DetectorCreationStep.CONFIGURE_FIELD_MAPPING, mappingsValid); + }; + updateMappingSharedState = (createdMappings: ruleFieldToIndexFieldMap) => { this.props.replaceFieldMappings( - Object.entries(newMappings).map((entry) => { + Object.entries(createdMappings).map((entry) => { return { - fieldName: entry[0], - aliasName: entry[1], + ruleFieldName: entry[0], + indexFieldName: entry[1], }; }) ); - const mappingsValid = this.validateMappings(newMappings); - this.props.updateDataValidState(DetectorCreationStep.CONFIGURE_FIELD_MAPPING, mappingsValid); }; render() { + const { isEdit } = this.props; const { loading, mappingsData, createdMappings, invalidMappingFieldNames } = this.state; - const viewonlyMappings: { indexFields: string[]; aliasNames: string[] } = { - indexFields: [], - aliasNames: [], + const existingMappings: ruleFieldToIndexFieldMap = { + ...createdMappings, }; + const ruleFields = [...(mappingsData.unmapped_field_aliases || [])]; + const indexFields = [...(mappingsData.unmapped_index_fields || [])]; - Object.keys(mappingsData.properties).forEach((aliasName) => { - viewonlyMappings.aliasNames.push(aliasName); - viewonlyMappings.indexFields.push(mappingsData.properties[aliasName].path); + Object.keys(mappingsData.properties).forEach((ruleFieldName) => { + existingMappings[ruleFieldName] = mappingsData.properties[ruleFieldName].path; + ruleFields.unshift(ruleFieldName); + indexFields.unshift(mappingsData.properties[ruleFieldName].path); }); return (
- -

{createDetectorSteps[DetectorCreationStep.CONFIGURE_FIELD_MAPPING].title}

-
- - + {!isEdit && ( +
+ +

{createDetectorSteps[DetectorCreationStep.CONFIGURE_FIELD_MAPPING].title}

+
+ +
+ )} - {mappingsData.unmappedIndexFields.length > 0 && ( + {ruleFields.length > 0 && ( <> - + loading={loading} - aliasNames={mappingsData.unmappedFieldAliases} - indexFields={mappingsData.unmappedIndexFields} + ruleFields={ruleFields} + indexFields={indexFields} mappingProps={{ type: MappingViewType.Edit, - createdMappings, + existingMappings, invalidMappingFieldNames, onMappingCreation: this.onMappingCreation, }} @@ -163,36 +169,6 @@ export default class ConfigureFieldMapping extends Component< )} - - - -

{`View all field mappings (${ - Object.keys(mappingsData.properties).length - })`}

- - } - buttonProps={{ style: { paddingLeft: '10px', paddingRight: '10px' } }} - id={'allFieldMappingsAccordion'} - initialIsOpen={true} - isLoading={loading} - > - -
- - - loading={loading} - mappingProps={{ - type: MappingViewType.Readonly, - }} - aliasNames={viewonlyMappings.aliasNames} - indexFields={viewonlyMappings.indexFields} - {...this.props} - /> -
-
-
); } diff --git a/public/pages/CreateDetector/components/ConfigureFieldMapping/utils/constants.ts b/public/pages/CreateDetector/components/ConfigureFieldMapping/utils/constants.ts index c4dce98d8..e1245b54e 100644 --- a/public/pages/CreateDetector/components/ConfigureFieldMapping/utils/constants.ts +++ b/public/pages/CreateDetector/components/ConfigureFieldMapping/utils/constants.ts @@ -3,7 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { GetFieldMappingViewResponse } from '../../../../../../server/models/interfaces'; + export const STATUS_ICON_PROPS = { unmapped: { type: 'alert', color: 'danger' }, mapped: { type: 'checkInCircleFilled', color: 'success' }, }; + +export const EMPTY_FIELD_MAPPINGS: GetFieldMappingViewResponse = { + properties: {}, + unmapped_field_aliases: [], + unmapped_index_fields: [], +}; diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx index df65bbb58..aa77c8829 100644 --- a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx @@ -3,191 +3,83 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Component } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { ContentPanel } from '../../../../../../components/ContentPanel'; import { - EuiAccordion, - EuiBasicTable, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, EuiPanel, - EuiSpacer, - EuiText, + EuiAccordion, EuiTitle, + EuiHorizontalRule, + CriteriaWithPagination, } from '@elastic/eui'; -import { Rule } from '../../../../../../../models/interfaces'; -import { getRulesColumns } from './utils/constants'; -import { RuleItem, RulesInfoByType } from './types/interfaces'; -import { dummyDetectorRules } from './utils/dummyData'; - -interface DetectionRulesProps extends RouteComponentProps { - enabledCustomRuleIds: string[]; - detectorType: string; - onRulesChanged: (rules: Rule[]) => void; +import React, { useMemo } from 'react'; +import { DetectionRulesTable } from './DetectionRulesTable'; +import { RuleItem, RuleItemInfo } from './types/interfaces'; + +export interface CreateDetectorRulesState { + allRules: RuleItemInfo[]; + page: { + index: number; + }; } -interface DetectionRulesState { - fieldTouched: boolean; - selectedRuleType?: string; - rulesByRuleType: RulesInfoByType; +export interface DetectionRulesProps { + rulesState: CreateDetectorRulesState; + onRuleToggle: (changedItem: RuleItem, isActive: boolean) => void; + onAllRulesToggle: (enabled: boolean) => void; + onPageChange: (page: { index: number; size: number }) => void; } -export default class DetectionRules extends Component { - constructor(props: DetectionRulesProps) { - super(props); - this.state = this.deriveInitialState(); - } - - componentDidMount(): void { - // get pre-packaged rules based on detector type - // get custom rules based on detector type - // merge the rule types and add the toggle state - } - - deriveInitialState(): DetectionRulesState { - const detectorRules = dummyDetectorRules; - const rulesByRuleType: { - [ruleType: string]: { ruleItems: RuleItem[]; activeCount: number }; - } = {}; - detectorRules.forEach((rule) => { - rulesByRuleType[rule.type] = rulesByRuleType[rule.type] || { ruleItems: [], activeCount: 0 }; - rulesByRuleType[rule.type].ruleItems.push({ - ruleName: rule.name, - ruleType: rule.type, - description: rule.description || '', - active: rule.active, - }); - - if (rule.active) { - rulesByRuleType[rule.type].activeCount++; - } - }); - - return { - fieldTouched: false, - selectedRuleType: undefined, - rulesByRuleType, - }; - } - - getActiveRulesCount(selectedRuleType?: string): number { - if (selectedRuleType) { - return this.state.rulesByRuleType[selectedRuleType]?.activeCount || 0; +export const DetectionRules: React.FC = ({ + rulesState, + onPageChange, + onRuleToggle, + onAllRulesToggle, +}) => { + let enabledRulesCount = 0; + rulesState.allRules.forEach((ruleItem) => { + if (ruleItem.enabled) { + enabledRulesCount++; } - - return Object.values(this.state.rulesByRuleType).reduce((aggregate, rulesInfo) => { - return aggregate + rulesInfo.activeCount; - }, 0); - } - - getRuleItems(selectedRuleType?: string): RuleItem[] { - if (selectedRuleType) { - return this.state.rulesByRuleType[selectedRuleType].ruleItems; - } - - return Object.values(this.state.rulesByRuleType).reduce( - (aggregate: RuleItem[], currentRulesInfo) => { - return aggregate.concat(currentRulesInfo.ruleItems); - }, - [] - ); - } - - onRuleTypeClick = (selectedRuleType?: string) => { - this.setState({ - selectedRuleType, - }); + }); + + const ruleItems: RuleItem[] = useMemo( + () => + rulesState.allRules.map((rule) => ({ + id: rule._id, + active: rule.enabled, + description: rule._source.description, + library: rule.prePackaged ? 'Sigma' : 'Custom', + logType: rule._source.category, + name: rule._source.title, + severity: rule._source.level, + })), + [rulesState.allRules] + ); + + const onTableChange = (nextValues: CriteriaWithPagination) => { + onPageChange(nextValues.page); }; - onRuleActivationToggle = (changedItem: RuleItem, changeToActive: boolean) => { - const { rulesByRuleType } = this.state; - const ruleItems = rulesByRuleType[changedItem.ruleType].ruleItems; - const changedIdx = ruleItems.findIndex((item) => item.ruleName === changedItem.ruleName); - - if (changedIdx > -1) { - const newRuleItems = [ - ...ruleItems.slice(0, changedIdx), - { ...ruleItems[changedIdx], active: changeToActive }, - ...ruleItems.slice(changedIdx + 1), - ]; - const newRulesByRuleType: RulesInfoByType = { - ...rulesByRuleType, - [changedItem.ruleType]: { - ruleItems: newRuleItems, - activeCount: - rulesByRuleType[changedItem.ruleType].activeCount + (changeToActive ? 1 : -1), - }, - }; - this.setState({ rulesByRuleType: newRulesByRuleType }); - } - }; - - render() { - const { rulesByRuleType, selectedRuleType } = this.state; - const detectorRules = - this.props.enabledCustomRuleIds.length > 0 - ? this.props.enabledCustomRuleIds - : dummyDetectorRules; - - const totalRulesCountForSelectedType = selectedRuleType - ? rulesByRuleType[selectedRuleType]?.ruleItems.length || 0 - : detectorRules.length; - const activeRulesCountForSelectedType = this.getActiveRulesCount(selectedRuleType); - const allRulesCount = this.props.enabledCustomRuleIds.length || dummyDetectorRules.length; - const ruleTypes = Object.keys(rulesByRuleType); - - return ( - - -

{`Threat detection rules (${allRulesCount})`}

- - } - buttonProps={{ style: { paddingLeft: '10px', paddingRight: '10px' } }} - id={'detectorRulesAccordion'} - initialIsOpen={false} - > - -
- - - - - this.onRuleTypeClick()}> - View all rules {`(${allRulesCount})`} - - - {ruleTypes.map((ruleType) => ( - - this.onRuleTypeClick(ruleType)} - >{`${ruleType} (${rulesByRuleType[ruleType].ruleItems.length})`} - - - ))} - - - - - `${item.ruleName}`} - /> - - - -
-
-
- ); - } -} + return ( + + +

{`Detection rules (${enabledRulesCount} selected)`}

+ + } + buttonProps={{ style: { paddingLeft: '10px', paddingRight: '10px' } }} + id={'detectorRulesAccordion'} + initialIsOpen={false} + > + + +
+
+ ); +}; diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRulesTable.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRulesTable.tsx new file mode 100644 index 000000000..1dd18e2e3 --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRulesTable.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CriteriaWithPagination, EuiInMemoryTable } from '@elastic/eui'; +import { ruleSeverity, ruleSource, ruleTypes } from '../../../../../../pages/Rules/lib/helpers'; +import React from 'react'; +import { RuleItem } from './types/interfaces'; +import { getRulesColumns } from './utils/constants'; +import { Search } from '@opensearch-project/oui/src/eui_components/basic_table'; + +export interface DetectionRulesTableProps { + ruleItems: RuleItem[]; + pageIndex?: number; + onAllRulesToggled?: (enabled: boolean) => void; + onRuleActivationToggle: (changedItem: RuleItem, isActive: boolean) => void; + onTableChange?: (nextValues: CriteriaWithPagination) => void; +} + +const rulePriorityBySeverity: { [severity: string]: number } = { + critical: 1, + high: 2, + medium: 3, + low: 4, + informational: 5, +}; + +export const DetectionRulesTable: React.FC = ({ + pageIndex, + ruleItems, + onAllRulesToggled, + onRuleActivationToggle, + onTableChange, +}) => { + //Filter table by rule type + const search: Search = { + box: { + schema: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'logType', + name: 'Log Type', + multiSelect: true, + options: ruleTypes.map((type: string) => ({ + value: type, + })), + }, + { + type: 'field_value_selection', + field: 'severity', + name: 'Rule Severity', + multiSelect: false, + options: ruleSeverity, + }, + { + type: 'field_value_selection', + field: 'library', + name: 'Source', + multiSelect: false, + options: ruleSource.map((source: string) => ({ + value: source, + })), + }, + ], + }; + + const allRulesEnabled = ruleItems.every((item) => item.active); + ruleItems.sort((a, b) => { + return (rulePriorityBySeverity[a.severity] || 6) - (rulePriorityBySeverity[b.severity] || 6); + }); + + return ( +
+ `${item.name}`} + search={search} + pagination={ + pageIndex !== undefined + ? { + pageIndex, + } + : true + } + onTableChange={onTableChange} + /> +
+ ); +}; diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces.ts b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces.ts index 97dc2d591..9f364750a 100644 --- a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces.ts +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces.ts @@ -3,13 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { RuleInfo } from '../../../../../../../../server/models/interfaces'; + export interface RuleItem { - ruleName: string; - ruleType: string; + name: string; + id: string; + severity: string; + logType: string; + library: string; description: string; active: boolean; } +export type RuleItemInfo = RuleInfo & { enabled: boolean; prePackaged: boolean }; + export type RulesInfoByType = { - [ruleType: string]: { ruleItems: RuleItem[]; activeCount: number }; + [ruleType: string]: RuleItemInfo[]; }; diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx index 5aeb0f44f..381e466a4 100644 --- a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx @@ -14,33 +14,63 @@ export type ActiveToggleOnChangeEvent = React.BaseSyntheticEvent< >; export const getRulesColumns = ( - onActivationToggle: (item: RuleItem, active: boolean) => void -): EuiBasicTableColumn[] => [ - { - field: 'ruleName', - name: 'Rule name', - render: (ruleName: string, item: RuleItem): ReactNode => ( - <> + allEnabled: boolean, + onAllRulesToggled?: (enabled: boolean) => void, + onActivationToggle?: (item: RuleItem, active: boolean) => void +): EuiBasicTableColumn[] => { + const columns: EuiBasicTableColumn[] = [ + { + field: 'name', + name: 'Rule name', + render: (ruleName: string, item: RuleItem): ReactNode => ( + {ruleName} + ), + }, + { + field: 'severity', + name: 'Rule severity', + }, + { + field: 'logType', + name: 'Log type', + }, + { + field: 'library', + name: 'Source', + }, + { + field: 'description', + name: 'Description', + }, + ]; + + if (onActivationToggle) { + columns.unshift({ + name: onAllRulesToggled ? ( - onActivationToggle(item, event.target.checked) - } + checked={allEnabled} + onChange={(event: ActiveToggleOnChangeEvent) => { + onAllRulesToggled(!allEnabled); + }} label={''} showLabel={false} /> - alert('opening rule details')} style={{ marginLeft: 10 }}> - {ruleName} - - - ), - }, - { - field: 'ruleType', - name: 'Rule Type', - }, - { - field: 'description', - name: 'Description', - }, -]; + ) : undefined, + render: (item: RuleItem) => { + return ( + + onActivationToggle(item, event.target.checked) + } + label={''} + showLabel={false} + /> + ); + }, + width: '60px', + }); + } + + return columns; +}; diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx index 394a96c36..656314162 100644 --- a/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx @@ -4,7 +4,6 @@ */ import React, { Component } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; import { ContentPanel } from '../../../../../../components/ContentPanel'; import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { FormFieldHeader } from '../../../../../../components/FormFieldHeader/FormFieldHeader'; @@ -12,7 +11,7 @@ import { IndexOption } from '../../../../../Detectors/models/interfaces'; import { MIN_NUM_DATA_SOURCES } from '../../../../../Detectors/utils/constants'; import IndexService from '../../../../../../services/IndexService'; -interface DetectorDataSourceProps extends RouteComponentProps { +interface DetectorDataSourceProps { detectorIndices: string[]; indexService: IndexService; onDetectorInputIndicesChange: (selectedOptions: EuiComboBoxOptionOption[]) => void; @@ -64,6 +63,12 @@ export default class DetectorDataSource extends Component< return indices.map((index) => ({ label: index })); }; + onCreateOption = (searchValue: string, options: EuiComboBoxOptionOption[]) => { + const parsedOptions = this.parseOptions(this.props.detectorIndices); + parsedOptions.push({ label: searchValue }); + this.onSelectionChange(parsedOptions); + }; + onSelectionChange = (options: EuiComboBoxOptionOption[]) => { if (options.length < MIN_NUM_DATA_SOURCES) { this.setState({ errorMessage: 'Select an input source.' }); @@ -79,7 +84,7 @@ export default class DetectorDataSource extends Component< const { loading, indexOptions, errorMessage } = this.state; return ( - + diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorDetails/DetectorBasicDetailsForm.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorDetails/DetectorBasicDetailsForm.tsx index 5f00a1c3d..23e57e175 100644 --- a/public/pages/CreateDetector/components/DefineDetector/components/DetectorDetails/DetectorBasicDetailsForm.tsx +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorDetails/DetectorBasicDetailsForm.tsx @@ -48,7 +48,7 @@ export default class DetectorBasicDetailsForm extends Component< const { nameIsInvalid } = this.state; return ( - + } diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/CustomCron.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/CustomCron.tsx new file mode 100644 index 000000000..22347586b --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/CustomCron.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export interface CustomCronProps {} + +export interface CustomCronState {} + +export class CustomCron extends React.Component { + constructor(props: CustomCronProps) { + super(props); + this.state = {}; + } + + render() { + return

Custom cron

; + } +} diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Daily.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Daily.tsx new file mode 100644 index 000000000..5a3a8ca31 --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Daily.tsx @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiDatePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, +} from '@elastic/eui'; +import React from 'react'; +import moment, { Moment } from 'moment'; +import FormFieldHeader from '../../../../../../components/FormFieldHeader'; + +export interface DailyProps {} + +export interface DailyState { + selectedTime?: number; + selectedTimeZone?: string; + invalidTimeMessage?: string; + invalidTimeZoneMessage?: string; +} + +const timezones = moment.tz.names().map((tz) => ({ label: tz })); + +export class Daily extends React.Component { + constructor(props: DailyProps) { + super(props); + this.state = { + selectedTime: 0, + }; + } + + onTimeSelect = (date: Moment) => { + if (date) { + this.setState({ selectedTime: date.hours(), invalidTimeMessage: undefined }); + } else { + this.setState({ invalidTimeMessage: 'Invalid time selected.', selectedTime: undefined }); + } + }; + + onTimeZoneSelect = (options: EuiComboBoxOptionOption[]) => { + this.setState( + options.length > 0 + ? { selectedTimeZone: options[0].label, invalidTimeMessage: undefined } + : { invalidTimeZoneMessage: 'Select a timezone.', selectedTimeZone: undefined } + ); + }; + + render() { + const { + selectedTime, + selectedTimeZone, + invalidTimeMessage, + invalidTimeZoneMessage, + } = this.state; + return ( + + + } + isInvalid={!!invalidTimeMessage} + error={invalidTimeMessage} + > + + + + + + `${tz} (${moment.tz(tz).format('Z')})`} + singleSelection={true} + selectedOptions={selectedTimeZone ? [{ label: selectedTimeZone }] : undefined} + onChange={this.onTimeZoneSelect} + /> + + + + ); + } +} diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/DetectorSchedule.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/DetectorSchedule.tsx new file mode 100644 index 000000000..e34824e72 --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/DetectorSchedule.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ContentPanel } from '../../../../../../components/ContentPanel'; +import React from 'react'; +import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; +import FormFieldHeader from '../../../../../../components/FormFieldHeader'; +import { Detector, PeriodSchedule } from '../../../../../../../models/interfaces'; +import { Interval } from './Interval'; +import { CustomCron } from './CustomCron'; +import { Daily } from './Daily'; +import { Monthly } from './Monthly'; +import { Weekly } from './Weekly'; + +const frequencies: EuiSelectOption[] = [{ value: 'interval', text: 'By interval' }]; + +export interface DetectorScheduleProps { + detector: Detector; + onDetectorScheduleChange(schedule: PeriodSchedule): void; +} + +export interface DetectorScheduleState { + selectedFrequency: string; +} + +const components: { [freq: string]: typeof React.Component } = { + daily: Daily, + weekly: Weekly, + monthly: Monthly, + cronExpression: CustomCron, + interval: Interval, +}; + +export class DetectorSchedule extends React.Component< + DetectorScheduleProps, + DetectorScheduleState +> { + constructor(props: DetectorScheduleProps) { + super(props); + this.state = { + selectedFrequency: frequencies[0].value as string, + }; + } + + onFrequencySelected = (event: React.ChangeEvent) => { + this.setState({ selectedFrequency: event.target.value }); + }; + + render() { + const FrequencyPicker = components[this.state.selectedFrequency]; + + return ( + + }> + + + + + + ); + } +} diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Interval.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Interval.tsx new file mode 100644 index 000000000..647526835 --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Interval.tsx @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSelectOption, +} from '@elastic/eui'; +import FormFieldHeader from '../../../../../../components/FormFieldHeader'; +import React from 'react'; +import { Detector, PeriodSchedule } from '../../../../../../../models/interfaces'; + +export interface IntervalProps { + detector: Detector; + onDetectorScheduleChange(schedule: PeriodSchedule): void; +} + +const unitOptions: EuiSelectOption[] = [ + { value: 'MINUTES', text: 'Minutes' }, + { value: 'HOURS', text: 'Hours' }, + { value: 'DAYS', text: 'Days' }, +]; + +export class Interval extends React.Component { + onTimeIntervalChange = (event: React.ChangeEvent) => { + this.props.onDetectorScheduleChange({ + period: { + ...this.props.detector.schedule.period, + interval: parseInt(event.target.value), + }, + }); + }; + + onUnitChange = (event: React.ChangeEvent) => { + this.props.onDetectorScheduleChange({ + period: { + ...this.props.detector.schedule.period, + unit: event.target.value, + }, + }); + }; + + render() { + const { period } = this.props.detector.schedule; + return ( + }> + + + + + + + + + + ); + } +} diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Monthly.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Monthly.tsx new file mode 100644 index 000000000..486aefa3c --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Monthly.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export interface MonthlyProps {} + +export interface MonthlyState {} + +export class Monthly extends React.Component { + constructor(props: MonthlyProps) { + super(props); + this.state = {}; + } + + render() { + return

Monthly

; + } +} diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Weekly.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Weekly.tsx new file mode 100644 index 000000000..129ff04bb --- /dev/null +++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorSchedule/Weekly.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export interface WeeklyProps {} + +export interface WeeklyState {} + +export class Weekly extends React.Component { + constructor(props: WeeklyProps) { + super(props); + this.state = {}; + } + + render() { + return

Weekly

; + } +} diff --git a/public/pages/CreateDetector/components/DefineDetector/containers/DefineDetector.tsx b/public/pages/CreateDetector/components/DefineDetector/containers/DefineDetector.tsx index 6846bc1bc..d03954700 100644 --- a/public/pages/CreateDetector/components/DefineDetector/containers/DefineDetector.tsx +++ b/public/pages/CreateDetector/components/DefineDetector/containers/DefineDetector.tsx @@ -6,22 +6,31 @@ import React, { ChangeEvent, Component } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; -import { Detector, Rule } from '../../../../../../models/interfaces'; +import { Detector, PeriodSchedule } from '../../../../../../models/interfaces'; import DetectorBasicDetailsForm from '../components/DetectorDetails'; import DetectorDataSource from '../components/DetectorDataSource'; import DetectorType from '../components/DetectorType'; -import DetectionRules from '../components/DetectionRules'; import { EuiComboBoxOptionOption } from '@opensearch-project/oui'; -import IndexService from '../../../../../services/IndexService'; +import { IndexService } from '../../../../../services'; import { MIN_NUM_DATA_SOURCES } from '../../../../Detectors/utils/constants'; import { DetectorCreationStep } from '../../../models/types'; +import { DetectorSchedule } from '../components/DetectorSchedule/DetectorSchedule'; +import { RuleItem } from '../components/DetectionRules/types/interfaces'; +import { + CreateDetectorRulesState, + DetectionRules, +} from '../components/DetectionRules/DetectionRules'; interface DefineDetectorProps extends RouteComponentProps { detector: Detector; isEdit: boolean; indexService: IndexService; + rulesState: CreateDetectorRulesState; changeDetector: (detector: Detector) => void; updateDataValidState: (step: DetectorCreationStep, isValid: boolean) => void; + onPageChange: (page: { index: number; size: number }) => void; + onRuleToggle: (changedItem: RuleItem, isActive: boolean) => void; + onAllRulesToggle: (enabled: boolean) => void; } interface DefineDetectorState {} @@ -37,7 +46,7 @@ export default class DefineDetector extends Component= MIN_NUM_DATA_SOURCES; + detector.inputs[0].detector_input.indices.length >= MIN_NUM_DATA_SOURCES; this.props.changeDetector(detector); this.props.updateDataValidState(DetectorCreationStep.DEFINE_DETECTOR, isDataValid); } @@ -57,8 +66,8 @@ export default class DefineDetector extends Component {}; + onPrepackagedRulesChanged = (enabledRuleIds: string[]) => { + const { inputs } = this.props.detector; + const newDetector: Detector = { + ...this.props.detector, + inputs: [ + { + detector_input: { + ...inputs[0].detector_input, + pre_packaged_rules: enabledRuleIds.map((id) => { + return { id }; + }), + }, + }, + ...inputs.slice(1), + ], + }; + + this.updateDetectorCreationState(newDetector); + }; + + onCustomRulesChanged = (enabledRuleIds: string[]) => { + const { inputs } = this.props.detector; + const newDetector: Detector = { + ...this.props.detector, + inputs: [ + { + detector_input: { + ...inputs[0].detector_input, + custom_rules: enabledRuleIds.map((id) => { + return { id }; + }), + }, + }, + ...inputs.slice(1), + ], + }; + + this.updateDetectorCreationState(newDetector); + }; + + onDetectorScheduleChange = (schedule: PeriodSchedule) => { + const newDetector: Detector = { + ...this.props.detector, + schedule, + }; + + this.updateDetectorCreationState(newDetector); + }; render() { - const { isEdit } = this.props; + const { + isEdit, + detector, + rulesState, + onRuleToggle, + onPageChange, + onAllRulesToggle, + } = this.props; const { name, inputs, detector_type } = this.props.detector; - const { description, indices, rules: enabledCustomRuleIds } = inputs[0].input; + const { description, indices } = inputs[0].detector_input; return (
@@ -138,10 +201,17 @@ export default class DefineDetector extends Component + + + +
); diff --git a/public/pages/CreateDetector/components/ReviewAndCreate/containers/ReviewAndCreate.tsx b/public/pages/CreateDetector/components/ReviewAndCreate/containers/ReviewAndCreate.tsx index d28d68ef9..78d991a3f 100644 --- a/public/pages/CreateDetector/components/ReviewAndCreate/containers/ReviewAndCreate.tsx +++ b/public/pages/CreateDetector/components/ReviewAndCreate/containers/ReviewAndCreate.tsx @@ -3,21 +3,60 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ContentPanel } from '../../../../../components/ContentPanel'; import React from 'react'; -import { createDetectorSteps } from '../../../utils/constants'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { DetectorDetailsView } from '../../../../Detectors/containers/DetectorDetailsView/DetectorDetailsView'; +import { FieldMappingsView } from '../../../../Detectors/components/FieldMappingsView/FieldMappingsView'; +import { AlertTriggersView } from '../../../../Detectors/containers/AlertTriggersView/AlertTriggersView'; +import { RouteComponentProps } from 'react-router-dom'; +import { Detector, FieldMapping } from '../../../../../../models/interfaces'; import { DetectorCreationStep } from '../../../models/types'; -export interface ReviewAndCreateProps {} +export interface ReviewAndCreateProps extends RouteComponentProps { + detector: Detector; + existingMappings: FieldMapping[]; + setDetectorCreationStep: (step: DetectorCreationStep) => void; +} + +export interface ReviewAndCreateState {} + +export class ReviewAndCreate extends React.Component { + setDefineDetectorStep = () => { + this.props.setDetectorCreationStep(DetectorCreationStep.DEFINE_DETECTOR); + }; + + setConfigureFieldMappingStep = () => { + this.props.setDetectorCreationStep(DetectorCreationStep.CONFIGURE_FIELD_MAPPING); + }; + + setConfigureAlertsStep = () => { + this.props.setDetectorCreationStep(DetectorCreationStep.CONFIGURE_ALERTS); + }; -export class ReviewAndCreate extends React.Component { render() { return ( - - {/* - - */} - +
+ +

Review and create

+
+ + + + + +
); } } diff --git a/public/pages/CreateDetector/containers/CreateDetector.tsx b/public/pages/CreateDetector/containers/CreateDetector.tsx index fece51f28..40ac070f7 100644 --- a/public/pages/CreateDetector/containers/CreateDetector.tsx +++ b/public/pages/CreateDetector/containers/CreateDetector.tsx @@ -18,6 +18,13 @@ import { CoreServicesContext } from '../../../components/core_services'; import { DetectorCreationStep } from '../models/types'; import { BrowserServices } from '../../../models/interfaces'; import { ReviewAndCreate } from '../components/ReviewAndCreate/containers/ReviewAndCreate'; +import { CreateDetectorRulesOptions } from '../../../models/types'; +import { CreateDetectorRulesState } from '../components/DefineDetector/components/DetectionRules/DetectionRules'; +import { + RuleItem, + RuleItemInfo, +} from '../components/DefineDetector/components/DetectionRules/types/interfaces'; +import { RuleInfo } from '../../../../server/models/interfaces/Rules'; interface CreateDetectorProps extends RouteComponentProps { isEdit: boolean; @@ -30,6 +37,7 @@ interface CreateDetectorState { fieldMappings: FieldMapping[]; stepDataValid: { [step in DetectorCreationStep]: boolean }; creatingDetector: boolean; + rulesState: CreateDetectorRulesState; } export default class CreateDetector extends Component { @@ -44,16 +52,28 @@ export default class CreateDetector extends Component, + prevState: Readonly, + snapshot?: any + ): void { + if (prevState.detector.detector_type !== this.state.detector.detector_type) { + this.setupRulesState(); + } } changeDetector = (detector: Detector) => { @@ -65,15 +85,24 @@ export default class CreateDetector extends Component { - if (this.state.creatingDetector) { + const { creatingDetector, detector, fieldMappings } = this.state; + if (creatingDetector) { return; } this.setState({ creatingDetector: true }); - const createDetectorRes = await this.props.services.detectorsService.createDetector( - this.state.detector + const createMappingsRes = await this.props.services.fieldMappingService.createMappings( + detector.inputs[0].detector_input.indices[0], + detector.detector_type, + fieldMappings ); + if (createMappingsRes.ok) { + console.log('Field mapping creation successful'); + } + + const createDetectorRes = await this.props.services.detectorsService.createDetector(detector); + if (createDetectorRes.ok) { this.props.history.push(ROUTES.DETECTORS); } else { @@ -91,6 +120,10 @@ export default class CreateDetector extends Component { + this.setState({ currentStep }); + }; + updateDataValidState = (step: DetectorCreationStep, isValid: boolean): void => { this.setState({ stepDataValid: { @@ -100,6 +133,130 @@ export default class CreateDetector extends Component ({ + id: rule._id, + name: rule._source.title, + severity: rule._source.level, + tags: rule._source.tags.map((tag: { value: string }) => tag.value), + })); + + return options; + } + + async setupRulesState() { + const prePackagedRules = await this.getRules(true); + const customRules = await this.getRules(false); + + this.setState({ + rulesState: { + ...this.state.rulesState, + allRules: customRules.concat(prePackagedRules), + page: { + index: 0, + }, + }, + detector: { + ...this.state.detector, + inputs: [ + { + detector_input: { + ...this.state.detector.inputs[0].detector_input, + pre_packaged_rules: prePackagedRules.map((rule) => ({ id: rule._id })), + custom_rules: customRules.map((rule) => ({ id: rule._id })), + }, + }, + ], + }, + }); + } + + async getRules(prePackaged: boolean): Promise { + try { + const { detector_type } = this.state.detector; + + if (!detector_type) { + return []; + } + + const rulesRes = await this.props.services.ruleService.getRules(prePackaged, { + from: 0, + size: 5000, + query: { + nested: { + path: 'rule', + query: { + bool: { + must: [{ match: { 'rule.category': `${detector_type}` } }], + }, + }, + }, + }, + }); + + if (rulesRes.ok) { + const rules: RuleItemInfo[] = rulesRes.response.hits.hits.map((ruleInfo: RuleInfo) => { + return { + ...ruleInfo, + enabled: true, + prePackaged, + }; + }); + + return rules; + } else { + return []; + } + } catch (error: any) { + return []; + } + } + + onPageChange = (page: { index: number; size: number }) => { + this.setState({ + rulesState: { + ...this.state.rulesState, + page: { index: page.index }, + }, + }); + }; + + onRuleToggle = (changedItem: RuleItem, isActive: boolean) => { + const ruleIndex = this.state.rulesState.allRules.findIndex((ruleItemInfo) => { + return ruleItemInfo._id === changedItem.id; + }); + + if (ruleIndex > -1) { + const newRules: RuleItemInfo[] = [ + ...this.state.rulesState.allRules.slice(0, ruleIndex), + { ...this.state.rulesState.allRules[ruleIndex], enabled: isActive }, + ...this.state.rulesState.allRules.slice(ruleIndex + 1), + ]; + + this.setState({ + rulesState: { + ...this.state.rulesState, + allRules: newRules, + }, + }); + } + }; + + onAllRulesToggle = (enabled: boolean) => { + const newRules: RuleItemInfo[] = this.state.rulesState.allRules.map((rule) => ({ + ...rule, + enabled, + })); + + this.setState({ + rulesState: { + ...this.state.rulesState, + allRules: newRules, + }, + }); + }; + getStepContent = () => { const { services } = this.props; switch (this.state.currentStep) { @@ -109,6 +266,10 @@ export default class CreateDetector extends Component @@ -129,12 +290,21 @@ export default class CreateDetector extends Component ); case DetectorCreationStep.REVIEW_CREATE: - return ; + return ( + + ); } }; diff --git a/public/pages/CreateDetector/models/interfaces.ts b/public/pages/CreateDetector/models/interfaces.ts index 38f4c73b0..16cfbe228 100644 --- a/public/pages/CreateDetector/models/interfaces.ts +++ b/public/pages/CreateDetector/models/interfaces.ts @@ -4,8 +4,8 @@ */ export interface FieldMappingsTableItem { - logFieldName: string; - siemFieldName?: string; + ruleFieldName: string; + logFieldName?: string; } export interface DetectorCreationStepInfo {