diff --git a/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js b/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js index 9d72546d8..730e2b535 100644 --- a/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js +++ b/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js @@ -14,6 +14,8 @@ import { EuiIcon, EuiLink, EuiSpacer, + EuiTab, + EuiTabs, EuiText, } from '@elastic/eui'; import { getTime } from '../../../../pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats'; @@ -27,7 +29,6 @@ import { SEARCH_TYPE, } from '../../../../utils/constants'; import { TRIGGER_TYPE } from '../../../../pages/CreateTrigger/containers/CreateTrigger/utils/constants'; -import { SEVERITY_OPTIONS } from '../../../../pages/CreateTrigger/containers/DefineTrigger/DefineTrigger'; import { UNITS_OF_TIME } from '../../../../pages/CreateMonitor/components/MonitorExpressions/expressions/utils/constants'; import { DEFAULT_WHERE_EXPRESSION_TEXT } from '../../../../pages/CreateMonitor/components/MonitorExpressions/expressions/utils/whereHelpers'; import { backendErrorNotification } from '../../../../utils/helpers'; @@ -45,14 +46,18 @@ import { queryColumns } from '../../../../pages/Dashboard/utils/tableUtils'; import { DEFAULT_PAGE_SIZE_OPTIONS } from '../../../../pages/Monitors/containers/Monitors/utils/constants'; import queryString from 'query-string'; import { MAX_ALERT_COUNT } from '../../../../pages/Dashboard/utils/constants'; +import { SEVERITY_OPTIONS } from '../../../../pages/CreateTrigger/utils/constants'; +import { TABLE_TAB_IDS } from '../../../../pages/Dashboard/components/FindingsDashboard/utils'; +import FindingsDashboard from '../../../../pages/Dashboard/containers/FindingsDashboard'; export const DEFAULT_NUM_FLYOUT_ROWS = 10; export default class AlertsDashboardFlyoutComponent extends Component { constructor(props) { super(props); - const { location, monitor_id } = this.props; - + const { location, monitors, monitor_id } = this.props; + const monitor = _.get(_.find(monitors, { _id: monitor_id }), '_source'); + const monitorType = _.get(monitor, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL); const { alertState, from, @@ -67,8 +72,9 @@ export default class AlertsDashboardFlyoutComponent extends Component { alerts: [], alertState: alertState, loading: true, - monitors: [], + monitor: monitor, monitorIds: [monitor_id], + monitorType: monitorType, page: Math.floor(from / size), search: search, selectable: true, @@ -77,6 +83,8 @@ export default class AlertsDashboardFlyoutComponent extends Component { size: DEFAULT_NUM_FLYOUT_ROWS, sortDirection: sortDirection, sortField: sortField, + tabContent: undefined, + tabId: TABLE_TAB_IDS.ALERTS.id, totalAlerts: 0, }; } @@ -129,6 +137,12 @@ export default class AlertsDashboardFlyoutComponent extends Component { monitorIds ); } + const { monitorType } = this.state; + if ( + monitorType === MONITOR_TYPE.DOC_LEVEL && + !_.isEqual(prevState.selectedItems, this.state.selectedItems) + ) + this.setState({ tabContent: this.renderAlertsTable() }); } getBucketLevelGraphConditions = (trigger) => { @@ -153,7 +167,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { }; getAlerts = async () => { - this.setState({ ...this.state, loading: true }); + this.setState({ loading: true }); const { from, search, @@ -193,9 +207,9 @@ export default class AlertsDashboardFlyoutComponent extends Component { console.log('error getting alerts:', resp); backendErrorNotification(notifications, 'get', 'alerts', resp.err); } + this.setState({ tabContent: this.renderAlertsTable() }); }); - - this.setState({ ...this.state, loading: false }); + this.setState({ loading: false }); }; acknowledgeAlerts = async () => { @@ -249,7 +263,8 @@ export default class AlertsDashboardFlyoutComponent extends Component { alertState, monitorIds ); - this.setState({ ...this.state, selectedItems: [] }); + this.setState({ selectedItems: [], tabContent: undefined }); + this.setState({ tabContent: this.renderAlertsTable() }); this.props.refreshDashboard(); }; @@ -284,89 +299,24 @@ export default class AlertsDashboardFlyoutComponent extends Component { this.setState({ alerts }); }; - render() { - const { - last_notification_time, - loadingMonitors, - monitors, - monitor_id, - monitor_name, - start_time, - triggerID, - trigger_name, - } = this.props; - const monitor = _.get(_.find(monitors, { _id: monitor_id }), '_source'); - const monitorType = _.get(monitor, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL); - const searchType = _.get(monitor, 'ui_metadata.search.searchType', SEARCH_TYPE.GRAPH); - const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID); - - const triggerType = - monitorType === MONITOR_TYPE.BUCKET_LEVEL - ? TRIGGER_TYPE.BUCKET_LEVEL - : TRIGGER_TYPE.QUERY_LEVEL; - - let trigger = _.get(monitor, 'triggers', []).find( - (trigger) => trigger[triggerType].id === triggerID - ); - trigger = _.get(trigger, triggerType); + getTriggerType() { + const { monitorType } = this.state; + switch (monitorType) { + case MONITOR_TYPE.BUCKET_LEVEL: + return TRIGGER_TYPE.BUCKET_LEVEL; + case MONITOR_TYPE.DOC_LEVEL: + return TRIGGER_TYPE.DOC_LEVEL; + default: + return TRIGGER_TYPE.QUERY_LEVEL; + } + } - const severity = _.get(trigger, 'severity'); + renderAlertsTable() { + const { trigger_name } = this.props; + const { monitor, monitorType } = this.state; + const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID); const groupBy = _.get(monitor, MONITOR_GROUP_BY); - const condition = - monitorType === MONITOR_TYPE.BUCKET_LEVEL && searchType === SEARCH_TYPE.GRAPH - ? this.getBucketLevelGraphConditions(trigger) - : _.get(trigger, 'condition.script.source', '-'); - - const filters = - monitorType === MONITOR_TYPE.BUCKET_LEVEL && searchType === SEARCH_TYPE.GRAPH - ? this.getBucketLevelGraphFilter(trigger) - : '-'; - - const bucketValue = _.get(monitor, 'ui_metadata.search.bucketValue'); - let bucketUnitOfTime = _.get(monitor, 'ui_metadata.search.bucketUnitOfTime'); - UNITS_OF_TIME.map((entry) => { - if (entry.value === bucketUnitOfTime) bucketUnitOfTime = entry.text; - }); - const timeRangeForLast = - bucketValue !== undefined && !_.isEmpty(bucketUnitOfTime) - ? `${bucketValue} ${bucketUnitOfTime}` - : '-'; - - const actions = () => { - const { selectedItems } = this.state; - const actions = [ - - Acknowledge - , - ]; - if (!_.isEmpty(detectorId)) { - actions.unshift( - - View detector - - ); - } - return actions; - }; - - const getItemId = (item) => { - switch (monitorType) { - case MONITOR_TYPE.QUERY_LEVEL: - case MONITOR_TYPE.CLUSTER_METRICS: - return `${item.id}-${item.version}`; - case MONITOR_TYPE.BUCKET_LEVEL: - return item.id; - } - }; - const { alerts, alertState, @@ -374,6 +324,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { page, search, selectable, + selectedItems, severityLevel, size, sortDirection, @@ -381,16 +332,26 @@ export default class AlertsDashboardFlyoutComponent extends Component { totalAlerts, } = this.state; - const columnType = () => { - let columns = []; + const getItemId = (item) => { switch (monitorType) { case MONITOR_TYPE.QUERY_LEVEL: case MONITOR_TYPE.CLUSTER_METRICS: - columns = queryColumns; - break; + case MONITOR_TYPE.DOC_LEVEL: + return `${item.id}-${item.version}`; + case MONITOR_TYPE.BUCKET_LEVEL: + return item.id; + } + }; + + const columnType = () => { + let columns; + switch (monitorType) { case MONITOR_TYPE.BUCKET_LEVEL: columns = insertGroupByColumn(groupBy); break; + default: + columns = queryColumns; + break; } return removeColumns(['severity', 'trigger_name'], columns); }; @@ -403,6 +364,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { }; const selection = { + initialSelected: selectedItems, onSelectionChange: this.onSelectionChange, selectable: (item) => item.state === ALERT_STATE.ACTIVE, selectableMessage: (selectable) => @@ -416,8 +378,153 @@ export default class AlertsDashboardFlyoutComponent extends Component { }, }; + const actions = () => { + const actions = [ + + Acknowledge + , + ]; + if (!_.isEmpty(detectorId)) { + actions.unshift( + + View detector + + ); + } + return actions; + }; + const trimmedAlerts = alerts.slice(page * size, page * size + size); + return ( + + + + + + ); + } + renderFindingsTable() { + const { httpClient, history, location, monitor_id, notifications } = this.props; + return ( + + ); + } + + renderTableTabs() { + const { tabId } = this.state; + const tabs = [ + { ...TABLE_TAB_IDS.ALERTS, content: this.renderAlertsTable() }, + { ...TABLE_TAB_IDS.FINDINGS, content: this.renderFindingsTable() }, + ]; + + return tabs.map((tab, index) => ( + { + this.setState({ + tabId: tab.id, + tabContent: tab.content, + }); + }} + > + {tab.name} + + )); + } + + render() { + const { + last_notification_time, + loadingMonitors, + monitor_id, + monitor_name, + start_time, + triggerID, + trigger_name, + } = this.props; + const { loading, monitor, monitorType, tabContent } = this.state; + const searchType = _.get(monitor, 'ui_metadata.search.searchType', SEARCH_TYPE.GRAPH); + const triggerType = this.getTriggerType(monitorType); + + let trigger = _.get(monitor, 'triggers', []).find( + (trigger) => trigger[triggerType].id === triggerID + ); + trigger = _.get(trigger, triggerType); + + const severity = _.get(trigger, 'severity'); + const groupBy = _.get(monitor, MONITOR_GROUP_BY); + + const condition = + (searchType === SEARCH_TYPE.GRAPH && monitorType === MONITOR_TYPE.BUCKET_LEVEL) || + MONITOR_TYPE.DOC_LEVEL + ? this.getBucketLevelGraphConditions(trigger) + : _.get(trigger, 'condition.script.source', '-'); + + const filters = + monitorType === MONITOR_TYPE.BUCKET_LEVEL && searchType === SEARCH_TYPE.GRAPH + ? this.getBucketLevelGraphFilter(trigger) + : '-'; + + const bucketValue = _.get(monitor, 'ui_metadata.search.bucketValue'); + let bucketUnitOfTime = _.get(monitor, 'ui_metadata.search.bucketUnitOfTime'); + UNITS_OF_TIME.map((entry) => { + if (entry.value === bucketUnitOfTime) bucketUnitOfTime = entry.text; + }); + const timeRangeForLast = + bucketValue !== undefined && !_.isEmpty(bucketUnitOfTime) + ? `${bucketValue} ${bucketUnitOfTime}` + : '-'; + + const displayTableTabs = monitorType === MONITOR_TYPE.DOC_LEVEL; return (
@@ -481,82 +588,65 @@ export default class AlertsDashboardFlyoutComponent extends Component {

- - - Time range for the last -

{timeRangeForLast}

-
-
-
- - - - - - Filters -

{loadingMonitors || loading ? 'Loading filters...' : filters}

-
-
- - - Group by -

- {loadingMonitors || loading - ? 'Loading groups...' - : !_.isEmpty(groupBy) - ? _.join(_.orderBy(groupBy), ', ') - : '-'} -

-
-
+ {monitorType !== MONITOR_TYPE.DOC_LEVEL && ( + + + Time range for the last +

{timeRangeForLast}

+
+
+ )}
- + {monitorType !== MONITOR_TYPE.DOC_LEVEL && ( +
+ + + + + + Filters +

{loadingMonitors || loading ? 'Loading filters...' : filters}

+
+
+ + + Group by +

+ {loadingMonitors || loading + ? 'Loading groups...' + : !_.isEmpty(groupBy) + ? _.join(_.orderBy(groupBy), ', ') + : '-'} +

+
+
+
+
+ )} - - - - - - - - - - - + + + + + {displayTableTabs ? ( +
+ {this.renderTableTabs()} + {tabContent} +
+ ) : ( + this.renderAlertsTable() + )}
); diff --git a/public/components/Flyout/flyouts/components/__snapshots__/AlertsDashboardFlyoutComponent.test.js.snap b/public/components/Flyout/flyouts/components/__snapshots__/AlertsDashboardFlyoutComponent.test.js.snap index 9e8cd1f3d..765fb84f6 100644 --- a/public/components/Flyout/flyouts/components/__snapshots__/AlertsDashboardFlyoutComponent.test.js.snap +++ b/public/components/Flyout/flyouts/components/__snapshots__/AlertsDashboardFlyoutComponent.test.js.snap @@ -114,153 +114,158 @@ exports[`AlertsDashboardFlyoutComponent renders 1`] = ` +
+ + + + + + Filters + +

+ Loading filters... +

+
+
+ + + + Group by + +

+ Loading groups... +

+
+
+
+
- - - - - Filters - -

- Loading filters... -

-
-
- - - - Group by - -

- Loading groups... -

-
-
-
- - - - Acknowledge - , - ] - } - bodyStyles={ + + + Acknowledge + , + ] + } + bodyStyles={ + Object { + "padding": "initial", + } + } + title="Alerts" + titleSize="s" + > + + + - - - - - - + } + responsive={true} + selection={ + Object { + "initialSelected": Array [], + "onSelectionChange": [Function], + "selectable": [Function], + "selectableMessage": [Function], + } + } + sorting={ + Object { + "sort": Object { + "direction": "desc", + "field": "start_time", + }, + } + } + tableLayout="fixed" + /> + diff --git a/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueries.js b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueries.js new file mode 100644 index 000000000..499b42fe5 --- /dev/null +++ b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueries.js @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import _ from 'lodash'; +import { connect, FieldArray } from 'formik'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { inputLimitText } from '../../../../utils/helpers'; +import DocumentLevelQuery, { getInitialQueryValues } from './DocumentLevelQuery'; + +export const MAX_QUERIES = 10; // TODO DRAFT: Placeholder limit + +class ConfigureDocumentLevelQueries extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + renderQueries = (arrayHelpers) => { + const { + dataTypes, + formik: { values }, + } = this.props; + if (_.isEmpty(values.queries)) arrayHelpers.push(_.cloneDeep(getInitialQueryValues())); + const numOfQueries = values.queries.length; + return ( +
+ {values.queries.map((query, index) => { + return ( + + ); + })} + +
+ arrayHelpers.push(_.cloneDeep(getInitialQueryValues(numOfQueries)))} + disabled={numOfQueries >= MAX_QUERIES} + > + {numOfQueries === 0 ? 'Add query' : 'Add another query'} + + + {inputLimitText(numOfQueries, MAX_QUERIES, 'query', 'queries')} +
+
+ ); + }; + + render() { + return ( + + {(arrayHelpers) => this.renderQueries(arrayHelpers)} + + ); + } +} + +export default connect(ConfigureDocumentLevelQueries); diff --git a/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueryTags.js b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueryTags.js new file mode 100644 index 000000000..ddca82962 --- /dev/null +++ b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueryTags.js @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import { connect, FieldArray } from 'formik'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { inputLimitText } from '../../../../utils/helpers'; +import DocumentLevelQueryTag from './DocumentLevelQueryTag'; + +export const MAX_TAGS = 10; // TODO DRAFT: Placeholder limit + +class ConfigureDocumentLevelQueryTags extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + renderTags(arrayHelpers) { + const { + formik: { values }, + formFieldName = '', + query, + queryIndex, + } = this.props; + const numOfTags = query.tags.length; + return ( +
+ {values.queries[queryIndex].tags.map((tag, index) => { + return ( + + + + ); + })} +
+ arrayHelpers.push('')} + disabled={numOfTags >= MAX_TAGS} + style={{ paddingTop: '5px' }} + > + + Add tag + + {inputLimitText(numOfTags, MAX_TAGS, 'tag', 'tags')} +
+
+ ); + } + + render() { + const { formFieldName } = this.props; + return ( + + {(arrayHelpers) => this.renderTags(arrayHelpers)} + + ); + } +} + +export default connect(ConfigureDocumentLevelQueryTags); diff --git a/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQuery.js b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQuery.js new file mode 100644 index 000000000..d7516b9fe --- /dev/null +++ b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQuery.js @@ -0,0 +1,156 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import _ from 'lodash'; +import { connect } from 'formik'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormikFieldText, FormikComboBox, FormikSelect } from '../../../../components/FormControls'; +import { hasError, isInvalid, required } from '../../../../utils/validate'; +import { FORMIK_INITIAL_DOCUMENT_LEVEL_QUERY_VALUES } from '../../containers/CreateMonitor/utils/constants'; +import { DOC_LEVEL_TAG_TOOLTIP } from './DocumentLevelQueryTag'; +import IconToolTip from '../../../../components/IconToolTip'; +import ConfigureDocumentLevelQueryTags from './ConfigureDocumentLevelQueryTags'; +import { getIndexFields } from '../MonitorExpressions/expressions/utils/dataTypes'; + +const ALLOWED_DATA_TYPES = ['number', 'text', 'keyword', 'boolean']; + +export const QUERY_OPERATORS = [ + { text: 'is', value: '==' }, + { text: 'is not', value: '!=' }, +]; + +export const getInitialQueryValues = (queryIndexNum = 0) => + _.cloneDeep({ + ...FORMIK_INITIAL_DOCUMENT_LEVEL_QUERY_VALUES, + queryName: `Query ${queryIndexNum + 1}`, + }); + +class DocumentLevelQuery extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + render() { + const { dataTypes, formFieldName = '', query, queryIndex, queriesArrayHelpers } = this.props; + return ( +
+ + + + + + {queryIndex > 0 && ( + + queriesArrayHelpers.remove(queryIndex)}> + Remove query + + + )} + + + + + + + form.setFieldValue(field.name, e[0].label), + onBlur: (e, field, form) => form.setFieldTouched(field.name, true), + singleSelection: { asPlainText: true }, + }} + /> + + + + field.onChange(e), + options: QUERY_OPERATORS, + }} + /> + + + + + + + + + + + Tags + - optional + + + + + {_.isEmpty(query.tags) && ( +
+ No tags defined. +
+ )} + + + + +
+ ); + } +} + +export default connect(DocumentLevelQuery); diff --git a/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQueryTag.js b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQueryTag.js new file mode 100644 index 000000000..a2b92ac6c --- /dev/null +++ b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQueryTag.js @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import _ from 'lodash'; +import { connect } from 'formik'; +import { + EuiBadge, + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, +} from '@elastic/eui'; +import { FormikFieldText } from '../../../../components/FormControls'; +import { hasError, isInvalid, required } from '../../../../utils/validate'; +import { EXPRESSION_STYLE, POPOVER_STYLE } from '../MonitorExpressions/expressions/utils/constants'; + +export const DOC_LEVEL_TAG_TOOLTIP = 'Tags to associate with your queries.'; // TODO DRAFT: Placeholder wording +export const TAG_PLACEHOLDER_TEXT = 'Enter the search term'; // TODO DRAFT: Placeholder wording + +class DocumentLevelQueryTag extends Component { + constructor(props) { + super(props); + const { tag } = props; + this.state = { + isPopoverOpen: _.isEmpty(tag), + }; + this.closePopover = this.closePopover.bind(this); + this.openPopover = this.openPopover.bind(this); + } + + closePopover() { + const { arrayHelpers, tag, tagIndex } = this.props; + if (_.isEmpty(tag)) arrayHelpers.remove(tagIndex); + this.setState({ isPopoverOpen: false }); + } + + openPopover() { + this.setState({ isPopoverOpen: true }); + } + + renderPopover() { + const { formFieldName } = this.props; + return ( +
+ + + + + Cancel + + + + Save + + + +
+ ); + } + + render() { + const { arrayHelpers, tag = '', tagIndex = 0 } = this.props; + const { isPopoverOpen } = this.state; + return ( + + arrayHelpers.remove(tagIndex)} + iconOnClickAriaLabel={'Remove tag'} + onClick={this.openPopover} + onClickAriaLabel={'Edit tag'} + > + {_.isEmpty(tag) ? TAG_PLACEHOLDER_TEXT : tag} + + + } + isOpen={isPopoverOpen} + closePopover={this.closePopover} + panelPaddingSize={'none'} + ownFocus + withTitle + anchorPosition={'downLeft'} + > + ADD TAG + {this.renderPopover()} + + ); + } +} + +export default connect(DocumentLevelQueryTag); diff --git a/public/pages/CreateMonitor/components/MonitorType/MonitorType.js b/public/pages/CreateMonitor/components/MonitorType/MonitorType.js index 62f42f119..03a8a39d9 100644 --- a/public/pages/CreateMonitor/components/MonitorType/MonitorType.js +++ b/public/pages/CreateMonitor/components/MonitorType/MonitorType.js @@ -5,20 +5,32 @@ import React from 'react'; import _ from 'lodash'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiFlexGrid, EuiFlexItem, EuiText } from '@elastic/eui'; import FormikCheckableCard from '../../../../components/FormControls/FormikCheckableCard'; import { MONITOR_TYPE, SEARCH_TYPE } from '../../../../utils/constants'; import { FORMIK_INITIAL_TRIGGER_VALUES } from '../../../CreateTrigger/containers/CreateTrigger/utils/constants'; +import { + DEFAULT_DOCUMENT_LEVEL_QUERY, + FORMIK_INITIAL_VALUES, +} from '../../containers/CreateMonitor/utils/constants'; -export const MONITOR_TYPE_CARD_WIDTH = 400; +export const MONITOR_TYPE_CARD_WIDTH = 400; // TODO DRAFT: Determine width const onChangeDefinition = (e, form) => { const type = e.target.value; form.setFieldValue('monitor_type', type); - // Clearing trigger definitions when changing monitor types. + // Clearing various form fields when changing monitor types. // TODO: Implement modal that confirms the change before clearing. + form.setFieldValue('index', FORMIK_INITIAL_VALUES.index); form.setFieldValue('triggerDefinitions', FORMIK_INITIAL_TRIGGER_VALUES.triggerConditions); + switch (type) { + case MONITOR_TYPE.DOC_LEVEL: + form.setFieldValue('query', DEFAULT_DOCUMENT_LEVEL_QUERY); + break; + default: + form.setFieldValue('query', FORMIK_INITIAL_VALUES.query); + } }; const queryLevelDescription = ( @@ -41,15 +53,19 @@ const clusterMetricsDescription = ( ); +const documentLevelDescription = ( // TODO DRAFT: confirm wording + + Per document monitors allow you to run queries on new documents as they're indexed. + +); + const MonitorType = ({ values }) => ( - + ( ( ( }} /> - + + { + onChangeDefinition(e, form); + }, + children: documentLevelDescription, + 'data-test-subj': 'docLevelMonitorRadioCard', + }} + /> + + ); export default MonitorType; diff --git a/public/pages/CreateMonitor/components/MonitorType/__snapshots__/MonitorType.test.js.snap b/public/pages/CreateMonitor/components/MonitorType/__snapshots__/MonitorType.test.js.snap index 290af85b3..a655e4c97 100644 --- a/public/pages/CreateMonitor/components/MonitorType/__snapshots__/MonitorType.test.js.snap +++ b/public/pages/CreateMonitor/components/MonitorType/__snapshots__/MonitorType.test.js.snap @@ -2,7 +2,8 @@ exports[`MonitorType renders 1`] = `
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ Per document monitors allow you to run queries on new documents as they're indexed. +
+
+
+
+
+
+
+
`; diff --git a/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap b/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap index 83fd74f79..9a5d9e711 100644 --- a/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap +++ b/public/pages/CreateMonitor/containers/AnomalyDetectors/__tests__/__snapshots__/AnomalyDetector.test.js.snap @@ -10,6 +10,7 @@ exports[`AnomalyDetectors renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -34,6 +35,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -78,6 +80,7 @@ exports[`AnomalyDetectors renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -102,6 +105,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -207,6 +211,7 @@ exports[`AnomalyDetectors renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -231,6 +236,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -294,6 +300,7 @@ exports[`AnomalyDetectors renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -318,6 +325,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -429,6 +437,7 @@ exports[`AnomalyDetectors renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -453,6 +462,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -516,6 +526,7 @@ exports[`AnomalyDetectors renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -540,6 +551,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js index 70921780f..57e4a102f 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js @@ -234,8 +234,16 @@ export default class CreateMonitor extends Component { } render() { + const { + edit, + history, + httpClient, + location, + monitorToEdit, + notifications, + isDarkMode, + } = this.props; const { initialValues, plugins } = this.state; - const { edit, httpClient, monitorToEdit, notifications, isDarkMode } = this.props; return (
@@ -250,6 +258,7 @@ export default class CreateMonitor extends Component { { + switch (values.monitor_type) { + case MONITOR_TYPE.DOC_LEVEL: + return { + doc_level_input: formikToDocLevelQueriesUiMetadata(values), + search: { searchType: values.searchType }, + }; + default: + return { search: formikToUiSearch(values) }; + } + }; + return { name: values.name, type: 'monitor', @@ -28,16 +40,18 @@ export function formikToMonitor(values) { triggers: [], ui_metadata: { schedule: uiSchedule, - search: uiSearch, monitor_type: values.monitor_type, + ...monitorUiMetadata(), }, }; } export function formikToInputs(values) { - switch (values.searchType) { - case SEARCH_TYPE.CLUSTER_METRICS: + switch (values.monitor_type) { + case MONITOR_TYPE.CLUSTER_METRICS: return formikToClusterMetricsInput(values); + case MONITOR_TYPE.DOC_LEVEL: + return formikToDocLevelInput(values); default: return formikToSearch(values); } @@ -169,10 +183,16 @@ export function formikToExtractionQuery(values) { export function formikToGraphQuery(values) { const { bucketValue, bucketUnitOfTime, monitor_type } = values; - const useComposite = monitor_type === MONITOR_TYPE.BUCKET_LEVEL; - const aggregation = useComposite - ? formikToCompositeAggregation(values) - : formikToAggregation(values); + + const aggregation = () => { + switch (monitor_type) { + case MONITOR_TYPE.BUCKET_LEVEL: + return formikToCompositeAggregation(values); + default: + return formikToAggregation(values); + } + }; + const timeField = values.timeField; const filters = [ { @@ -191,7 +211,7 @@ export function formikToGraphQuery(values) { } return { size: 0, - aggregations: aggregation, + aggregations: aggregation(), query: { bool: { filter: filters, @@ -200,6 +220,61 @@ export function formikToGraphQuery(values) { }; } +export function formikToDocLevelInput(values) { + let description = FORMIK_INITIAL_VALUES.description; + let indices = formikToIndices(values); + let queries = _.get(values, 'queries', FORMIK_INITIAL_VALUES.queries); + switch (values.searchType) { + case SEARCH_TYPE.GRAPH: + description = values.description; + queries = queries.map((query) => { + const formikToQuery = + query.operator === '==' + ? `${query.field}:\"${query.query}\"` + : JSON.stringify({ + bool: { must_not: { term: { [query.field]: `\"${query.query}\"` } } }, + }); + return { + // id: query.id, // TODO FIXME: Refactor to this assignment logic once backend generates its own ID value + id: query.queryName, + name: query.queryName, + query: formikToQuery, + tags: query.tags, + }; + }); + break; + case SEARCH_TYPE.QUERY: + let query = _.get(values, 'query', ''); + try { + query = JSON.parse(query); + description = _.get(query, 'description', description); + queries = _.get(query, 'queries', queries); + } catch (e) { + /* Ignore JSON parsing errors as users may just be configuring the query */ + } + break; + default: + console.log( + `Unsupported searchType found for ${MONITOR_TYPE.DOC_LEVEL}: ${JSON.stringify( + values.searchType + )}`, + values.searchType + ); + } + + return { + doc_level_input: { + description: description, + indices: indices, + queries: queries, + }, + }; +} + +export function formikToDocLevelQueriesUiMetadata(values) { + return { queries: _.get(values, 'queries', []) }; +} + export function formikToCompositeAggregation(values) { const { aggregations, groupBy } = values; diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js index c97adf1fe..250ef48a1 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js @@ -4,8 +4,8 @@ */ import _ from 'lodash'; -import { FORMIK_INITIAL_VALUES } from './constants'; -import { SEARCH_TYPE, INPUTS_DETECTOR_ID } from '../../../../../utils/constants'; +import { FORMIK_INITIAL_DOCUMENT_LEVEL_QUERY_VALUES, FORMIK_INITIAL_VALUES } from './constants'; +import { SEARCH_TYPE, INPUTS_DETECTOR_ID, MONITOR_TYPE } from '../../../../../utils/constants'; // Convert Monitor JSON to Formik values used in UI forms export default function monitorToFormik(monitor) { @@ -21,10 +21,26 @@ export default function monitorToFormik(monitor) { } = monitor; // Default searchType to query, because if there is no ui_metadata or search then it was created through API or overwritten by API // In that case we don't want to guess on the UI what selections a user made, so we will default to just showing the extraction query - let { searchType = 'query', fieldName } = search; - if (_.isEmpty(search) && 'uri' in inputs[0]) searchType = SEARCH_TYPE.CLUSTER_METRICS; + const { searchType = 'query', fieldName } = search; const isAD = searchType === SEARCH_TYPE.AD; - const isClusterMetrics = searchType === SEARCH_TYPE.CLUSTER_METRICS; + + const monitorInputs = () => { + switch (monitor_type) { + case MONITOR_TYPE.CLUSTER_METRICS: + return { + index: FORMIK_INITIAL_VALUES.index, + uri: inputs[0].uri, + }; + case MONITOR_TYPE.DOC_LEVEL: + return docLevelInputToFormik(monitor); + default: + return { + index: indicesToFormik(inputs[0].search.indices), + query: JSON.stringify(inputs[0].search.query, null, 4), + }; + } + }; + return { /* INITIALIZE WITH DEFAULTS */ ...formikValues, @@ -38,17 +54,63 @@ export default function monitorToFormik(monitor) { cronExpression, /* DEFINE MONITOR */ + ...monitorInputs(), monitor_type, ...search, searchType, fieldName: fieldName ? [{ label: fieldName }] : [], timezone: timezone ? [{ label: timezone }] : [], - detectorId: isAD ? _.get(inputs, INPUTS_DETECTOR_ID) : undefined, - index: !isClusterMetrics - ? inputs[0].search.indices.map((index) => ({ label: index })) - : FORMIK_INITIAL_VALUES.index, - query: !isClusterMetrics ? JSON.stringify(inputs[0].search.query, null, 4) : undefined, - uri: isClusterMetrics ? inputs[0].uri : undefined, + adResultIndex: isAD ? _.get(inputs, '0.search.indices.0') : undefined, + }; +} + +export function docLevelInputToFormik(monitor) { + const input = monitor.inputs[0]['doc_level_input']; + const { description, indices, queries } = input; + return { + description: description, // TODO DRAFT: DocLevelInput 'description' field isn't currently represented in the mocks. Remove it from frontend? + index: indicesToFormik(indices), + query: JSON.stringify(_.omit(input, 'indices'), null, 4), + queries: queriesToFormik(queries), }; } + +export function queriesToFormik(queries) { + return queries.map((query) => { + let querySource = ''; + try { + querySource = JSON.parse(query.query); + } catch (e) { + querySource = query.query; + } + + const parsedQuerySource = {}; + const usesIsNotOperator = _.has(querySource, 'bool'); + const operator = usesIsNotOperator ? '!=' : '=='; + + if (usesIsNotOperator) { + const term = _.get(querySource, 'bool.must_not.term'); + const field = _.keys(term)[0]; + parsedQuerySource['field'] = _.trim(field, '":'); + parsedQuerySource['query'] = _.trim(term[field], '"'); + } else { + const splitQuery = _.split(querySource, '"'); + parsedQuerySource['field'] = _.trim(splitQuery[0], '":'); + parsedQuerySource['query'] = _.trim(splitQuery[1], '"'); + } + + return { + ...FORMIK_INITIAL_DOCUMENT_LEVEL_QUERY_VALUES, + id: query.id, + queryName: query.name, + tags: query.tags, + operator: operator, + ...parsedQuerySource, + }; + }); +} + +export function indicesToFormik(indices) { + return indices.map((index) => ({ label: index })); +} diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.test.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.test.js index d26f84283..8ddf26954 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.test.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.test.js @@ -7,6 +7,7 @@ import _ from 'lodash'; import monitorToFormik from './monitorToFormik'; import { FORMIK_INITIAL_VALUES, MATCH_ALL_QUERY } from './constants'; +import { MONITOR_TYPE, SEARCH_TYPE } from '../../../../../utils/constants'; const exampleMonitor = { name: 'Example Monitor', @@ -156,7 +157,8 @@ describe('monitorToFormik', () => { describe('can build ClusterMetricsMonitor', () => { test('with path params', () => { const clusterMetricsMonitor = _.cloneDeep(exampleMonitor); - clusterMetricsMonitor.ui_metadata.search.searchType = 'clusterMetrics'; + clusterMetricsMonitor.monitor_type = MONITOR_TYPE.CLUSTER_METRICS; + clusterMetricsMonitor.ui_metadata.search.searchType = SEARCH_TYPE.CLUSTER_METRICS; clusterMetricsMonitor.inputs = [ { uri: { @@ -171,7 +173,8 @@ describe('monitorToFormik', () => { }); test('without path params', () => { const clusterMetricsMonitor = _.cloneDeep(exampleMonitor); - clusterMetricsMonitor.ui_metadata.search.searchType = 'clusterMetrics'; + clusterMetricsMonitor.monitor_type = MONITOR_TYPE.CLUSTER_METRICS; + clusterMetricsMonitor.ui_metadata.search.searchType = SEARCH_TYPE.CLUSTER_METRICS; clusterMetricsMonitor.inputs = [ { uri: { diff --git a/public/pages/CreateMonitor/containers/DataSource/DataSource.js b/public/pages/CreateMonitor/containers/DataSource/DataSource.js index 7c0496027..0300d441f 100644 --- a/public/pages/CreateMonitor/containers/DataSource/DataSource.js +++ b/public/pages/CreateMonitor/containers/DataSource/DataSource.js @@ -9,7 +9,7 @@ import { EuiSpacer } from '@elastic/eui'; import MonitorIndex from '../MonitorIndex'; import MonitorTimeField from '../../components/MonitorTimeField'; import ContentPanel from '../../../../components/ContentPanel'; -import { SEARCH_TYPE } from '../../../../utils/constants'; +import { MONITOR_TYPE, SEARCH_TYPE } from '../../../../utils/constants'; const propTypes = { values: PropTypes.object.isRequired, @@ -30,8 +30,9 @@ class DataSource extends Component { } render() { - const { searchType } = this.props.values; - const isGraph = searchType === SEARCH_TYPE.GRAPH; + const { monitor_type, searchType } = this.props.values; + const displayTimeField = + searchType === SEARCH_TYPE.GRAPH && monitor_type !== MONITOR_TYPE.DOC_LEVEL; return ( - + - {isGraph && } + {displayTimeField && } ); } diff --git a/public/pages/CreateMonitor/containers/DataSource/__snapshots__/DataSource.test.js.snap b/public/pages/CreateMonitor/containers/DataSource/__snapshots__/DataSource.test.js.snap index da337a372..3182e6526 100644 --- a/public/pages/CreateMonitor/containers/DataSource/__snapshots__/DataSource.test.js.snap +++ b/public/pages/CreateMonitor/containers/DataSource/__snapshots__/DataSource.test.js.snap @@ -18,6 +18,7 @@ exports[`DataSource renders 1`] = ` > { + switch (values.monitor_type) { + case MONITOR_TYPE.DOC_LEVEL: + return ; + default: + return ; + } + }; + + const previewContent = () => { + switch (values.monitor_type) { + case MONITOR_TYPE.BUCKET_LEVEL: + return this.getBucketMonitorGraphs(aggregations, formikSnapshot, response); + case MONITOR_TYPE.DOC_LEVEL: + const { index, queries } = values; + return _.isEmpty(response) ? ( + renderEmptyMessage('Loading findings...') + ) : ( + + ); + default: + return ; + } + }; return ( - + {monitorExpressions()} - {errors.where ? ( - renderEmptyMessage('Invalid input in data filter. Remove data filter or adjust filter ') - ) : isBucketLevel ? ( - this.getBucketMonitorGraphs(aggregations, formikSnapshot, response) - ) : ( - - )} + {errors.where + ? renderEmptyMessage( + 'Invalid input in data filter. Remove data filter or adjust filter ' + ) + : previewContent()} @@ -238,7 +292,7 @@ class DefineMonitor extends Component { let requests; switch (searchType) { case SEARCH_TYPE.QUERY: - requests = [buildSearchRequest(values)]; + requests = [buildRequest(values)]; break; case SEARCH_TYPE.GRAPH: // TODO: Might need to check if groupBy is defined if monitor_type === Graph, and prevent onRunQuery() if no group by defined to avoid errors. @@ -246,14 +300,15 @@ class DefineMonitor extends Component { // 1. The actual query that will be saved on the monitor, to get accurate query performance stats // 2. The UI generated query that gets [BUCKET_COUNT] times the aggregated buckets to show past history of query // If the query is an extraction query, we can use the same query for results and query performance - requests = [buildSearchRequest(values)]; - requests.push(buildSearchRequest(values, false)); + requests = [buildRequest(values)]; + requests.push(buildRequest(values, false)); break; case SEARCH_TYPE.CLUSTER_METRICS: requests = [buildClusterMetricsRequest(values)]; break; } + const startTime = moment(); try { const promises = requests.map((request) => { // Fill in monitor name in case it's empty (in create workflow) @@ -266,7 +321,7 @@ class DefineMonitor extends Component { switch (searchType) { case SEARCH_TYPE.QUERY: case SEARCH_TYPE.GRAPH: - _.set(monitor, 'inputs[0].search', request); + _.set(monitor, 'inputs[0]', request); break; case SEARCH_TYPE.CLUSTER_METRICS: _.set(monitor, 'inputs[0].uri', request); @@ -283,12 +338,23 @@ class DefineMonitor extends Component { const [queryResponse, optionalResponse] = await Promise.all(promises); if (queryResponse.ok) { + const endTime = moment(); + const duration = moment.duration(endTime.diff(startTime)).milliseconds(); const response = _.get(queryResponse.resp, 'input_results.results[0]'); // If there is an optionalResponse use it's results, otherwise use the original response const performanceResponse = optionalResponse ? _.get(optionalResponse, 'resp.input_results.results[0]', null) : response; - this.setState({ response, formikSnapshot, performanceResponse }); + this.setState({ + response, + formikSnapshot, + // TODO FIXME: Doc level backend monitor run results don't include duration metric. Using this for now. + // This returns a much longer duration than other monitors, though. + performanceResponse: + values.monitor_type === MONITOR_TYPE.DOC_LEVEL + ? { ...performanceResponse, took: duration } + : performanceResponse, + }); } else { console.error('There was an error running the query', queryResponse.resp); backendErrorNotification(notifications, 'run', 'query', queryResponse.resp); @@ -336,11 +402,13 @@ class DefineMonitor extends Component { renderVisualMonitor() { const { values } = this.props; const { index, timeField } = values; - let content = null; + let content; + const supportsTimeField = values.monitor_type !== MONITOR_TYPE.DOC_LEVEL; if (index.length) { - content = timeField - ? this.renderGraph() - : renderEmptyMessage('You must specify a time field.'); + content = + _.isEmpty(timeField) && supportsTimeField + ? renderEmptyMessage('You must specify a time field.') + : this.renderGraph(); } else { content = renderEmptyMessage('You must specify an index.'); } @@ -527,7 +595,7 @@ class DefineMonitor extends Component { ? [ , diff --git a/public/pages/CreateMonitor/containers/DefineMonitor/__snapshots__/DefineMonitor.test.js.snap b/public/pages/CreateMonitor/containers/DefineMonitor/__snapshots__/DefineMonitor.test.js.snap index ca81b4831..294fc0c8b 100644 --- a/public/pages/CreateMonitor/containers/DefineMonitor/__snapshots__/DefineMonitor.test.js.snap +++ b/public/pages/CreateMonitor/containers/DefineMonitor/__snapshots__/DefineMonitor.test.js.snap @@ -15,6 +15,7 @@ exports[`DefineMonitor renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -39,6 +40,7 @@ exports[`DefineMonitor renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -158,7 +160,7 @@ exports[`DefineMonitor should show warning in case of Ad monitor and plugin is n color="warning" iconType="help" size="s" - title="Anomaly detector plugin is not installed on Opensearch, This monitor will not functional properly." + title="Anomaly detector plugin is not installed on OpenSearch, This monitor will not functional properly." /> +export const buildRequest = (values, uiGraphQuery = true) => values.searchType === SEARCH_TYPE.GRAPH ? buildGraphSearchRequest(values, uiGraphQuery) : buildQuerySearchRequest(values); function buildQuerySearchRequest(values) { - const indices = formikToIndices(values); - const query = JSON.parse(values.query); - return { query, indices }; + switch (values.monitor_type) { + case MONITOR_TYPE.DOC_LEVEL: + return formikToDocLevelInput(values); + default: + const indices = formikToIndices(values); + const query = JSON.parse(values.query); + return { search: { query, indices } }; + } } function buildGraphSearchRequest(values, uiGraphQuery) { - const query = uiGraphQuery ? formikToUiGraphQuery(values) : formikToGraphQuery(values); - const indices = formikToIndices(values); - return { query, indices }; + switch (values.monitor_type) { + case MONITOR_TYPE.DOC_LEVEL: + return formikToDocLevelInput(values); + default: + const query = uiGraphQuery ? formikToUiGraphQuery(values) : formikToGraphQuery(values); + const indices = formikToIndices(values); + return { search: { query, indices } }; + } } diff --git a/public/pages/CreateMonitor/containers/MonitorIndex/MonitorIndex.js b/public/pages/CreateMonitor/containers/MonitorIndex/MonitorIndex.js index f7878730c..ac424f2b4 100644 --- a/public/pages/CreateMonitor/containers/MonitorIndex/MonitorIndex.js +++ b/public/pages/CreateMonitor/containers/MonitorIndex/MonitorIndex.js @@ -11,6 +11,7 @@ import { EuiHealth, EuiHighlight } from '@elastic/eui'; import { FormikComboBox } from '../../../../components/FormControls'; import { validateIndex, hasError, isInvalid } from '../../../../utils/validate'; import { canAppendWildcard, createReasonableWait, getMatchedOptions } from './utils/helpers'; +import { MONITOR_TYPE } from '../../../../utils/constants'; const CustomOption = ({ option, searchValue, contentClassName }) => { const { health, label, index } = option; @@ -215,6 +216,8 @@ class MonitorIndex extends React.Component { false //isIncludingSystemIndices ); + const supportMultipleIndices = this.props.monitorType !== MONITOR_TYPE.DOC_LEVEL; + return ( diff --git a/public/pages/CreateMonitor/containers/MonitorIndex/__snapshots__/MonitorIndex.test.js.snap b/public/pages/CreateMonitor/containers/MonitorIndex/__snapshots__/MonitorIndex.test.js.snap index edc02cc3b..cc9024932 100644 --- a/public/pages/CreateMonitor/containers/MonitorIndex/__snapshots__/MonitorIndex.test.js.snap +++ b/public/pages/CreateMonitor/containers/MonitorIndex/__snapshots__/MonitorIndex.test.js.snap @@ -10,6 +10,7 @@ exports[`MonitorIndex renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -34,6 +35,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -100,6 +102,7 @@ exports[`MonitorIndex renders 1`] = ` ], "placeholder": "Select indices", "renderOption": [Function], + "singleSelection": false, } } name="index" @@ -150,6 +153,7 @@ exports[`MonitorIndex renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -174,6 +178,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -237,6 +242,7 @@ exports[`MonitorIndex renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -261,6 +267,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -388,6 +395,7 @@ exports[`MonitorIndex renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -412,6 +420,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -475,6 +484,7 @@ exports[`MonitorIndex renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -499,6 +509,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { @@ -556,6 +567,7 @@ exports[`MonitorIndex renders 1`] = ` ], "placeholder": "Select indices", "renderOption": [Function], + "singleSelection": false, } } name="index" diff --git a/public/pages/CreateTrigger/components/Action/actions/Message.js b/public/pages/CreateTrigger/components/Action/actions/Message.js index 34301811d..bee665ffa 100644 --- a/public/pages/CreateTrigger/components/Action/actions/Message.js +++ b/public/pages/CreateTrigger/components/Action/actions/Message.js @@ -34,6 +34,7 @@ import { } from '../../../../../utils/validate'; import { URL, MAX_THROTTLE_VALUE, WRONG_THROTTLE_WARNING } from '../../../../../../utils/constants'; import { MONITOR_TYPE } from '../../../../../utils/constants'; +import OverviewStat from '../../../../MonitorDetails/components/OverviewStat'; export const NOTIFY_OPTIONS_VALUES = { PER_ALERT: 'per_alert', @@ -352,7 +353,9 @@ export default function Message( - ) : null} + ) : ( + + )} {actionExecutionScopeId !== NOTIFY_OPTIONS_VALUES.PER_EXECUTION ? ( diff --git a/public/pages/CreateTrigger/components/Action/actions/__snapshots__/Message.test.js.snap b/public/pages/CreateTrigger/components/Action/actions/__snapshots__/Message.test.js.snap index bfc62b2ed..1fb13b85c 100644 --- a/public/pages/CreateTrigger/components/Action/actions/__snapshots__/Message.test.js.snap +++ b/public/pages/CreateTrigger/components/Action/actions/__snapshots__/Message.test.js.snap @@ -165,6 +165,20 @@ exports[`Message renders 1`] = `
+
+
+ + Perform action + +
+ Per monitor execution +
+
+
{ if (!triggerResults) return 'No trigger results'; const triggerId = Object.keys(triggerResults)[0]; if (!triggerId) return 'No trigger results'; - const executeResults = _.get(triggerResults, `${triggerId}`); + const executeResults = _.get(triggerResults, triggerId); if (!executeResults) return 'No execute results'; - const { error, triggered } = executeResults; - return error || `${triggered}`; + const { error, triggered, triggeredDocs } = executeResults; + if (!_.isNull(error) && !_.isUndefined(error)) return error; + if (!_.isNull(triggered) && !_.isUndefined(triggered)) return `${triggered}`; + if (!_.isNull(triggeredDocs) && !_.isUndefined(triggeredDocs)) + return JSON.stringify(triggeredDocs, null, 4); }; const TriggerQuery = ({ diff --git a/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js b/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js index c46c39550..8fffa4a9a 100644 --- a/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js +++ b/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js @@ -15,10 +15,11 @@ import DefineTrigger from '../DefineTrigger'; import { MONITOR_TYPE, SEARCH_TYPE } from '../../../../utils/constants'; import { getPathsPerDataType } from '../../../CreateMonitor/containers/DefineMonitor/utils/mappings'; import monitorToFormik from '../../../CreateMonitor/containers/CreateMonitor/utils/monitorToFormik'; -import { buildSearchRequest } from '../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; +import { buildRequest } from '../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; import { backendErrorNotification, inputLimitText } from '../../../../utils/helpers'; import moment from 'moment'; import { formikToTrigger } from '../CreateTrigger/utils/formikToTrigger'; +import DefineDocumentLevelTrigger from '../DefineDocumentLevelTrigger/DefineDocumentLevelTrigger'; import { buildClusterMetricsRequest, canExecuteClusterMetricsMonitor, @@ -133,7 +134,7 @@ class ConfigureTriggers extends React.Component { switch (searchType) { case SEARCH_TYPE.QUERY: case SEARCH_TYPE.GRAPH: - const searchRequest = buildSearchRequest(formikValues); + const searchRequest = buildRequest(formikValues); _.set(monitorToExecute, 'inputs[0].search', searchRequest); break; case SEARCH_TYPE.CLUSTER_METRICS: @@ -201,7 +202,41 @@ class ConfigureTriggers extends React.Component { }; }; - renderTriggers = (triggerArrayHelpers) => { + renderDefineTrigger = (triggerArrayHelpers, index) => { + const { + edit, + monitor, + monitorValues, + notifications, + setFlyout, + triggers, + triggerValues, + isDarkMode, + httpClient, + } = this.props; + + const { executeResponse } = this.state; + return ( + + ); + }; + + renderDefineBucketLevelTrigger = (triggerArrayHelpers, index) => { const { edit, monitor, @@ -213,53 +248,89 @@ class ConfigureTriggers extends React.Component { httpClient, notifications, } = this.props; - const { dataTypes, executeResponse, isBucketLevelMonitor, triggerEmptyPrompt } = this.state; + const { dataTypes, executeResponse } = this.state; + return ( + + ); + }; + + renderDefineDocumentLevelTrigger = (triggerArrayHelpers, index) => { + const { + edit, + monitor, + monitorValues, + setFlyout, + triggers, + triggerValues, + isDarkMode, + httpClient, + notifications, + } = this.props; + const { dataTypes, executeResponse } = this.state; + return ( + + ); + }; + + renderTriggers = (triggerArrayHelpers) => { + const { monitorValues, triggerValues } = this.props; const hasTriggers = !_.isEmpty(_.get(triggerValues, 'triggerDefinitions')); - return hasTriggers - ? triggerValues.triggerDefinitions.map((trigger, index) => { - return ( -
- {isBucketLevelMonitor ? ( - - ) : ( - - )} - -
- ); - }) - : triggerEmptyPrompt; + + const triggerContent = (arrayHelpers, index) => { + switch (monitorValues.monitor_type) { + case MONITOR_TYPE.BUCKET_LEVEL: + return this.renderDefineBucketLevelTrigger(arrayHelpers, index); + case MONITOR_TYPE.DOC_LEVEL: + return this.renderDefineDocumentLevelTrigger(arrayHelpers, index); + default: + return this.renderDefineTrigger(arrayHelpers, index); + } + }; + + return hasTriggers ? ( + triggerValues.triggerDefinitions.map((trigger, index) => { + return ( +
+ {triggerContent(triggerArrayHelpers, index)} + +
+ ); + }) + ) : ( + + ); }; render() { diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/CreateTrigger/CreateTrigger.js b/public/pages/CreateTrigger/containers/CreateTrigger/CreateTrigger/CreateTrigger.js index d4fa69b1b..7e582d0f0 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/CreateTrigger/CreateTrigger.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/CreateTrigger/CreateTrigger.js @@ -25,7 +25,7 @@ import 'brace/ext/language_tools'; import ConfigureActions from '../../ConfigureActions'; import DefineTrigger from '../../DefineTrigger'; import monitorToFormik from '../../../../CreateMonitor/containers/CreateMonitor/utils/monitorToFormik'; -import { buildSearchRequest } from '../../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; +import { buildRequest } from '../../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; import { formikToTrigger, formikToTriggerUiMetadata } from '../utils/formikToTrigger'; import { triggerToFormik } from '../utils/triggerToFormik'; import { FORMIK_INITIAL_TRIGGER_VALUES, TRIGGER_TYPE } from '../utils/constants'; @@ -142,7 +142,7 @@ export default class CreateTrigger extends Component { switch (searchType) { case SEARCH_TYPE.QUERY: case SEARCH_TYPE.GRAPH: - const searchRequest = buildSearchRequest(formikValues); + const searchRequest = buildRequest(formikValues); _.set(monitorToExecute, 'inputs[0].search', searchRequest); break; case SEARCH_TYPE.CLUSTER_METRICS: diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/constants.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/constants.js index 93990c60b..414fffd49 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/constants.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/constants.js @@ -8,6 +8,7 @@ export const TRIGGER_TYPE = { BUCKET_LEVEL: 'bucket_level_trigger', ALERT_TRIGGER: 'alerting_trigger', QUERY_LEVEL: 'query_level_trigger', + DOC_LEVEL: 'document_level_trigger', }; export const FORMIK_INITIAL_BUCKET_SELECTOR_VALUES = { @@ -37,6 +38,7 @@ export const FORMIK_INITIAL_TRIGGER_CONDITION_VALUES = { buckets_path: {}, parent_bucket_path: 'composite_agg', gap_policy: '', + query: undefined, queryMetric: undefined, andOrCondition: undefined, }; diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js index 845499f18..7ac2381e0 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js @@ -29,12 +29,14 @@ export function formikToTriggerDefinitions(values, monitorUiMetadata) { } export function formikToTriggerDefinition(values, monitorUiMetadata) { - const isBucketLevelMonitor = - _.get(monitorUiMetadata, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL) === - MONITOR_TYPE.BUCKET_LEVEL; - return isBucketLevelMonitor - ? formikToBucketLevelTrigger(values, monitorUiMetadata) - : formikToQueryLevelTrigger(values, monitorUiMetadata); + switch (monitorUiMetadata.monitor_type) { + case MONITOR_TYPE.BUCKET_LEVEL: + return formikToBucketLevelTrigger(values, monitorUiMetadata); + case MONITOR_TYPE.DOC_LEVEL: + return formikToDocumentLevelTrigger(values, monitorUiMetadata); + default: + return formikToQueryLevelTrigger(values, monitorUiMetadata); + } } export function formikToQueryLevelTrigger(values, monitorUiMetadata) { @@ -69,6 +71,50 @@ export function formikToBucketLevelTrigger(values, monitorUiMetadata) { }; } +export function formikToDocumentLevelTrigger(values, monitorUiMetadata) { + const condition = formikToDocumentLevelTriggerCondition(values, monitorUiMetadata); + const actions = formikToAction(values); + return { + document_level_trigger: { + id: values.id, + name: values.name, + severity: values.severity, + condition: condition, + actions: actions, + }, + }; +} + +export function formikToDocumentLevelTriggerCondition(values, monitorUiMetadata) { + const triggerConditions = _.get(values, 'triggerConditions', []); + const searchType = _.get(monitorUiMetadata, 'search.searchType', SEARCH_TYPE.QUERY); + if (searchType === SEARCH_TYPE.QUERY) return { script: values.script }; + const source = getDocumentLevelScriptSource(triggerConditions); + return { + script: { + lang: 'painless', + source: source, + }, + }; +} + +export function getDocumentLevelScriptSource(conditions) { + const scriptSourceContents = []; + conditions.forEach((condition) => { + const { andOrCondition, query } = condition; + if (andOrCondition) { + const logicOperator = getLogicalOperator(andOrCondition); + scriptSourceContents.push(logicOperator); + } + if (!_.isEmpty(query) && !_.isEmpty(query.queryName)) { + const queryExpression = _.get(query, 'expression'); + const operator = query.operator === '!=' ? '!' : ''; + scriptSourceContents.push(`(${operator}query[${queryExpression}])`); + } + }); + return scriptSourceContents.join(' '); +} + export function formikToAction(values) { const actions = values.actions; if (actions && actions.length > 0) { @@ -164,6 +210,17 @@ export function formikToTriggerUiMetadata(values, monitorUiMetadata) { _.set(bucketLevelTriggersUiMetadata, `${trigger.name}`, triggerMetadata); }); return bucketLevelTriggersUiMetadata; + case MONITOR_TYPE.DOC_LEVEL: + const docLevelTriggersUiMetadata = {}; + _.get(values, 'triggerDefinitions', []).forEach((trigger) => { + const triggerMetadata = _.get(trigger, 'triggerConditions', []).map((condition) => ({ + query: condition.query, + andOrCondition: condition.andOrCondition, + script: condition.script, + })); + docLevelTriggersUiMetadata[trigger.name] = triggerMetadata; + }); + return docLevelTriggersUiMetadata; } } diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js index 66c4b60dc..efc9877ce 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js @@ -29,11 +29,15 @@ export function triggerDefinitionsToFormik(triggers, monitor) { } export function triggerDefinitionToFormik(trigger, monitor) { - const isBucketLevelMonitor = - _.get(monitor, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL) === MONITOR_TYPE.BUCKET_LEVEL; - return isBucketLevelMonitor - ? bucketLevelTriggerToFormik(trigger, monitor) - : queryLevelTriggerToFormik(trigger, monitor); + const monitorType = _.get(monitor, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL); + switch (monitorType) { + case MONITOR_TYPE.BUCKET_LEVEL: + return bucketLevelTriggerToFormik(trigger, monitor); + case MONITOR_TYPE.DOC_LEVEL: + return documentLevelTriggerToFormik(trigger, monitor); + default: + return queryLevelTriggerToFormik(trigger, monitor); + } } export function queryLevelTriggerToFormik(trigger, monitor) { @@ -177,6 +181,30 @@ export function bucketLevelTriggerToFormik(trigger, monitor) { }; } +export function documentLevelTriggerToFormik(trigger, monitor) { + const { + id, + name, + severity, + condition: { script }, + actions, + minTimeBetweenExecutions, + rollingWindowSize, + } = trigger[TRIGGER_TYPE.DOC_LEVEL]; + const triggerUiMetadata = _.get(monitor, `ui_metadata.triggers[${name}]`); + return { + ..._.cloneDeep(FORMIK_INITIAL_TRIGGER_VALUES), + id, + name, + severity, + script, + actions, + minTimeBetweenExecutions, + rollingWindowSize, + triggerConditions: triggerUiMetadata, + }; +} + export function getBucketLevelTriggerActions(actions) { const executionPolicyPath = 'action_execution_policy.action_execution_scope'; return _.cloneDeep(actions).map((action) => { diff --git a/public/pages/CreateTrigger/containers/DefineBucketLevelTrigger/DefineBucketLevelTrigger.js b/public/pages/CreateTrigger/containers/DefineBucketLevelTrigger/DefineBucketLevelTrigger.js index e63ac45a6..671ecb3cf 100644 --- a/public/pages/CreateTrigger/containers/DefineBucketLevelTrigger/DefineBucketLevelTrigger.js +++ b/public/pages/CreateTrigger/containers/DefineBucketLevelTrigger/DefineBucketLevelTrigger.js @@ -9,7 +9,7 @@ import _ from 'lodash'; import { EuiAccordion, EuiButton, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import 'brace/mode/plain_text'; import { FormikFieldText, FormikSelect } from '../../../../components/FormControls'; -import { isInvalid, hasError } from '../../../../utils/validate'; +import { hasError, isInvalid } from '../../../../utils/validate'; import { SEARCH_TYPE } from '../../../../utils/constants'; import { FORMIK_INITIAL_TRIGGER_CONDITION_VALUES } from '../CreateTrigger/utils/constants'; import AddTriggerConditionButton from '../../components/AddTriggerConditionButton'; @@ -19,8 +19,8 @@ import { validateTriggerName } from '../DefineTrigger/utils/validation'; import WhereExpression from '../../../CreateMonitor/components/MonitorExpressions/expressions/WhereExpression'; import { FieldArray } from 'formik'; import ConfigureActions from '../ConfigureActions'; -import { SEVERITY_OPTIONS } from '../DefineTrigger/DefineTrigger'; import { inputLimitText } from '../../../../utils/helpers'; +import { DEFAULT_TRIGGER_NAME, SEVERITY_OPTIONS } from '../../utils/constants'; const defaultRowProps = { label: 'Trigger name', @@ -59,11 +59,9 @@ const propTypes = { isDarkMode: PropTypes.bool.isRequired, }; -const DEFAULT_TRIGGER_NAME = 'New trigger'; -const MAX_TRIGGER_CONDITIONS = 5; +const MAX_TRIGGER_CONDITIONS = 10; export const DEFAULT_METRIC_AGGREGATION = { value: '_count', text: 'Count of documents' }; -export const DEFAULT_AND_OR_CONDITION = 'AND'; export const TRIGGER_OPERATORS_MAP = { INCLUDE: 'include', diff --git a/public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DefineDocumentLevelTrigger.js b/public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DefineDocumentLevelTrigger.js new file mode 100644 index 000000000..93ab13ba9 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DefineDocumentLevelTrigger.js @@ -0,0 +1,313 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { FieldArray } from 'formik'; +import { + EuiAccordion, + EuiButton, + EuiButtonEmpty, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { FormikFieldText, FormikSelect } from '../../../../components/FormControls'; +import { hasError, isInvalid } from '../../../../utils/validate'; +import { SEARCH_TYPE } from '../../../../utils/constants'; +import { DEFAULT_TRIGGER_NAME, SEVERITY_OPTIONS } from '../../utils/constants'; +import { validateTriggerName } from '../DefineTrigger/utils/validation'; +import ConfigureActions from '../ConfigureActions'; +import TriggerQuery from '../../components/TriggerQuery'; +import { + FORMIK_INITIAL_TRIGGER_CONDITION_VALUES, + TRIGGER_TYPE, +} from '../CreateTrigger/utils/constants'; +import DocumentLevelTriggerExpression from './DocumentLevelTriggerExpression'; +import { backendErrorNotification, inputLimitText } from '../../../../utils/helpers'; +import monitorToFormik from '../../../CreateMonitor/containers/CreateMonitor/utils/monitorToFormik'; +import { buildRequest } from '../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; + +const MAX_TRIGGER_CONDITIONS = 5; // TODO DRAFT: Placeholder limit + +const defaultRowProps = { + label: 'Trigger name', + style: { paddingLeft: '10px' }, + isInvalid, + error: hasError, +}; + +const defaultInputProps = { isInvalid }; + +const selectFieldProps = { + validate: () => {}, +}; + +const selectRowProps = { + label: 'Severity level', + style: { paddingLeft: '10px', marginTop: '0px' }, + isInvalid, + error: hasError, +}; + +const selectInputProps = { + options: SEVERITY_OPTIONS, +}; + +const propTypes = { + context: PropTypes.object.isRequired, + executeResponse: PropTypes.object, + monitorValues: PropTypes.object.isRequired, + onRun: PropTypes.func.isRequired, + setFlyout: PropTypes.func.isRequired, + triggers: PropTypes.arrayOf(PropTypes.object).isRequired, + triggerValues: PropTypes.object.isRequired, + isDarkMode: PropTypes.bool.isRequired, +}; + +export const QUERY_IDENTIFIERS = { + ID: 'id=', + NAME: 'name=', + TAG: 'tag=', +}; + +class DefineDocumentLevelTrigger extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + onRunExecute = (triggers = []) => { + const { httpClient, monitor, notifications } = this.props; + const formikValues = monitorToFormik(monitor); + const searchType = formikValues.searchType; + const docLevelTriggers = triggers.map((trigger) => ({ [TRIGGER_TYPE.DOC_LEVEL]: trigger })); + const monitorToExecute = _.cloneDeep(monitor); + _.set(monitorToExecute, 'triggers', docLevelTriggers); + + switch (searchType) { + case SEARCH_TYPE.QUERY: + case SEARCH_TYPE.GRAPH: + const request = buildRequest(formikValues); + _.set(monitorToExecute, 'inputs[0]', request); + break; + default: + console.log(`Unsupported searchType found: ${JSON.stringify(searchType)}`, searchType); + } + + httpClient + .post('../api/alerting/monitors/_execute', { body: JSON.stringify(monitorToExecute) }) + .then((resp) => { + if (resp.ok) { + this.setState({ executeResponse: resp.resp }); + } else { + // TODO: need a notification system to show errors or banners at top + console.error('err:', resp); + backendErrorNotification(notifications, 'run', 'trigger', resp.resp); + } + }) + .catch((err) => { + console.log('err:', err); + }); + }; + + renderDocumentLevelTriggerGraph = ( + arrayHelpers, + fieldPath, + monitor, + monitorValues, + response, + triggerValues + ) => { + const queries = _.get(monitorValues, 'queries', []); + const tagSelectOptions = []; + const querySelectOptions = queries.map((query) => { + query.tags.forEach((tag) => { + const tagOption = { + label: tag, + value: { queryName: tag, operator: '==', expression: `${QUERY_IDENTIFIERS.TAG}${tag}` }, + }; + if (!_.includes(tagSelectOptions, tagOption)) tagSelectOptions.push(tagOption); + }); + return { + label: query.queryName, + value: { ...query, expression: `${QUERY_IDENTIFIERS.NAME}${query.queryName}` }, + }; + }); + + const triggerConditions = _.get(triggerValues, `${fieldPath}triggerConditions`, []); + if (_.isEmpty(triggerConditions)) { + arrayHelpers.push(_.cloneDeep(FORMIK_INITIAL_TRIGGER_CONDITION_VALUES)); + } + + return triggerConditions.map((triggerCondition, index) => ( +
+ +
+ )); + }; + + render() { + const { + edit, + triggerArrayHelpers, + context, + monitor, + monitorValues, + onRun, + setFlyout, + triggers, + triggerValues, + isDarkMode, + triggerIndex, + httpClient, + notifications, + } = this.props; + const executeResponse = _.get(this.state, 'executeResponse', this.props.executeResponse); + const fieldPath = triggerIndex !== undefined ? `triggerDefinitions[${triggerIndex}].` : ''; + const isGraph = _.get(monitorValues, 'searchType') === SEARCH_TYPE.GRAPH; + const response = _.get(executeResponse, 'input_results.results[0]'); + const error = _.get(executeResponse, 'error') || _.get(executeResponse, 'input_results.error'); + const triggerName = _.get(triggerValues, `${fieldPath}name`, DEFAULT_TRIGGER_NAME); + + const disableAddTriggerConditionButton = + _.get(triggerValues, `${fieldPath}triggerConditions`, []).length >= MAX_TRIGGER_CONDITIONS; + + const triggerContent = isGraph ? ( + + {(conditionsArrayHelpers) => ( +
+
+ +

Trigger conditions

+
+ + Triggers on documents that match the following conditions + +
+ + + + {this.renderDocumentLevelTriggerGraph( + conditionsArrayHelpers, + fieldPath, + monitor, + monitorValues, + response, + triggerValues + )} + + + + + conditionsArrayHelpers.push(_.cloneDeep(FORMIK_INITIAL_TRIGGER_CONDITION_VALUES)) + } + disabled={disableAddTriggerConditionButton} + size={'xs'} + > + + Add condition + + {inputLimitText( + _.get(triggerValues, `${fieldPath}triggerConditions`, []).length, + MAX_TRIGGER_CONDITIONS, + 'trigger condition', + 'trigger conditions', + { paddingLeft: '10px' } + )} +
+ )} +
+ ) : ( + + ); + + return ( + +

{_.isEmpty(triggerName) ? DEFAULT_TRIGGER_NAME : triggerName}

+ + } + initialIsOpen={edit ? false : triggerIndex === 0} + extraAction={ + { + triggerArrayHelpers.remove(triggerIndex); + }} + size={'s'} + > + Remove trigger + + } + style={{ paddingBottom: '15px', paddingTop: '10px' }} + > +
+ + + + + + {triggerContent} + + +
+ + {(arrayHelpers) => ( + + )} + +
+
+
+ ); + } +} + +DefineDocumentLevelTrigger.propTypes = propTypes; + +export default DefineDocumentLevelTrigger; diff --git a/public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DocumentLevelTriggerExpression.js b/public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DocumentLevelTriggerExpression.js new file mode 100644 index 000000000..19ad3f4c2 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DocumentLevelTriggerExpression.js @@ -0,0 +1,115 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import _ from 'lodash'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormikComboBox, FormikSelect } from '../../../../components/FormControls'; +import { AND_OR_CONDITION_OPTIONS } from '../../utils/constants'; + +class DocumentLevelTriggerExpression extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + render() { + const { + arrayHelpers, + formFieldName, + index, + querySelectOptions = [], + tagSelectOptions = [], + values, + } = this.props; + const isFirstCondition = index === 0; + if (index > 0) + values['andOrCondition'] = values.andOrCondition || AND_OR_CONDITION_OPTIONS[0].value; + return isFirstCondition ? ( + form.setFieldValue(field.name, e[0].value), + isClearable: false, + singleSelection: { asPlainText: true }, + options: [ + { label: 'Queries', options: querySelectOptions }, + { label: 'Tags', options: tagSelectOptions }, + ], + selectedOptions: + !_.isEmpty(values.query) && !_.isEmpty(values.query.queryName) + ? [ + { + value: values.query.queryName, + label: values.query.queryName, + query: values.query, + }, + ] + : undefined, + }} + /> + ) : ( + + + field.onChange(e), + options: AND_OR_CONDITION_OPTIONS, + }} + /> + + + + form.setFieldValue(field.name, e[0].value), + isClearable: false, + singleSelection: { asPlainText: true }, + options: [ + { label: 'Queries', options: querySelectOptions }, + { label: 'Tags', options: tagSelectOptions }, + ], + selectedOptions: + !_.isEmpty(values.query) && !_.isEmpty(values.query.queryName) + ? [ + { + value: values.query.queryName, + label: values.query.queryName, + query: values.query, + }, + ] + : undefined, + }} + /> + + + + arrayHelpers.remove(index)}> + Remove condition + + + + ); + } +} + +export default DocumentLevelTriggerExpression; diff --git a/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js b/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js index cf77a8d50..23fa59285 100644 --- a/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js +++ b/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js @@ -19,12 +19,13 @@ import { TRIGGER_TYPE } from '../CreateTrigger/utils/constants'; import { FieldArray } from 'formik'; import ConfigureActions from '../ConfigureActions'; import monitorToFormik from '../../../CreateMonitor/containers/CreateMonitor/utils/monitorToFormik'; -import { buildSearchRequest } from '../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; +import { buildRequest } from '../../../CreateMonitor/containers/DefineMonitor/utils/searchRequests'; import { backendErrorNotification } from '../../../../utils/helpers'; import { buildClusterMetricsRequest, canExecuteClusterMetricsMonitor, } from '../../../CreateMonitor/components/ClusterMetricsMonitor/utils/clusterMetricsMonitorHelpers'; +import { DEFAULT_TRIGGER_NAME, SEVERITY_OPTIONS } from '../../utils/constants'; const defaultRowProps = { label: 'Trigger name', @@ -33,6 +34,7 @@ const defaultRowProps = { isInvalid, error: hasError, }; + const defaultInputProps = { isInvalid }; const selectFieldProps = { @@ -47,14 +49,6 @@ const selectRowProps = { error: hasError, }; -export const SEVERITY_OPTIONS = [ - { value: '1', text: '1 (Highest)' }, - { value: '2', text: '2 (High)' }, - { value: '3', text: '3 (Medium)' }, - { value: '4', text: '4 (Low)' }, - { value: '5', text: '5 (Lowest)' }, -]; - const triggerOptions = [ { value: TRIGGER_TYPE.AD, text: 'Anomaly detector grade and confidence' }, { value: TRIGGER_TYPE.ALERT_TRIGGER, text: 'Extraction query response' }, @@ -75,8 +69,6 @@ const propTypes = { isDarkMode: PropTypes.bool.isRequired, }; -const DEFAULT_TRIGGER_NAME = 'New trigger'; - class DefineTrigger extends Component { constructor(props) { super(props); @@ -109,8 +101,8 @@ class DefineTrigger extends Component { switch (searchType) { case SEARCH_TYPE.QUERY: case SEARCH_TYPE.GRAPH: - const searchRequest = buildSearchRequest(formikValues); - _.set(monitorToExecute, 'inputs[0].search', searchRequest); + const searchRequest = buildRequest(formikValues); + _.set(monitorToExecute, 'inputs[0]', searchRequest); break; case SEARCH_TYPE.AD: break; diff --git a/public/pages/CreateTrigger/utils/constants.js b/public/pages/CreateTrigger/utils/constants.js index d0b5768ae..4bedc50e6 100644 --- a/public/pages/CreateTrigger/utils/constants.js +++ b/public/pages/CreateTrigger/utils/constants.js @@ -53,4 +53,25 @@ export const FORMIK_INITIAL_ACTION_VALUES = { }, }; +export const SEVERITY_OPTIONS = [ + { value: '1', text: '1 (Highest)' }, + { value: '2', text: '2 (High)' }, + { value: '3', text: '3 (Medium)' }, + { value: '4', text: '4 (Low)' }, + { value: '5', text: '5 (Lowest)' }, +]; + +export const THRESHOLD_ENUM_OPTIONS = [ + { value: 'ABOVE', text: 'IS ABOVE' }, + { value: 'BELOW', text: 'IS BELOW' }, + { value: 'EXACTLY', text: 'IS EXACTLY' }, +]; + +export const DEFAULT_AND_OR_CONDITION = 'AND'; +export const AND_OR_CONDITION_OPTIONS = [ + { value: 'AND', text: 'AND' }, + { value: 'OR', text: 'OR' }, +]; + +export const DEFAULT_TRIGGER_NAME = 'New trigger'; export const DEFAULT_ACTION_TYPE = 'slack'; diff --git a/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap b/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap index c3c08a463..6fce46663 100644 --- a/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap +++ b/public/pages/Dashboard/components/AcknowledgeAlertsModal/__snapshots__/AcknowledgeAlertsModal.test.js.snap @@ -39,6 +39,7 @@ exports[`AcknowledgeAlertsModal renders 1`] = ` "bucketValue": 1, "cronExpression": "0 */1 * * *", "daily": 0, + "description": "", "detectorId": "", "disabled": false, "fieldName": Array [], @@ -63,6 +64,7 @@ exports[`AcknowledgeAlertsModal renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "queries": Array [], "query": "{ \\"size\\": 0, \\"query\\": { diff --git a/public/pages/Dashboard/components/FindingsDashboard/FindingFlyout.js b/public/pages/Dashboard/components/FindingsDashboard/FindingFlyout.js new file mode 100644 index 000000000..331be736e --- /dev/null +++ b/public/pages/Dashboard/components/FindingsDashboard/FindingFlyout.js @@ -0,0 +1,146 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import { + EuiButtonEmpty, + EuiCodeBlock, + EuiFlexGrid, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiLink, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +export const NO_FINDING_DOC_ID_TEXT = 'No document ID'; + +export default class FindingFlyout extends Component { + constructor(props) { + super(props); + this.state = { + isFlyoutOpen: false, + }; + } + + componentDidMount() { + this.renderFlyout(); + } + + onClick = () => { + const { isFlyoutOpen } = this.state; + this.setState({ isFlyoutOpen: !isFlyoutOpen }); + }; + + closeFlyout = () => { + this.setState({ isFlyoutOpen: false }); + }; + + renderFlyout() { + const { + isAlertsFlyout = false, + document_list = [], + finding: { id: findingId = '', queries = [] }, + } = this.props; + const { id: docId = '', index = '', document = '' } = document_list[0]; + const documentDisplay = JSON.parse(document); + const queriesDisplay = queries.map((query, index) => { + return ( +

0 ? '10px' : undefined }}> + {`${query.name} (${query.query})`} +

+ ); + }); + + return ( + + + +

Document finding

+
+
+ + + + + + Document ID +

{docId}

+
+
+ + {/*TODO FIXME: ExecuteMonitor API currently only returns a list of query names/IDs and the relevant docIds */} + {!_.isEmpty(findingId) && ( + + + Finding ID +

{findingId}

+
+
+ )} + + + + Index +

{index}

+
+
+
+ + + + + Queries + {queriesDisplay} + + + + + + Document + + + {JSON.stringify(documentDisplay, null, 3)} + +
+ + + Close + +
+ ); + } + + render() { + const { document_list } = this.props; + const { isFlyoutOpen } = this.state; + return ( +
+ + {_.get(document_list, '0.id', NO_FINDING_DOC_ID_TEXT)} + + {isFlyoutOpen && this.renderFlyout()} +
+ ); + } +} diff --git a/public/pages/Dashboard/components/FindingsDashboard/QueriesPopover.js b/public/pages/Dashboard/components/FindingsDashboard/QueriesPopover.js new file mode 100644 index 000000000..ed2ad4f9b --- /dev/null +++ b/public/pages/Dashboard/components/FindingsDashboard/QueriesPopover.js @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; + +import { EuiLink, EuiPopover, EuiSpacer, EuiText } from '@elastic/eui'; + +export default function QueryPopover(queries) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onButtonClick = () => setIsPopoverOpen(!isPopoverOpen); + const closePopover = () => setIsPopoverOpen(false); + + const popoverContent = queries.queries.map((query, index) => { + return ( +
+ {index > 0 && } + + {query.name} +

{query.query}

+
+
+ ); + }); + + return ( + {`${queries.queries.length} Queries`}} + isOpen={isPopoverOpen} + closePopover={closePopover} + > + {popoverContent} + + ); +} diff --git a/public/pages/Dashboard/components/FindingsDashboard/utils.js b/public/pages/Dashboard/components/FindingsDashboard/utils.js new file mode 100644 index 000000000..7ec7a6478 --- /dev/null +++ b/public/pages/Dashboard/components/FindingsDashboard/utils.js @@ -0,0 +1,114 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import _ from 'lodash'; +import { renderTime } from '../../utils/tableUtils'; +import FindingFlyout from './FindingFlyout'; +import QueryPopover from './QueriesPopover'; + +export const TABLE_TAB_IDS = { + ALERTS: { id: 'alerts', name: 'Alerts' }, + FINDINGS: { id: 'findings', name: 'Document findings' }, +}; + +export const findingsColumnTypes = (isAlertsFlyout) => [ + { + field: 'document_list', + name: 'Document', + sortable: true, + truncateText: true, + render: (document_list, finding) => { + // TODO FIXME: ExecuteMonitor API currently only returns a list of query names/IDs and the relevant docIds. + // As a result, the preview dashboard cannot display document contents. + return _.isEmpty(document_list) ? ( + finding.related_doc_id + ) : ( + + ); + }, + }, + { + field: 'queries', + name: 'Query', + sortable: true, + truncateText: false, + render: (queries) => { + if (_.isEmpty(queries)) + console.log('Findings index contains an entry with 0 queries:', queries); + return queries.length > 1 ? ( + + ) : ( + `${queries[0].name} (${queries[0].query})` + ); + }, + }, + { + field: 'timestamp', + name: 'Time found', + sortable: true, + truncateText: false, + render: renderTime, + dataType: 'date', + }, +]; + +export const getFindingsForMonitor = (findings, monitorId) => { + const monitorFindings = []; + findings.map((finding) => { + const findingId = _.keys(finding)[0]; + const findingValues = _.get(finding, `${findingId}.finding`); + const findingMonitorId = findingValues.monitor_id; + if (!_.isEmpty(findingValues) && findingMonitorId === monitorId) + monitorFindings.push({ ...findingValues, document_list: finding[findingId].document_list }); + }); + return { findings: monitorFindings, totalFindings: monitorFindings.length }; +}; + +export const parseFindingsForPreview = (previewResponse, index, queries = []) => { + // TODO FIXME: ExecuteMonitor API currently only returns a list of query names/IDs and the relevant docIds. + // As a result, the preview dashboard cannot display document contents. + const timestamp = Date.now(); + const findings = []; + const docIdsToQueries = {}; + + _.keys(previewResponse).forEach((queryName) => { + _.get(previewResponse, queryName, []).forEach((id) => { + if (_.includes(_.keys(docIdsToQueries), id)) { + const query = _.find(queries, { queryName: queryName }); + docIdsToQueries[id].push({ name: queryName, query: query.query }); + } else { + const query = _.find(queries, { queryName: queryName }); + docIdsToQueries[id] = [{ name: queryName, query: query.query }]; + } + }); + }); + + _.keys(docIdsToQueries).forEach((docId) => { + const finding = { + index: index, + related_doc_id: docId, + queries: docIdsToQueries[docId], + timestamp: timestamp, + }; + findings.push(finding); + }); + return findings; +}; + +export const getPreviewResponseDocIds = (response) => { + const docIds = []; + _.keys(response).map((queryId) => { + const docIdsList = _.get(response, queryId, []); + docIdsList.forEach((docId) => { + if (!_.includes(docIds, docId)) docIds.push(docId); + }); + }); + return docIds; +}; diff --git a/public/pages/Dashboard/containers/FindingsDashboard.js b/public/pages/Dashboard/containers/FindingsDashboard.js new file mode 100644 index 000000000..d62d8593b --- /dev/null +++ b/public/pages/Dashboard/containers/FindingsDashboard.js @@ -0,0 +1,195 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import _ from 'lodash'; +import queryString from 'query-string'; +import { EuiBasicTable } from '@elastic/eui'; +import ContentPanel from '../../../components/ContentPanel'; +import { backendErrorNotification } from '../../../utils/helpers'; +import { DEFAULT_PAGE_SIZE_OPTIONS } from '../../Monitors/containers/Monitors/utils/constants'; +import { + DEFAULT_GET_FINDINGS_PARAMS, + GET_FINDINGS_SORT_FIELDS, +} from '../../../../server/services/FindingService'; +import { + findingsColumnTypes, + getFindingsForMonitor, + parseFindingsForPreview, +} from '../components/FindingsDashboard/utils'; + +export const GET_FINDINGS_PREVIEW_PARAMS = { + id: DEFAULT_GET_FINDINGS_PARAMS.id, + from: DEFAULT_GET_FINDINGS_PARAMS.from, + search: DEFAULT_GET_FINDINGS_PARAMS.search, + size: 10, + sortDirection: DEFAULT_GET_FINDINGS_PARAMS.sortDirection, + sortField: GET_FINDINGS_SORT_FIELDS.TIMESTAMP, +}; + +export default class FindingsDashboard extends Component { + constructor(props) { + super(props); + + const { isPreview = false } = props; + const { id, from, size, search, sortField, sortDirection } = isPreview + ? GET_FINDINGS_PREVIEW_PARAMS + : this.getURLQueryParams(); + + this.state = { + loadingFindings: true, + findings: [], + totalFindings: 0, + page: Math.floor(from / size), + id, + from, + size, + search, + sortField, + sortDirection, + }; + } + + componentDidMount() { + const { isPreview = false } = this.props; + if (isPreview) { + this.getPreviewFindingsDocuments(); + } else { + const { id, from, size, search, sortField, sortDirection } = this.state; + this.getFindings(id, from, size, search, sortDirection, sortField); + } + } + + componentDidUpdate(prevProps, prevState) { + const prevQuery = this.getQueryObjectFromState(prevState); + const currQuery = this.getQueryObjectFromState(this.state); + if (!_.isEqual(prevQuery, currQuery)) this.componentDidMount(); + } + + getURLQueryParams() { + const { location } = this.props; + const { + id = DEFAULT_GET_FINDINGS_PARAMS.id, + from = DEFAULT_GET_FINDINGS_PARAMS.from, + size = DEFAULT_GET_FINDINGS_PARAMS.size, + search = DEFAULT_GET_FINDINGS_PARAMS.search, + sortField = DEFAULT_GET_FINDINGS_PARAMS.sortField, + sortDirection = DEFAULT_GET_FINDINGS_PARAMS.sortDirection, + } = queryString.parse(location.search); + return { + id, + from: isNaN(parseInt(from, 10)) ? DEFAULT_GET_FINDINGS_PARAMS.from : parseInt(from, 10), + size: isNaN(parseInt(size, 10)) ? DEFAULT_GET_FINDINGS_PARAMS.size : parseInt(size, 10), + search, + sortField: _.includes(_.values(GET_FINDINGS_SORT_FIELDS), sortField) + ? sortField + : DEFAULT_GET_FINDINGS_PARAMS.sortField, + sortDirection, + }; + } + + getQueryObjectFromState({ id, from, size, search, sortField, sortDirection }) { + return { id, from, size, search, sortField, sortDirection }; + } + + getFindings = _.debounce( + (id, from, size, search, sortDirection, sortField) => { + this.setState({ loadingFindings: true }); + const params = { + id, + from, + size, + search, + sortDirection, + sortField, + }; + const queryParamsString = queryString.stringify(params); + location.search; + const { httpClient, history, monitorId, notifications } = this.props; + history.replace({ ...this.props.location, search: queryParamsString }); + + httpClient.get('../api/alerting/findings/_search', { query: params }).then((resp) => { + if (resp.ok) { + this.setState({ ...getFindingsForMonitor(resp.findings, monitorId) }); + } else { + console.log('Error getting findings:', resp); + backendErrorNotification(notifications, 'get', 'findings', resp.err); + } + }); + this.setState({ loadingFindings: false }); + }, + 500, + { leading: true } + ); + + getPreviewFindingsDocuments() { + this.setState({ loadingFindings: true }); + const { index, queries, previewResponse } = this.props; + this.setState({ + loadingFindings: false, + findings: parseFindingsForPreview(previewResponse, index, queries), + }); + } + + onTableChange = ({ page: tablePage = {}, sort = {} }) => { + const { index: page, size } = tablePage; + const { field: sortField, direction: sortDirection } = sort; + this.setState({ page, size, sortField, sortDirection }); + }; + + render() { + const { isAlertsFlyout = false, isPreview = false } = this.props; + const { + loadingFindings, + findings, + totalFindings, + size, + sortField, + sortDirection, + page, + } = this.state; + + const pagination = { + pageIndex: page, + pageSize: size, + totalItemCount: Math.min(size, totalFindings), + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + }; + + const sorting = { + sort: { + direction: sortDirection, + field: sortField, + }, + }; + + const getItemId = (item) => item.id; + + return ( + + + + ); + } +} diff --git a/public/pages/Dashboard/utils/helpers.js b/public/pages/Dashboard/utils/helpers.js index c3e1b9094..b8c260e4b 100644 --- a/public/pages/Dashboard/utils/helpers.js +++ b/public/pages/Dashboard/utils/helpers.js @@ -8,6 +8,7 @@ import { DEFAULT_GET_ALERTS_QUERY_PARAMS, EMPTY_ALERT_LIST, MAX_ALERT_COUNT } fr import { bucketColumns } from './tableUtils'; import { ALERT_STATE, DEFAULT_EMPTY_DATA } from '../../../utils/constants'; import queryString from 'query-string'; +import { GET_ALERTS_SORT_FILTERS } from '../../../../server/services/AlertService'; export function groupAlertsByTrigger(alerts) { if (_.isUndefined(alerts)) return _.cloneDeep(EMPTY_ALERT_LIST.alerts); @@ -142,7 +143,9 @@ export function getURLQueryParams(location) { from: isNaN(parseInt(from, 10)) ? DEFAULT_GET_ALERTS_QUERY_PARAMS.from : parseInt(from, 10), size: isNaN(parseInt(size, 10)) ? DEFAULT_GET_ALERTS_QUERY_PARAMS.size : parseInt(size, 10), search, - sortField, + sortField: _.includes(_.values(GET_ALERTS_SORT_FILTERS), sortField) + ? sortField + : DEFAULT_GET_ALERTS_QUERY_PARAMS.sortField, sortDirection, severityLevel, alertState, diff --git a/public/pages/Dashboard/utils/helpers.test.js b/public/pages/Dashboard/utils/helpers.test.js index 57c906561..08bdc2d7b 100644 --- a/public/pages/Dashboard/utils/helpers.test.js +++ b/public/pages/Dashboard/utils/helpers.test.js @@ -695,7 +695,8 @@ describe('Dashboard/utils/helpers', () => { }); }); describe('when perAlertView is true', () => { - const perAlertView = test('when defaultSize is undefined', () => { + const perAlertView = true; + test('when defaultSize is undefined', () => { const defaultSize = undefined; expect(getInitialSize(perAlertView, defaultSize)).toEqual( DEFAULT_GET_ALERTS_QUERY_PARAMS.size diff --git a/public/pages/Dashboard/utils/tableUtils.js b/public/pages/Dashboard/utils/tableUtils.js index d18a57fb6..76fcf8a76 100644 --- a/public/pages/Dashboard/utils/tableUtils.js +++ b/public/pages/Dashboard/utils/tableUtils.js @@ -10,7 +10,7 @@ import moment from 'moment'; import { ALERT_STATE, DEFAULT_EMPTY_DATA } from '../../../utils/constants'; import { PLUGIN_NAME } from '../../../../utils/constants'; -const renderTime = (time) => { +export const renderTime = (time) => { const momentTime = moment(time); if (time && momentTime.isValid()) return momentTime.format('MM/DD/YY h:mm a'); return DEFAULT_EMPTY_DATA; diff --git a/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js b/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js index aa0768a5e..3b6d574fc 100644 --- a/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js +++ b/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js @@ -34,8 +34,7 @@ function getMonitorType(searchType, monitor) { case SEARCH_TYPE.CLUSTER_METRICS: const uri = _.get(monitor, 'inputs.0.uri'); const apiType = getApiType(uri); - const apiTypeLabel = _.get(API_TYPES, `${apiType}.label`); - return apiTypeLabel; + return _.get(API_TYPES, `${apiType}.label`); default: return 'Extraction Query'; } @@ -49,6 +48,8 @@ function getMonitorLevelType(monitorType) { return 'Per bucket monitor'; case MONITOR_TYPE.CLUSTER_METRICS: return 'Per cluster metrics monitor'; + case MONITOR_TYPE.DOC_LEVEL: + return 'Per document monitor'; default: // TODO: May be valuable to implement a toast that displays in this case. console.log('Unexpected monitor type:', monitorType); diff --git a/public/pages/MonitorDetails/containers/MonitorDetails.js b/public/pages/MonitorDetails/containers/MonitorDetails.js index 52a62df0c..c999df5f7 100644 --- a/public/pages/MonitorDetails/containers/MonitorDetails.js +++ b/public/pages/MonitorDetails/containers/MonitorDetails.js @@ -4,7 +4,7 @@ */ import React, { Component, Fragment } from 'react'; -import { get } from 'lodash'; +import _ from 'lodash'; import queryString from 'query-string'; import { EuiButton, @@ -23,6 +23,8 @@ import { EuiModalHeaderTitle, EuiOverlayMask, EuiSpacer, + EuiTab, + EuiTabs, EuiTitle, } from '@elastic/eui'; import CreateMonitor from '../../CreateMonitor'; @@ -34,6 +36,7 @@ import { MONITOR_ACTIONS, MONITOR_GROUP_BY, MONITOR_INPUT_DETECTOR_ID, + MONITOR_TYPE, TRIGGER_ACTIONS, } from '../../../utils/constants'; import { migrateTriggerMetadata } from './utils/helpers'; @@ -41,6 +44,8 @@ import { backendErrorNotification } from '../../../utils/helpers'; import { getUnwrappedTriggers } from './Triggers/Triggers'; import { formikToMonitor } from '../../CreateMonitor/containers/CreateMonitor/utils/formikToMonitor'; import monitorToFormik from '../../CreateMonitor/containers/CreateMonitor/utils/monitorToFormik'; +import FindingsDashboard from '../../Dashboard/containers/FindingsDashboard'; +import { TABLE_TAB_IDS } from '../../Dashboard/components/FindingsDashboard/utils'; export default class MonitorDetails extends Component { constructor(props) { @@ -63,6 +68,7 @@ export default class MonitorDetails extends Component { }); }, isJsonModalOpen: false, + tabId: TABLE_TAB_IDS.ALERTS.id, }; } @@ -128,10 +134,11 @@ export default class MonitorDetails extends Component { loading: false, error: null, }); - const adId = get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); + const adId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); if (adId) { this.getDetector(adId); } + this.setState({ tabContent: this.renderAlertsTable() }); } else { // TODO: 404 handling this.props.history.push('/monitors'); @@ -237,6 +244,79 @@ export default class MonitorDetails extends Component { return { ...formikToMonitor(monitorValues), triggers }; }; + renderAlertsTable = () => { + const { monitor, editMonitor } = this.state; + const { + location, + match: { + params: { monitorId }, + }, + history, + httpClient, + notifications, + } = this.props; + const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); + const groupBy = _.get(monitor, MONITOR_GROUP_BY); + return ( + + ); + }; + + renderFindingsTable = () => { + const { + httpClient, + history, + location, + notifications, + match: { + params: { monitorId }, + }, + } = this.props; + return ( + + ); + }; + + renderTableTabs = () => { + const { tabId } = this.state; + const tabs = [ + { ...TABLE_TAB_IDS.ALERTS, content: this.renderAlertsTable() }, + { ...TABLE_TAB_IDS.FINDINGS, content: this.renderFindingsTable() }, + ]; + return tabs.map((tab, index) => ( + { + this.setState({ + tabId: tab.id, + tabContent: tab.content, + }); + }} + style={{ paddingTop: '0px' }} + > + {tab.name} + + )); + }; + render() { const { monitor, @@ -253,15 +333,14 @@ export default class MonitorDetails extends Component { match: { params: { monitorId }, }, - history, httpClient, notifications, isDarkMode, } = this.props; const { action } = queryString.parse(location.search); const updatingMonitor = action === MONITOR_ACTIONS.UPDATE_MONITOR; - const detectorId = get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); - const groupBy = get(monitor, MONITOR_GROUP_BY); + const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); + if (loading) { return ( @@ -283,6 +362,7 @@ export default class MonitorDetails extends Component { ); } + const displayTableTabs = monitor.monitor_type === MONITOR_TYPE.DOC_LEVEL; return (
{this.renderNoTriggersCallOut()} @@ -352,18 +432,16 @@ export default class MonitorDetails extends Component { />
- + + {displayTableTabs ? ( +
+ {this.renderTableTabs()} + {this.state.tabContent} +
+ ) : ( + this.renderAlertsTable() + )} + {isJsonModalOpen && ( diff --git a/public/pages/MonitorDetails/containers/Triggers/Triggers.js b/public/pages/MonitorDetails/containers/Triggers/Triggers.js index 37cdca1c9..8a12a5373 100644 --- a/public/pages/MonitorDetails/containers/Triggers/Triggers.js +++ b/public/pages/MonitorDetails/containers/Triggers/Triggers.js @@ -11,20 +11,23 @@ import _ from 'lodash'; import ContentPanel from '../../../../components/ContentPanel'; import { MONITOR_TYPE } from '../../../../utils/constants'; +import { TRIGGER_TYPE } from '../../../CreateTrigger/containers/CreateTrigger/utils/constants'; export const MAX_TRIGGERS = 10; // TODO: For now, unwrapping all the Triggers since it's conflicting with the table // retrieving the 'id' and causing it to behave strangely export function getUnwrappedTriggers(monitor) { - const isBucketLevelMonitor = monitor.monitor_type === MONITOR_TYPE.BUCKET_LEVEL; - return isBucketLevelMonitor - ? monitor.triggers.map((trigger) => { - return trigger.bucket_level_trigger; - }) - : monitor.triggers.map((trigger) => { - return trigger.query_level_trigger; - }); + return monitor.triggers.map((trigger) => { + switch (monitor.monitor_type) { + case MONITOR_TYPE.BUCKET_LEVEL: + return trigger[TRIGGER_TYPE.BUCKET_LEVEL]; + case MONITOR_TYPE.DOC_LEVEL: + return trigger[TRIGGER_TYPE.DOC_LEVEL]; + default: + return trigger[TRIGGER_TYPE.QUERY_LEVEL]; + } + }); } export default class Triggers extends Component { diff --git a/public/utils/constants.js b/public/utils/constants.js index 21900fae4..f8692cf4e 100644 --- a/public/utils/constants.js +++ b/public/utils/constants.js @@ -29,6 +29,7 @@ export const MONITOR_TYPE = { QUERY_LEVEL: 'query_level_monitor', BUCKET_LEVEL: 'bucket_level_monitor', CLUSTER_METRICS: 'cluster_metrics_monitor', + DOC_LEVEL: 'doc_level_monitor', }; export const DESTINATION_ACTIONS = { diff --git a/server/clusters/alerting/alertingPlugin.js b/server/clusters/alerting/alertingPlugin.js index 8004da57a..6a056fed4 100644 --- a/server/clusters/alerting/alertingPlugin.js +++ b/server/clusters/alerting/alertingPlugin.js @@ -4,6 +4,7 @@ */ import { + API_ROUTE_PREFIX, MONITOR_BASE_API, DESTINATION_BASE_API, EMAIL_ACCOUNT_BASE_API, @@ -16,6 +17,14 @@ export default function alertingPlugin(Client, config, components) { Client.prototype.alerting = components.clientAction.namespaceFactory(); const alerting = Client.prototype.alerting.prototype; + alerting.getFindings = ca({ + url: { + fmt: `${API_ROUTE_PREFIX}/findings/_search`, + }, + needBody: true, + method: 'GET', + }); + alerting.getMonitor = ca({ url: { fmt: `${MONITOR_BASE_API}/<%=monitorId%>`, diff --git a/server/plugin.js b/server/plugin.js index 647eb1114..b21b66ac5 100644 --- a/server/plugin.js +++ b/server/plugin.js @@ -11,8 +11,9 @@ import { OpensearchService, MonitorService, AnomalyDetectorService, + FindingService, } from './services'; -import { alerts, destinations, opensearch, monitors, detectors } from '../server/routes'; +import { alerts, destinations, opensearch, monitors, detectors, findings } from '../server/routes'; export class AlertingPlugin { constructor(initializerContext) { @@ -34,12 +35,14 @@ export class AlertingPlugin { const monitorService = new MonitorService(alertingESClient); const destinationsService = new DestinationsService(alertingESClient); const anomalyDetectorService = new AnomalyDetectorService(adESClient); + const findingService = new FindingService(alertingESClient); const services = { alertService, destinationsService, opensearchService, monitorService, anomalyDetectorService, + findingService, }; // Create router @@ -50,6 +53,7 @@ export class AlertingPlugin { opensearch(services, router); monitors(services, router); detectors(services, router); + findings(services, router); return {}; } diff --git a/server/routes/findings.js b/server/routes/findings.js new file mode 100644 index 000000000..fe8fa924c --- /dev/null +++ b/server/routes/findings.js @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; + +export default function (services, router) { + const { findingService } = services; + + router.get( + { + path: '/api/alerting/findings/_search', + validate: { + query: schema.object({ + id: schema.maybe(schema.string()), + from: schema.number(), + size: schema.number(), + search: schema.string(), + sortField: schema.string(), + sortDirection: schema.string(), + }), + }, + }, + findingService.getFindings + ); +} diff --git a/server/routes/index.js b/server/routes/index.js index aca1baea4..029623900 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -8,5 +8,6 @@ import destinations from './destinations'; import opensearch from './opensearch'; import monitors from './monitors'; import detectors from './anomalyDetector'; +import findings from './findings'; -export { alerts, destinations, opensearch, monitors, detectors }; +export { alerts, destinations, opensearch, monitors, detectors, findings }; diff --git a/server/services/AlertService.js b/server/services/AlertService.js index c52ae917a..cf2f1ba9a 100644 --- a/server/services/AlertService.js +++ b/server/services/AlertService.js @@ -3,6 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import _ from 'lodash'; + +export const GET_ALERTS_SORT_FILTERS = { + MONITOR_NAME: 'monitor_name', + TRIGGER_NAME: 'trigger_name', + START_TIME: 'start_time', + END_TIME: 'end_time', + ACKNOWLEDGE_TIME: 'acknowledged_time', +}; + export default class AlertService { constructor(esDriver) { this.esDriver = esDriver; @@ -14,7 +24,10 @@ export default class AlertService { size = 20, search = '', sortDirection = 'desc', - sortField = 'start_time', + // If the sortField parsed from the URL isn't a valid option for this API, use a default option. + sortField = _.includes(_.values(GET_ALERTS_SORT_FILTERS), req.query.sortField) + ? req.query.sortField + : GET_ALERTS_SORT_FILTERS.START_TIME, severityLevel = 'ALL', alertState = 'ALL', monitorIds = [], @@ -22,32 +35,32 @@ export default class AlertService { var params; switch (sortField) { - case 'monitor_name': + case GET_ALERTS_SORT_FILTERS.MONITOR_NAME: params = { sortString: `${sortField}.keyword`, sortOrder: sortDirection, }; break; - case 'trigger_name': + case GET_ALERTS_SORT_FILTERS.TRIGGER_NAME: params = { sortString: `${sortField}.keyword`, sortOrder: sortDirection, }; break; - case 'start_time': + case GET_ALERTS_SORT_FILTERS.START_TIME: params = { sortString: sortField, sortOrder: sortDirection, }; break; - case 'end_time': + case GET_ALERTS_SORT_FILTERS.END_TIME: params = { sortString: sortField, sortOrder: sortDirection, missing: sortDirection === 'asc' ? '_last' : '_first', }; break; - case 'acknowledged_time': + case GET_ALERTS_SORT_FILTERS.ACKNOWLEDGE_TIME: params = { sortString: sortField, sortOrder: sortDirection, diff --git a/server/services/FindingService.js b/server/services/FindingService.js new file mode 100644 index 000000000..177c3be29 --- /dev/null +++ b/server/services/FindingService.js @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from 'lodash'; + +// TODO DRAFT: Are these sortField options appropriate? +export const GET_FINDINGS_SORT_FIELDS = { + INDEX: 'index', + MONITOR_NAME: 'monitor_name', + TIMESTAMP: 'timestamp', +}; + +// TODO DRAFT: RestGetFindingsAction.kt in the backend references a `missing` field in params. +// Investigate if/how we should make use of that. +export const DEFAULT_GET_FINDINGS_PARAMS = { + // TODO DRAFT: Does providing a finding ID serve a particular function? Results with/without the ID seemed the same. + id: undefined, + from: 0, + search: '', + size: 20, + sortDirection: 'desc', + sortField: GET_FINDINGS_SORT_FIELDS.TIMESTAMP, +}; + +export default class FindingService { + constructor(esDriver) { + this.esDriver = esDriver; + } + + getFindings = async (context, req, res) => { + const { + id = DEFAULT_GET_FINDINGS_PARAMS.id, + from = DEFAULT_GET_FINDINGS_PARAMS.from, + size = DEFAULT_GET_FINDINGS_PARAMS.size, + search = DEFAULT_GET_FINDINGS_PARAMS.search, + sortDirection = DEFAULT_GET_FINDINGS_PARAMS.sortDirection, + // If the sortField parsed from the URL isn't a valid option for this API, use a default option. + sortField = _.includes(_.values(GET_FINDINGS_SORT_FIELDS), req.query.sortField) + ? req.query.sortField + : DEFAULT_GET_FINDINGS_PARAMS.sortField, + } = req.query; + + var params; + switch (sortField) { + case GET_FINDINGS_SORT_FIELDS.INDEX: + params = { + sortString: `${sortField}.keyword`, + sortOrder: sortDirection, + }; + break; + case GET_FINDINGS_SORT_FIELDS.MONITOR_NAME: + params = { + sortString: `${sortField}.keyword`, + sortOrder: sortDirection, + }; + break; + case GET_FINDINGS_SORT_FIELDS.TIMESTAMP: + params = { + sortString: sortField, + sortOrder: sortDirection, + }; + break; + } + + if (!_.isEmpty(id)) params.findingId = id; + params.startIndex = from; + params.size = size; + params.searchString = search; + if (search.trim()) params.searchString = `*${search.trim().split(' ').join('* *')}*`; + + const { callAsCurrentUser } = this.esDriver.asScoped(req); + try { + const resp = await callAsCurrentUser('alerting.getFindings', params); + const findings = resp.findings.map((result) => ({ [result.finding.id]: { ...result } })); + const totalFindings = resp.totalFindings; + return res.ok({ + body: { + ok: true, + findings, + totalFindings, + }, + }); + } catch (err) { + console.log(err.message); + return res.ok({ + body: { + ok: false, + err: err.message, + }, + }); + } + }; +} diff --git a/server/services/index.js b/server/services/index.js index ce75d617f..6e3da1d6e 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -8,6 +8,7 @@ import DestinationsService from './DestinationsService'; import OpensearchService from './OpensearchService'; import MonitorService from './MonitorService'; import AnomalyDetectorService from './AnomalyDetectorService'; +import FindingService from './FindingService'; export { AlertService, @@ -15,4 +16,5 @@ export { OpensearchService, MonitorService, AnomalyDetectorService, + FindingService, };