diff --git a/cypress/fixtures/sample_composite_level_monitor.json b/cypress/fixtures/sample_composite_level_monitor.json new file mode 100644 index 000000000..9b1f67bf4 --- /dev/null +++ b/cypress/fixtures/sample_composite_level_monitor.json @@ -0,0 +1,274 @@ +{ + "sample_composite_monitor": { + "type": "workflow", + "schema_version": 0, + "name": "sampleComponentLevelMonitor", + "workflow_type": "composite", + "enabled": true, + "enabled_time": 1686908176848, + "schedule": { + "period": { + "interval": 1, + "unit": "MINUTES" + } + }, + "inputs": [ + { + "composite_input": { + "sequence": { + "delegates": [ + { + "order": 1, + "monitor_id": "qdYBw4gB2qeAWe54jQyZ" + }, + { + "order": 2, + "monitor_id": "rtYBw4gB2qeAWe54wAx5" + } + ] + } + } + } + ], + "triggers": [ + { + "chained_alert_trigger": { + "id": "pNaQw4gB2qeAWe54Fg2U", + "name": "sample_trigger", + "severity": "1", + "condition": { + "script": { + "source": "(monitor[id=qdYBw4gB2qeAWe54jQyZ]) && (monitor[id=rtYBw4gB2qeAWe54wAx5])", + "lang": "painless" + } + }, + "actions": [ + { + "id": "pdaQw4gB2qeAWe54Fg2U", + "name": "sample_channel", + "destination_id": "6dYFw4gB2qeAWe54NgyL", + "message_template": { + "source": "Monitor {{ctx.monitor.name}} just entered alert status. Please investigate the issue.\n - Trigger: {{ctx.trigger.name}}\n - Severity: {{ctx.trigger.severity}}\n - Period start: {{ctx.periodStart}}\n - Period end: {{ctx.periodEnd}}", + "lang": "mustache" + }, + "throttle_enabled": false, + "subject_template": { + "source": "Monitor {{ctx.monitor.name}} triggered an alert {{ctx.trigger.name}}", + "lang": "mustache" + } + } + ] + } + } + ], + "last_update_time": 1686908180116, + "owner": "alerting", + "monitor_type": "composite" + }, + "sample_composite_index": { + "mappings": { + "properties": { + "audit_category": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "audit_node_host_name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "audit_node_id": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + }, + "sample_composite_associated_monitor_1": { + "name": "monitorOne", + "type": "monitor", + "monitor_type": "doc_level_monitor", + "enabled": false, + "schedule": { + "period": { + "unit": "MINUTES", + "interval": 1 + } + }, + "inputs": [ + { + "doc_level_input": { + "description": "", + "indices": ["sample_index_1"], + "queries": [ + { + "id": "monitor_1_query_1", + "name": "monitor_1_query_1", + "query": "NOT (audit_category:\"sample_text\")", + "tags": [] + }, + { + "id": "monitor_1_query_2", + "name": "monitor_1_query_2", + "query": "NOT (audit_node_host_name:\"sample_text\")", + "tags": [] + }, + { + "id": "monitor_1_query_3", + "name": "monitor_1_query_3", + "query": "NOT (audit_node_id:\"sample_text\")", + "tags": [] + } + ] + } + } + ], + "triggers": [ + { + "document_level_trigger": { + "id": "sample_trigger_id_1", + "name": "monitor_1_query_2", + "severity": "1", + "condition": { + "script": { + "source": "query[name=monitor_1_query_1] || query[name=monitor_1_query_2] && query[name=monitor_1_query_3]", + "lang": "painless" + } + }, + "actions": [] + } + } + ] + }, + "sample_composite_associated_monitor_2": { + "name": "monitorTwo", + "type": "monitor", + "monitor_type": "doc_level_monitor", + "enabled": false, + "schedule": { + "period": { + "unit": "MINUTES", + "interval": 1 + } + }, + "inputs": [ + { + "doc_level_input": { + "description": "", + "indices": ["sample_index_2"], + "queries": [ + { + "id": "monitor_2_query_1", + "name": "monitor_2_query_1", + "query": "NOT (audit_category:\"sample_text\")", + "tags": [] + }, + { + "id": "monitor_2_query_2", + "name": "monitor_2_query_2", + "query": "NOT (audit_node_host_name:\"sample_text\")", + "tags": [] + }, + { + "id": "monitor_2_query_3", + "name": "monitor_2_query_3", + "query": "NOT (audit_node_id:\"sample_text\")", + "tags": [] + } + ] + } + } + ], + "triggers": [ + { + "document_level_trigger": { + "id": "sample_trigger_2", + "name": "monitor_2_query_2", + "severity": "1", + "condition": { + "script": { + "source": "query[name=monitor_2_query_1] || query[name=monitor_2_query_2] && query[name=monitor_2_query_3]", + "lang": "painless" + } + }, + "actions": [] + } + } + ] + }, + "sample_composite_associated_monitor_3": { + "name": "monitorThree", + "type": "monitor", + "monitor_type": "doc_level_monitor", + "enabled": false, + "schedule": { + "period": { + "unit": "MINUTES", + "interval": 1 + } + }, + "inputs": [ + { + "doc_level_input": { + "description": "", + "indices": ["sample_index_2"], + "queries": [ + { + "id": "monitor_2_query_1", + "name": "monitor_2_query_1", + "query": "NOT (audit_category:\"sample_text\")", + "tags": [] + }, + { + "id": "monitor_2_query_2", + "name": "monitor_2_query_2", + "query": "NOT (audit_node_host_name:\"sample_text\")", + "tags": [] + }, + { + "id": "monitor_2_query_3", + "name": "monitor_2_query_3", + "query": "NOT (audit_node_id:\"sample_text\")", + "tags": [] + } + ] + } + } + ], + "triggers": [ + { + "document_level_trigger": { + "id": "sample_trigger_2", + "name": "monitor_2_query_2", + "severity": "1", + "condition": { + "script": { + "source": "query[name=monitor_2_query_1] || query[name=monitor_2_query_2] && query[name=monitor_2_query_3]", + "lang": "painless" + } + }, + "actions": [] + } + } + ] + }, + "sample_composite_associated_index_document": { + "audit_category": "FAILED_LOGIN", + "audit_node_host_name": "127.0.0.1", + "audit_node_id": "sample_node_id" + } +} diff --git a/cypress/integration/acknowledge_alerts_modal_spec.js b/cypress/integration/acknowledge_alerts_modal_spec.js index daf439b05..16e68c539 100644 --- a/cypress/integration/acknowledge_alerts_modal_spec.js +++ b/cypress/integration/acknowledge_alerts_modal_spec.js @@ -18,6 +18,7 @@ const TWENTY_SECONDS = 20000; describe('AcknowledgeAlertsModal', () => { before(() => { // Delete any existing monitors + cy.deleteAllAlerts(); cy.deleteAllMonitors(); // Load sample data diff --git a/cypress/integration/composite_level_monitor_spec.js b/cypress/integration/composite_level_monitor_spec.js new file mode 100644 index 000000000..84a8a45b1 --- /dev/null +++ b/cypress/integration/composite_level_monitor_spec.js @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { API, PLUGIN_NAME } from '../support/constants'; +import sampleCompositeJson from '../fixtures/sample_composite_level_monitor.json'; +import * as _ from 'lodash'; + +const sample_index_1 = 'sample_index_1'; +const sample_index_2 = 'sample_index_2'; +const SAMPLE_VISUAL_EDITOR_MONITOR = 'sample_visual_editor_composite_level_monitor'; + +const clearAll = () => { + cy.deleteIndexByName(sample_index_1); + cy.deleteIndexByName(sample_index_2); + + cy.deleteAllAlerts(); + cy.deleteAllMonitors(); +}; + +describe('CompositeLevelMonitor', () => { + before(() => { + clearAll(); + + // Create indices + cy.createIndexByName(sample_index_1, sampleCompositeJson.sample_composite_index); + cy.createIndexByName(sample_index_2, sampleCompositeJson.sample_composite_index); + + // Create associated monitors + cy.createMonitor(sampleCompositeJson.sample_composite_associated_monitor_1); + cy.createMonitor(sampleCompositeJson.sample_composite_associated_monitor_2); + cy.createMonitor(sampleCompositeJson.sample_composite_associated_monitor_3); + }); + + beforeEach(() => { + // Set welcome screen tracking to false + localStorage.setItem('home:welcome:show', 'false'); + }); + + describe('can be created', () => { + beforeEach(() => { + // Visit Alerting OpenSearch Dashboards + cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/monitors`); + + // Common text to wait for to confirm page loaded, give up to 20 seconds for initial load + cy.contains('Create monitor', { timeout: 20000 }); + + // Go to create monitor page + cy.contains('Create monitor').click({ force: true }); + + // Select the Composite-Level Monitor type + cy.get('[data-test-subj="compositeLevelMonitorRadioCard"]').click({ force: true }); + }); + + it('by visual editor', () => { + // Select visual editor for method of definition + cy.get('[data-test-subj="visualEditorRadioCard"]').click({ force: true }); + + // Wait for input to load and then type in the monitor name + cy.get('input[name="name"]').type(SAMPLE_VISUAL_EDITOR_MONITOR); + + // Select associated monitors + cy.get('[data-test-subj="monitors_list_0"]') + .type('monitorOne', { delay: 50 }) + .type('{enter}'); + cy.get('[data-test-subj="monitors_list_1"]') + .type('monitorTwo', { delay: 50 }) + .type('{enter}'); + + cy.get('button').contains('Add trigger').click({ force: true }); + + // Type trigger name + cy.get('[data-test-subj="composite-trigger-name"]') + .type('{selectall}') + .type('{backspace}') + .type('Composite trigger'); + + cy.intercept('api/alerting/workflows').as('createMonitorRequest'); + cy.intercept(`api/alerting/monitors?*`).as('getMonitorsRequest'); + cy.get('button').contains('Create').click({ force: true }); + + // Wait for monitor to be created + cy.wait('@createMonitorRequest').then((interceptor) => { + const monitorId = interceptor.response.body.resp._id; + + cy.contains('Loading monitors'); + cy.wait('@getMonitorsRequest').then((interceptor) => { + const monitors = interceptor.response.body.monitors; + const monitor1 = monitors.filter((monitor) => monitor.name === 'monitor_1'); + const monitor2 = monitors.filter((monitor) => monitor.name === 'monitor_2'); + + // Let monitor's table render the rows before querying + cy.wait(1000).then(() => { + cy.get('table tbody td').contains(SAMPLE_VISUAL_EDITOR_MONITOR); + + // Load sample data + cy.insertDocumentToIndex( + sample_index_1, + undefined, + sampleCompositeJson.sample_composite_associated_index_document + ); + cy.insertDocumentToIndex( + sample_index_2, + undefined, + sampleCompositeJson.sample_composite_associated_index_document + ); + + cy.wait(1000).then(() => { + cy.executeCompositeMonitor(monitorId); + monitor1[0] && cy.executeMonitor(monitor1[0].id); + monitor2[0] && cy.executeMonitor(monitor2[0].id); + + cy.get('[role="tab"]').contains('Alerts').click({ force: true }); + cy.get('table tbody td').contains('Composite trigger'); + }); + }); + }); + }); + }); + }); + + describe('can be edited', () => { + beforeEach(() => { + const body = { + size: 200, + query: { + match_all: {}, + }, + }; + cy.request({ + method: 'GET', + url: `${Cypress.env('opensearch')}${API.MONITOR_BASE}/_search`, + failOnStatusCode: false, // In case there is no alerting config index in cluster, where the status code is 404 + body, + }).then((response) => { + if (response.status === 200) { + const monitors = response.body.hits.hits; + const createdMonitor = _.find( + monitors, + (monitor) => monitor._source.name === SAMPLE_VISUAL_EDITOR_MONITOR + ); + if (createdMonitor) { + cy.visit( + `${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/monitors/${ + createdMonitor._id + }?action=update-monitor&type=workflow` + ); + } else { + cy.log('Failed to get created monitor ', SAMPLE_VISUAL_EDITOR_MONITOR); + throw new Error(`Failed to get created monitor ${SAMPLE_VISUAL_EDITOR_MONITOR}`); + } + } else { + cy.log('Failed to get all monitors.', response); + } + }); + }); + }); + + after(() => clearAll()); +}); diff --git a/cypress/integration/query_level_monitor_spec.js b/cypress/integration/query_level_monitor_spec.js index 05404f696..8995c29d5 100644 --- a/cypress/integration/query_level_monitor_spec.js +++ b/cypress/integration/query_level_monitor_spec.js @@ -244,6 +244,8 @@ describe('Query-Level Monitors', () => { // Click the Delete button cy.contains('Delete').click({ force: true }); + cy.wait(1000); + cy.get('[data-test-subj="confirmModalConfirmButton"]').click({ force: true }); // Confirm we can see an empty monitor list cy.contains('There are no existing monitors'); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index b3d4b1abd..e05711de0 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -85,6 +85,27 @@ Cypress.Commands.add('createAndExecuteMonitor', (monitorJSON) => { ); }); +Cypress.Commands.add('executeMonitor', (monitorID) => { + cy.request('POST', `${Cypress.env('opensearch')}${API.MONITOR_BASE}/${monitorID}/_execute`); +}); + +Cypress.Commands.add('executeCompositeMonitor', (monitorID) => { + cy.request('POST', `${Cypress.env('opensearch')}${API.WORKFLOW_BASE}/${monitorID}/_execute`); +}); + +Cypress.Commands.add('deleteAllAlerts', () => { + cy.request({ + method: 'POST', + url: `${Cypress.env('opensearch')}/.opendistro-alerting-alert*/_delete_by_query`, + body: { + query: { + match_all: {}, + }, + }, + failOnStatusCode: false, + }); +}); + Cypress.Commands.add('deleteMonitorByName', (monitorName) => { const body = { query: { @@ -110,9 +131,7 @@ Cypress.Commands.add('deleteAllMonitors', () => { const body = { size: 200, query: { - exists: { - field: 'monitor', - }, + match_all: {}, }, }; cy.request({ @@ -122,11 +141,19 @@ Cypress.Commands.add('deleteAllMonitors', () => { body, }).then((response) => { if (response.status === 200) { - for (let i = 0; i < response.body.hits.total.value; i++) { - cy.request( - 'DELETE', - `${Cypress.env('opensearch')}${API.MONITOR_BASE}/${response.body.hits.hits[i]._id}` - ); + const monitors = response.body.hits.hits.sort((monitor) => + monitor._source.type === 'workflow' ? -1 : 1 + ); + for (let i = 0; i < monitors.length; i++) { + if (monitors[i]._id) { + cy.request({ + method: 'DELETE', + url: `${Cypress.env('opensearch')}${ + monitors[i]._source.type === 'workflow' ? API.WORKFLOW_BASE : API.MONITOR_BASE + }/${monitors[i]._id}`, + failOnStatusCode: false, + }); + } } } else { cy.log('Failed to get all monitors.', response); @@ -134,12 +161,16 @@ Cypress.Commands.add('deleteAllMonitors', () => { }); }); -Cypress.Commands.add('createIndexByName', (indexName) => { - cy.request('PUT', `${Cypress.env('opensearch')}/${indexName}`); +Cypress.Commands.add('createIndexByName', (indexName, body = {}) => { + cy.request('PUT', `${Cypress.env('opensearch')}/${indexName}`, body); }); Cypress.Commands.add('deleteIndexByName', (indexName) => { - cy.request('DELETE', `${Cypress.env('opensearch')}/${indexName}`); + cy.request({ + method: 'DELETE', + url: `${Cypress.env('opensearch')}/${indexName}`, + failOnStatusCode: false, + }); }); Cypress.Commands.add('insertDocumentToIndex', (indexName, documentId, documentBody) => { diff --git a/cypress/support/constants.js b/cypress/support/constants.js index 66865cff7..6fb34cffc 100644 --- a/cypress/support/constants.js +++ b/cypress/support/constants.js @@ -13,6 +13,7 @@ export const INDEX = { export const API = { MONITOR_BASE: `${API_ROUTE_PREFIX}/monitors`, + WORKFLOW_BASE: `${API_ROUTE_PREFIX}/workflows`, DESTINATION_BASE: `${API_ROUTE_PREFIX}/destinations`, }; diff --git a/package.json b/package.json index bb8e51af3..e41f4b65d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "lint": "../../node_modules/.bin/eslint '**/*.js' -c .eslintrc --ignore-path .gitignore", "test:jest:windows": "SET TZ=UTC ../../node_modules/.bin/jest --config ./test/jest.config.js", "test:jest": "TZ=UTC ../../node_modules/.bin/jest --config ./test/jest.config.js", + "test:jest:update-snapshots": "yarn run test:jest -u", + "cypress:run:browser": "cypress open", + "cypress:run:ci": "cypress run", "build": "yarn plugin-helpers build", "plugin-helpers": "node ../../scripts/plugin_helpers", "postbuild": "echo Renaming build artifact to [$npm_package_config_id-$npm_package_version.zip] && mv build/$npm_package_config_id*.zip build/$npm_package_config_id-$npm_package_version.zip" diff --git a/public/app.js b/public/app.js index 3f0049057..dfc85f6c3 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,7 @@ import { HashRouter as Router, Route } from 'react-router-dom'; import 'react-vis/dist/style.css'; // TODO: review the CSS style and migrate the necessary style to SASS, as Less is not supported in OpenSearch Dashboards "new platform" anymore // import './less/main.less'; +import './app.scss'; import Main from './pages/Main'; import { CoreContext } from './utils/CoreContext'; import { ServicesContext, NotificationService } from './services'; diff --git a/public/app.scss b/public/app.scss new file mode 100644 index 000000000..78fa72311 --- /dev/null +++ b/public/app.scss @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +@import "./pages/MonitorDetails/components/MonitorOverview/MonitorOverview.scss"; \ No newline at end of file diff --git a/public/components/Breadcrumbs/Breadcrumbs.js b/public/components/Breadcrumbs/Breadcrumbs.js index 3ba41a8ff..4bc968b40 100644 --- a/public/components/Breadcrumbs/Breadcrumbs.js +++ b/public/components/Breadcrumbs/Breadcrumbs.js @@ -106,7 +106,7 @@ export async function getBreadcrumb(route, routeState, httpClient) { // This condition is true for any auto generated 20 character long, // URL-safe, base64-encoded document ID by opensearch if (RegExp(/^[0-9a-z_-]{20}$/i).test(base)) { - const { action } = queryString.parse(`?${queryParams}`); + const { action, type, monitorType } = queryString.parse(`?${queryParams}`); switch (action) { case DESTINATION_ACTIONS.UPDATE_DESTINATION: const destinationName = _.get(routeState, 'destinationToEdit.name', base); @@ -119,7 +119,9 @@ export async function getBreadcrumb(route, routeState, httpClient) { // TODO::Everything else is considered as monitor, we should break this. let monitorName = base; try { - const response = await httpClient.get(`../api/alerting/monitors/${base}`); + const searchPool = + type === 'workflow' || monitorType === 'composite' ? 'workflows' : 'monitors'; + const response = await httpClient.get(`../api/alerting/${searchPool}/${base}`); if (response.ok) { monitorName = response.resp.name; } diff --git a/public/components/DeleteModal/DeleteMonitorModal.tsx b/public/components/DeleteModal/DeleteMonitorModal.tsx new file mode 100644 index 000000000..b14f00ee1 --- /dev/null +++ b/public/components/DeleteModal/DeleteMonitorModal.tsx @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component, useEffect, useState } from 'react'; +import { + EuiConfirmModal, + EuiLink, + EuiOverlayMask +} from '@elastic/eui'; +import { PLUGIN_NAME } from '../../../utils/constants'; + +interface DeleteModalProps { + monitors: any[]; + httpClient?: any; + onClickDelete: () => void; + closeDeleteModal: () => void; +} + +export const DEFAULT_DELETION_TEXT = 'delete'; + +export const DeleteMonitorModal = ({ + monitors, + httpClient, + closeDeleteModal, + onClickDelete + }: DeleteModalProps) => { + const [associatedWorkflows, setAssociatedWorkflows] = useState(undefined); + const monitorNames = monitors.map(monitor => monitor.name); + let warningHeading = `Delete monitor ${monitorNames[0]}?`; + let warningBody: React.ReactNode = 'This action cannot be undone.'; + let allowDelete = true; + + if (monitors.length === 1 && monitors[0].associatedCompositeMonitorCnt > 0) { + if (monitors[0].associated_workflows) { + setAssociatedWorkflows(monitors[0].associated_workflows); + } + else { + httpClient?.get(`../api/alerting/monitors/${monitors[0].id}`) + .then((res: any) => { + setAssociatedWorkflows(res.resp.associated_workflows); + }) + .catch((err :any) => { + console.error('err', err); + }); + } + + warningHeading = `Unable to delete ${monitorNames[0]}`; + warningBody = ( + <> + {`The monitor ${monitorNames[0]} is currently being used as a delegate monitor for composite monitors. Unlink from the following composite monitors before deleting this monitor:`} + { associatedWorkflows ? + + : null + } + + ) + allowDelete = false; + } + else if (monitorNames.length > 1) { + warningHeading = `Delete ${monitorNames.length} monitors?`; + warningBody = ( + <> + {`The following monitors will be permanently deleted. ${warningBody}`} + + + ) + } + + return ( + + { + if (allowDelete) { + onClickDelete(); + } + closeDeleteModal(); + }} + cancelButtonText={allowDelete ? 'Cancel' : undefined} + confirmButtonText={allowDelete ? 'Delete' : 'Close'} + buttonColor={allowDelete ? 'danger' : 'primary'} + defaultFocusedButton="confirm" + > + {warningBody} + + + ); +} diff --git a/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/CreateNew/__snapshots__/CreateNew.test.js.snap b/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/CreateNew/__snapshots__/CreateNew.test.js.snap deleted file mode 100644 index 9488e4705..000000000 --- a/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/CreateNew/__snapshots__/CreateNew.test.js.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CreateNew renders 1`] = ` -
- -

- - - Learn more - - -

-
- -
- -

- -

-
- -
-
- - -
-
-`; diff --git a/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap b/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap index 48c907c9f..4d1dfa1b6 100644 --- a/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap +++ b/public/components/FeatureAnywhereContextMenu/AddAlertingMonitor/__snapshots__/AddAlertingMonitor.test.js.snap @@ -10,6 +10,13 @@ exports[`AddAlertingMonitor renders 1`] = ` "adResultIndex": undefined, "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -40,6 +47,7 @@ exports[`AddAlertingMonitor renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, diff --git a/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js b/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js index a283e0cbd..7c902d97e 100644 --- a/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js +++ b/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js @@ -17,6 +17,8 @@ import { EuiTab, EuiTabs, EuiText, + EuiToolTip, + EuiButtonIcon, } from '@elastic/eui'; import { getTime } from '../../../../pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats'; import { PLUGIN_NAME } from '../../../../../utils/constants'; @@ -60,7 +62,10 @@ export default class AlertsDashboardFlyoutComponent extends Component { super(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); + let monitorType = _.get(monitor, 'monitor_type', undefined); + if (!monitorType) { + monitorType = _.get(monitor, 'workflow_type', MONITOR_TYPE.QUERY_LEVEL); + } const { alertState, from, search, severityLevel, size, sortDirection, sortField } = getURLQueryParams(location); @@ -160,8 +165,16 @@ export default class AlertsDashboardFlyoutComponent extends Component { getAlerts = async () => { this.setState({ loading: true, tabContent: undefined }); - const { from, search, sortField, sortDirection, severityLevel, alertState, monitorIds } = - this.state; + const { + from, + search, + sortField, + sortDirection, + severityLevel, + alertState, + monitorIds, + monitorType, + } = this.state; const { httpClient, history, notifications, triggerID } = this.props; @@ -174,6 +187,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { severityLevel, alertState, monitorIds, + monitorType, }; const queryParamsString = queryString.stringify(params); @@ -282,6 +296,8 @@ export default class AlertsDashboardFlyoutComponent extends Component { return TRIGGER_TYPE.BUCKET_LEVEL; case MONITOR_TYPE.DOC_LEVEL: return TRIGGER_TYPE.DOC_LEVEL; + case MONITOR_TYPE.COMPOSITE_LEVEL: + return TRIGGER_TYPE.COMPOSITE_LEVEL; default: return TRIGGER_TYPE.QUERY_LEVEL; } @@ -334,6 +350,29 @@ export default class AlertsDashboardFlyoutComponent extends Component { getAlertsFindingColumn(httpClient, history, location, notifications) ); break; + case MONITOR_TYPE.COMPOSITE_LEVEL: + columns = _.cloneDeep(queryColumns); + columns.push({ + name: 'Actions', + sortable: false, + actions: [ + { + render: (alert) => ( + + { + this.props.openChainedAlertsFlyout?.(alert); + }} + /> + + ), + }, + ], + }); + break; default: columns = queryColumns; break; @@ -421,6 +460,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { sorting={sorting} isSelectable={selectable} selection={selection} + hasActions={true} onChange={this.onTableChange} noItemsMessage={loading ? 'Loading alerts...' : 'No alerts.'} data-test-subj={`alertsDashboardFlyout_table_${trigger_name}`} 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 097ed241d..a0549545e 100644 --- a/public/components/Flyout/flyouts/components/__snapshots__/AlertsDashboardFlyoutComponent.test.js.snap +++ b/public/components/Flyout/flyouts/components/__snapshots__/AlertsDashboardFlyoutComponent.test.js.snap @@ -228,6 +228,7 @@ exports[`AlertsDashboardFlyoutComponent renders 1`] = ` ] } data-test-subj="alertsDashboardFlyout_table_undefined" + hasActions={true} isSelectable={true} itemId={[Function]} items={Array []} diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/AssociateMonitors.js b/public/pages/CreateMonitor/components/AssociateMonitors/AssociateMonitors.js new file mode 100644 index 000000000..2981d0afd --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/AssociateMonitors.js @@ -0,0 +1,75 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import MonitorsList from './components/MonitorsList'; +import MonitorsEditor from './components/MonitorsEditor'; +import { monitorTypesForComposition } from '../../../../utils/constants'; + +export const getMonitors = async (httpClient) => { + const response = await httpClient.get('../api/alerting/monitors', { + query: { + from: 0, + size: 1000, + search: '', + sortField: 'name', + sortDirection: 'desc', + state: 'all', + }, + }); + + if (response.ok) { + const { monitors } = response; + return monitors + .filter( + (monitor) => + monitor.monitor?.type === 'monitor' && + monitorTypesForComposition.has(monitor.monitor?.monitor_type) + ) + .map((monitor) => ({ monitor_id: monitor.id, monitor_name: monitor.name })); + } else { + console.log('error getting monitors:', response); + return []; + } +}; + +const AssociateMonitors = ({ isDarkMode, values, httpClient, errors }) => { + const [graphUi, setGraphUi] = useState(false); + + useEffect(() => { + setGraphUi(values.searchType === 'graph'); + }, [values.searchType]); + + return ( + + +

Delegate monitors

+
+ + Delegate two or more monitors to run as part of this workflow. The monitor types per query, + per bucket, and per document are supported.{' '} + + Learn more. + + + + + + {graphUi ? ( + + ) : ( + + )} +
+ ); +}; + +export default AssociateMonitors; diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/AssociateMonitors.test.js b/public/pages/CreateMonitor/components/AssociateMonitors/AssociateMonitors.test.js new file mode 100644 index 000000000..f9debfe3e --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/AssociateMonitors.test.js @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import AssociateMonitors from './AssociateMonitors'; +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../containers/CreateMonitor/utils/constants'; + +describe('AssociateMonitors', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/__snapshots__/AssociateMonitors.test.js.snap b/public/pages/CreateMonitor/components/AssociateMonitors/__snapshots__/AssociateMonitors.test.js.snap new file mode 100644 index 000000000..a0f27e104 --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/__snapshots__/AssociateMonitors.test.js.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssociateMonitors renders 1`] = ` +Array [ +
+

+ Delegate monitors +

+
, +
+
+ Delegate two or more monitors to run as part of this workflow. The monitor types per query, per bucket, and per document are supported. + + Learn more. +
+ EuiIconMock +
+ + (opens in a new tab or window) + +
+
+
, +
, +
+
+ +
+
+
+ +
+
+
+
, +] +`; diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsEditor.js b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsEditor.js new file mode 100644 index 000000000..38389620c --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsEditor.js @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { FormikCodeEditor } from '../../../../../components/FormControls'; + +const MonitorsEditor = ({ values, isDarkMode, errors }) => { + const codeFieldName = 'associatedMonitorsEditor'; + const formikValueName = 'associatedMonitors'; + const [editorValue, setEditorValue] = useState(''); + + useEffect(() => { + try { + const code = JSON.stringify(values.associatedMonitors, null, 4); + _.set(values, codeFieldName, code); + setEditorValue(code); + } catch (e) {} + }, [values.associatedMonitors]); + + const onCodeChange = (codeValue, field, form) => { + form.setFieldValue(codeFieldName, codeValue); + form.setFieldTouched(codeFieldName, true); + setEditorValue(codeValue); + + try { + const code = JSON.parse(codeValue); // test the code before setting it to formik + form.setFieldValue(formikValueName, code); + } catch (e) { + console.error('Invalid json.'); + } + }; + + const isInvalid = (name, form) => { + try { + const associatedMonitors = form.values[name]; + const json = JSON.parse(associatedMonitors); + return !json.sequence?.delegates?.length || json.sequence?.delegates?.length < 2; + } catch (e) { + return true; + } + }; + + const hasError = (name, form) => { + const associatedMonitors = form.values[name]; + return validate(associatedMonitors); + }; + + const validate = (value) => { + try { + const json = JSON.parse(value); + if (!json.sequence?.delegates?.length || json.sequence?.delegates?.length < 2) { + return 'Delegates list can not be empty or have less then two associated monitors.'; + } + } catch (e) { + return 'Invalid json.'; + } + }; + + return ( + form.touched[codeFieldName] && isInvalid(name, form), + error: hasError, + }} + inputProps={{ + isInvalid: (name, form) => isInvalid(name, form), + mode: 'json', + width: '80%', + height: '300px', + theme: isDarkMode ? 'sense-dark' : 'github', + onChange: onCodeChange, + onBlur: (e, field, form) => form.setFieldTouched(codeFieldName, true), + value: editorValue, + 'data-test-subj': codeFieldName, + }} + /> + ); +}; + +export default MonitorsEditor; diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsEditor.test.js b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsEditor.test.js new file mode 100644 index 000000000..1b32fcdae --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsEditor.test.js @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../containers/CreateMonitor/utils/constants'; +import MonitorsEditor from './MonitorsEditor'; + +describe('MonitorsEditor', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsList.js b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsList.js new file mode 100644 index 000000000..fffab8553 --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsList.js @@ -0,0 +1,283 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import * as _ from 'lodash'; +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { + FormikComboBox, + FormikInputWrapper, + FormikFormRow, +} from '../../../../../components/FormControls'; +import { DEFAULT_ASSOCIATED_MONITORS_VALUE } from '../../../containers/CreateMonitor/utils/constants'; +import { getMonitors } from '../AssociateMonitors'; +import { required } from '../../../../../utils/validate'; + +const MonitorsList = ({ values, httpClient }) => { + const formikFieldName = 'associatedMonitorsList'; + const formikValueName = 'associatedMonitors'; + + const [fields, setFields] = useState([0, 1]); + const [options, setOptions] = useState([]); + const [selection, setSelection] = useState({}); + + useEffect(() => { + const monitorOptions = _.get(values, 'monitorOptions', []); + if (monitorOptions.length) { + setOptions(monitorsToOptions(monitorOptions)); + const selected = formikToSelection(monitorOptions); + setFields(generateFields(selected)); + } else { + getMonitors(httpClient).then((monitors) => { + _.set(values, 'monitorOptions', monitors); + + setOptions(monitorsToOptions(monitors)); + const selected = formikToSelection(monitors); + setFields(generateFields(selected)); + }); + } + }, [values]); + + const formikToSelection = (options) => { + const associatedMonitors = _.get( + values, + 'associatedMonitors', + DEFAULT_ASSOCIATED_MONITORS_VALUE + ); + + const selected = {}; + const delegates = _.sortBy(associatedMonitors.sequence.delegates, ['order']); + delegates.forEach((monitor, index) => { + const filteredOption = options.filter((option) => option.monitor_id === monitor.monitor_id); + selected[index] = { + label: filteredOption[0]?.monitor_name || '', + value: monitor.monitor_id, + }; + }); + + setSelection(selected); + return selected; + }; + + const generateFields = (selected) => { + return _.reduce( + Object.keys(selected).length > 1 ? Object.keys(selected) : [0, 1], + (result, value, key) => { + result.push(key); + return result; + }, + [] + ); + }; + + const monitorsToOptions = (monitors) => + monitors.map((monitor) => ({ + label: monitor.monitor_name, + value: monitor.monitor_id, + })); + + const onChange = (options, monitorIdx, form) => { + let selected = { + ...selection, + }; + if (options[0]) { + selected[monitorIdx] = options[0]; + } else { + delete selected[monitorIdx]; + } + setSelection(selected); + + setFormikValues(selected, monitorIdx, form); + updateSelection(selected); + }; + + const onBlur = (e, field, form) => { + form.setFieldTouched(formikFieldName, true); + form.setFieldTouched(field.name, true); + }; + + const updateSelection = (selected) => { + const newMonitorOptions = [...options]; + newMonitorOptions.forEach((mon) => { + mon.disabled = isSelected(selected, mon); + }); + + setOptions([...newMonitorOptions]); + }; + + const setFormikValues = (selected, monitorIdx, form) => { + const associatedMonitors = _.get( + values, + 'associatedMonitors', + DEFAULT_ASSOCIATED_MONITORS_VALUE + ); + associatedMonitors.sequence.delegates = selectionToFormik(selected); + form.setFieldValue(formikValueName, associatedMonitors); + }; + + const selectionToFormik = (selection) => { + const monitors = []; + Object.values(selection).forEach((monitor, index) => { + monitors.push({ + order: index + 1, + monitor_id: monitor.value, + }); + }); + + return monitors; + }; + + const isSelected = (selected, monitor) => { + let isSelected = false; + for (const key in selected) { + if (selected.hasOwnProperty(key)) { + if (selected[key].value === monitor.value) { + isSelected = true; + break; + } + } + } + return isSelected; + }; + + const onAddMonitor = () => { + let nextIndex = Math.max(...fields) + 1; + const newMonitorFields = [...fields, nextIndex]; + setFields(newMonitorFields); + + updateSelection(selection); + }; + + const onRemoveMonitor = (monitorIdx, idx, form) => { + const selected = { ...selection }; + delete selected[monitorIdx]; + setSelection(selected); + + const newMonitorFields = [...fields]; + newMonitorFields.splice(idx, 1); + setFields(newMonitorFields); + + setFormikValues(selected, monitorIdx, form); + updateSelection(selected); + }; + + const isValid = () => Object.keys(selection).length > 1; + + return ( + ( + form.touched[formikFieldName] && !isValid(), + error: () => required(), + }} + > + + {fields.map((monitorIdx, idx) => ( + + + onChange(options, monitorIdx, form), + onBlur: (e, field, form) => onBlur(e, field, form), + options: options, + singleSelection: { asPlainText: true }, + selectedOptions: selection[monitorIdx] ? [selection[monitorIdx]] : undefined, + 'data-test-subj': `monitors_list_${monitorIdx}`, + fullWidth: true, + }} + /> + + {selection[monitorIdx] && ( + + + + + + )} + {fields.length > 2 && ( + + + onRemoveMonitor(monitorIdx, idx, form)} + /> + + + )} + + ))} + + onAddMonitor()} + disabled={fields.length >= 10 || fields.length >= options.length} + > + Add another monitor + + + You can add up to {10 - fields.length} more monitors. + + + + )} + /> + ); +}; + +export default MonitorsList; diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsList.test.js b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsList.test.js new file mode 100644 index 000000000..4b3102d3b --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/components/MonitorsList.test.js @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../containers/CreateMonitor/utils/constants'; +import MonitorsList from './MonitorsList'; + +describe('MonitorsList', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/components/__snapshots__/MonitorsEditor.test.js.snap b/public/pages/CreateMonitor/components/AssociateMonitors/components/__snapshots__/MonitorsEditor.test.js.snap new file mode 100644 index 000000000..4726f7a90 --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/components/__snapshots__/MonitorsEditor.test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorsEditor renders 1`] = ` +
+
+ +
+
+
+ +
+
+
+
+`; diff --git a/public/pages/CreateMonitor/components/AssociateMonitors/components/__snapshots__/MonitorsList.test.js.snap b/public/pages/CreateMonitor/components/AssociateMonitors/components/__snapshots__/MonitorsList.test.js.snap new file mode 100644 index 000000000..e75098251 --- /dev/null +++ b/public/pages/CreateMonitor/components/AssociateMonitors/components/__snapshots__/MonitorsList.test.js.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorsList renders 1`] = ` +
+
+ +
+
+
+
+ +
+
+
+ +
+
+ +
+
+ You can add up to 8 more monitors. +
+
+
+
+`; diff --git a/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/utils/constants.js b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/utils/constants.js index 497d882eb..d9494b581 100644 --- a/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/utils/constants.js +++ b/public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/utils/constants.js @@ -6,6 +6,7 @@ import { OPERATORS_MAP } from '../../MonitorExpressions/expressions/utils/constants'; export const DOC_LEVEL_INPUT_FIELD = 'doc_level_input'; +export const COMPOSITE_INPUT_FIELD = 'composite_input'; /** * A list of the operators currently supported for defining queries through the UI. diff --git a/public/pages/CreateMonitor/components/MonitorDefinitionCard/MonitorDefinitionCard.js b/public/pages/CreateMonitor/components/MonitorDefinitionCard/MonitorDefinitionCard.js index 8e3ba733a..5be9c5ece 100644 --- a/public/pages/CreateMonitor/components/MonitorDefinitionCard/MonitorDefinitionCard.js +++ b/public/pages/CreateMonitor/components/MonitorDefinitionCard/MonitorDefinitionCard.js @@ -8,12 +8,32 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import FormikCheckableCard from '../../../../components/FormControls/FormikCheckableCard/FormikCheckableCard'; import { OS_AD_PLUGIN, MONITOR_TYPE, SEARCH_TYPE } from '../../../../utils/constants'; import { URL } from '../../../../../utils/constants'; +import _ from 'lodash'; +import { conditionToExpressions } from '../../../CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder'; const MONITOR_DEFINITION_CARD_WIDTH = '275'; -const onChangeDefinition = (e, form) => { +const onChangeDefinition = (e, form, values) => { const type = e.target.value; form.setFieldValue('searchType', type, false); + + let preventVisualEditor = false; + + if (values.monitor_type === MONITOR_TYPE.COMPOSITE_LEVEL && type === 'graph') { + const triggerDefinitions = _.get(values, 'triggerDefinitions', []); + const monitors = _.get(values, 'monitorOptions', []); + for (let trigger of triggerDefinitions) { + const triggerConditions = trigger.triggerConditions || ''; + const parsedConditions = conditionToExpressions(triggerConditions, monitors); + + if (triggerConditions !== '()' && !!triggerConditions.length && !parsedConditions.length) { + preventVisualEditor = true; + break; + } + } + } + + form.setFieldValue('preventVisualEditor', preventVisualEditor); }; const MonitorDefinitionCard = ({ values, plugins }) => { @@ -52,7 +72,7 @@ const MonitorDefinitionCard = ({ values, plugins }) => { checked: values.searchType === SEARCH_TYPE.GRAPH, value: SEARCH_TYPE.GRAPH, onChange: (e, field, form) => { - onChangeDefinition(e, form); + onChangeDefinition(e, form, values); }, 'data-test-subj': 'visualEditorRadioCard', }} @@ -68,7 +88,7 @@ const MonitorDefinitionCard = ({ values, plugins }) => { checked: values.searchType === SEARCH_TYPE.QUERY, value: SEARCH_TYPE.QUERY, onChange: (e, field, form) => { - onChangeDefinition(e, form); + onChangeDefinition(e, form, values); }, 'data-test-subj': 'extractionQueryEditorRadioCard', }} @@ -85,7 +105,7 @@ const MonitorDefinitionCard = ({ values, plugins }) => { checked: values.searchType === SEARCH_TYPE.AD, value: SEARCH_TYPE.AD, onChange: (e, field, form) => { - onChangeDefinition(e, form); + onChangeDefinition(e, form, values); }, 'data-test-subj': 'anomalyDetectorRadioCard', }} diff --git a/public/pages/CreateMonitor/components/MonitorType/MonitorType.js b/public/pages/CreateMonitor/components/MonitorType/MonitorType.js index 1ba2a1ef9..bf1442bbe 100644 --- a/public/pages/CreateMonitor/components/MonitorType/MonitorType.js +++ b/public/pages/CreateMonitor/components/MonitorType/MonitorType.js @@ -25,6 +25,9 @@ const onChangeDefinition = (e, form) => { form.setFieldValue('searchType', FORMIK_INITIAL_VALUES.searchType); form.setFieldValue('triggerDefinitions', FORMIK_INITIAL_TRIGGER_VALUES.triggerConditions); switch (type) { + case MONITOR_TYPE.COMPOSITE_LEVEL: + form.setFieldValue('searchType', SEARCH_TYPE.GRAPH); + break; case MONITOR_TYPE.CLUSTER_METRICS: form.setFieldValue('searchType', SEARCH_TYPE.CLUSTER_METRICS); break; @@ -38,27 +41,36 @@ const onChangeDefinition = (e, form) => { const queryLevelDescription = ( - Per query monitors run a specified query and define triggers that check the results of that - query. + Per query monitors run a query and generate alerts based on trigger criteria that match query + results. ); const bucketLevelDescription = ( - Per bucket monitors allow you to group results into buckets and define triggers that check each - bucket. + Per bucket monitors run a query that evaluates trigger criteria based on aggregated values in + the dataset. ); const clusterMetricsDescription = ( - Per cluster metrics monitors allow you to alert based on responses to common REST APIs. + Per cluster metrics monitors run API requests to monitor the cluster’s health. ); -const documentLevelDescription = ( // TODO DRAFT: confirm wording +const documentLevelDescription = // TODO DRAFT: confirm wording + ( + + Per document monitors run queries that return individual documents matching the trigger + conditions. + + ); + +const compositeLevelDescription = ( - Per document monitors allow you to run queries on new documents as they're indexed. + Composite monitors chain the outputs of different monitor types and focus trigger conditions to + reduce alert noise. ); @@ -128,6 +140,22 @@ const MonitorType = ({ values }) => ( }} /> + + onChangeDefinition(e, form), + children: compositeLevelDescription, + 'data-test-subj': 'compositeLevelMonitorRadioCard', + }} + /> + ); 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 4178b864f..8c5ea8fbf 100644 --- a/public/pages/CreateMonitor/components/MonitorType/__snapshots__/MonitorType.test.js.snap +++ b/public/pages/CreateMonitor/components/MonitorType/__snapshots__/MonitorType.test.js.snap @@ -67,7 +67,7 @@ exports[`MonitorType renders 1`] = `
- Per query monitors run a specified query and define triggers that check the results of that query. + Per query monitors run a query and generate alerts based on trigger criteria that match query results.
@@ -129,7 +129,7 @@ exports[`MonitorType renders 1`] = `
- Per bucket monitors allow you to group results into buckets and define triggers that check each bucket. + Per bucket monitors run a query that evaluates trigger criteria based on aggregated values in the dataset.
@@ -191,7 +191,7 @@ exports[`MonitorType renders 1`] = `
- Per cluster metrics monitors allow you to alert based on responses to common REST APIs. + Per cluster metrics monitors run API requests to monitor the cluster’s health.
@@ -253,7 +253,69 @@ exports[`MonitorType renders 1`] = `
- Per document monitors allow you to run queries on new documents as they're indexed. + Per document monitors run queries that return individual documents matching the trigger conditions. +
+ + + + + + + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ Composite monitors chain the outputs of different monitor types and focus trigger conditions to reduce alert noise.
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 ad99e0e92..dd1a89d69 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 @@ -6,6 +6,13 @@ exports[`AnomalyDetectors renders 1`] = ` Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -36,6 +43,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -70,6 +78,13 @@ exports[`AnomalyDetectors renders 1`] = ` Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -100,6 +115,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -195,6 +211,13 @@ exports[`AnomalyDetectors renders 1`] = ` "initialValues": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -225,6 +248,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -278,6 +302,13 @@ exports[`AnomalyDetectors renders 1`] = ` "values": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -308,6 +339,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -409,6 +441,13 @@ exports[`AnomalyDetectors renders 1`] = ` "initialValues": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -439,6 +478,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -492,6 +532,13 @@ exports[`AnomalyDetectors renders 1`] = ` "values": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -522,6 +569,7 @@ exports[`AnomalyDetectors renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js index 98cd5c83b..328d377a2 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js @@ -17,13 +17,13 @@ import { import DefineMonitor from '../DefineMonitor'; import { FORMIK_INITIAL_VALUES } from './utils/constants'; import { formikToMonitor } from './utils/formikToMonitor'; -import { SEARCH_TYPE } from '../../../../utils/constants'; +import { MONITOR_TYPE, SEARCH_TYPE } from '../../../../utils/constants'; import { SubmitErrorHandler } from '../../../../utils/SubmitErrorHandler'; import MonitorDetails from '../MonitorDetails'; import ConfigureTriggers from '../../../CreateTrigger/containers/ConfigureTriggers'; import { triggerToFormik } from '../../../CreateTrigger/containers/CreateTrigger/utils/triggerToFormik'; +import WorkflowDetails from '../WorkflowDetails/WorkflowDetails'; import { getInitialValues, getPlugins, submit } from './utils/helpers'; -import { createSavedObjectAssociation } from '../../../../utils/savedObjectHelper'; export default class CreateMonitor extends Component { static defaultProps = { @@ -139,85 +139,109 @@ export default class CreateMonitor extends Component { return (
- {({ values, errors, handleSubmit, isSubmitting, isValid, touched }) => ( - - -

{edit ? 'Edit' : 'Create'} monitor

-
- - - - - - {values.searchType !== SEARCH_TYPE.AD && ( -
- - -
- )} - - - {(triggerArrayHelpers) => ( - + {({ values, errors, handleSubmit, isSubmitting, isValid, touched }) => { + const isComposite = values.monitor_type === MONITOR_TYPE.COMPOSITE_LEVEL; + + return ( + + +

{edit ? 'Edit' : 'Create'} monitor

+
+ + + + + {values.preventVisualEditor ? null : ( + + {isComposite ? ( + <> + + + + ) : null} + + + + {values.searchType !== SEARCH_TYPE.AD && + values.monitor_type !== MONITOR_TYPE.COMPOSITE_LEVEL && ( +
+ + +
+ )} + + + {(triggerArrayHelpers) => ( + + )} + + + + + + Cancel + + + + {edit ? 'Update' : 'Create'} + + + +
)} -
- - - - - Cancel - - - - {edit ? 'Update' : 'Create'} - - - - - notifications.toasts.addDanger({ - title: `Failed to ${edit ? 'update' : 'create'} the monitor`, - text: 'Fix all highlighted error(s) before continuing.', - }) - } - /> -
- )} + + + notifications.toasts.addDanger({ + title: `Failed to ${edit ? 'update' : 'create'} the monitor`, + text: 'Fix all highlighted error(s) before continuing.', + }) + } + /> + + ); + }}
); diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap b/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap index a0ddd0f2c..6400e4709 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap +++ b/public/pages/CreateMonitor/containers/CreateMonitor/__snapshots__/CreateMonitor.test.js.snap @@ -14,6 +14,13 @@ exports[`CreateMonitor renders 1`] = ` "adResultIndex": undefined, "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -44,6 +51,7 @@ exports[`CreateMonitor renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js index b846481f3..9e35830be 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js @@ -9,7 +9,6 @@ import { } from '../../../components/MonitorExpressions/expressions/utils/constants'; import { MONITOR_TYPE } from '../../../../../utils/constants'; import { SUPPORTED_DOC_LEVEL_QUERY_OPERATORS } from '../../../components/DocumentLevelMonitorQueries/utils/constants'; -import { QUERY_OPERATORS } from '../../../../Dashboard/components/FindingsDashboard/findingsUtils'; export const BUCKET_COUNT = 5; @@ -23,6 +22,18 @@ export const FORMIK_INITIAL_WHERE_EXPRESSION_VALUES = { fieldRangeEnd: undefined, }; +/** Sample delegate + * { + order: 1, + monitor_id: '{{m1}}', + } + */ +export const DEFAULT_ASSOCIATED_MONITORS_VALUE = { + sequence: { + delegates: [], + }, +}; + export const FORMIK_INITIAL_VALUES = { /* CONFIGURE MONITOR */ name: '', @@ -61,6 +72,10 @@ export const FORMIK_INITIAL_VALUES = { bucketUnitOfTime: 'h', // m = minute, h = hour, d = day filters: [], // array of FORMIK_INITIAL_WHERE_EXPRESSION_VALUES detectorId: '', + associatedMonitors: DEFAULT_ASSOCIATED_MONITORS_VALUE, + associatedMonitorsList: [], + associatedMonitorsEditor: '', + preventVisualEditor: false, }; export const FORMIK_INITIAL_AGG_VALUES = { diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js index 84981cb62..6aea28e13 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/formikToMonitor.js @@ -17,6 +17,7 @@ import { getApiType, } from '../../../components/ClusterMetricsMonitor/utils/clusterMetricsMonitorHelpers'; import { + COMPOSITE_INPUT_FIELD, DOC_LEVEL_INPUT_FIELD, DOC_LEVEL_QUERY_MAP, } from '../../../components/DocumentLevelMonitorQueries/utils/constants'; @@ -32,11 +33,39 @@ export function formikToMonitor(values) { [DOC_LEVEL_INPUT_FIELD]: formikToDocLevelQueriesUiMetadata(values), search: { searchType: values.searchType }, }; + case MONITOR_TYPE.COMPOSITE_LEVEL: + return { + [COMPOSITE_INPUT_FIELD]: formikToCompositeUiMetadata(values), + search: { searchType: values.searchType }, + }; default: return { search: formikToUiSearch(values) }; } }; + if (values.monitor_type === MONITOR_TYPE.COMPOSITE_LEVEL) { + const enabled_time = new Date(); + return { + last_update_time: enabled_time.getTime(), + owner: 'alerting', + type: 'workflow', + enabled_time: enabled_time.getTime(), + enabled: !values.disabled, + monitor_type: MONITOR_TYPE.COMPOSITE_LEVEL, + workflow_type: MONITOR_TYPE.COMPOSITE_LEVEL, + schema_version: 0, + name: values.name, + schedule, + inputs: [formikToInputs(values)], + triggers: [], + ui_metadata: { + schedule: uiSchedule, + monitor_type: values.monitor_type, + ...monitorUiMetadata(), + }, + }; + } + return { name: values.name, type: 'monitor', @@ -59,11 +88,19 @@ export function formikToInputs(values) { return formikToClusterMetricsInput(values); case MONITOR_TYPE.DOC_LEVEL: return formikToDocLevelInput(values); + case MONITOR_TYPE.COMPOSITE_LEVEL: + return formikToCompositeInput(values); default: return formikToSearch(values); } } +export function formikToCompositeInput(values) { + return { + composite_input: values.associatedMonitors, + }; +} + export function formikToSearch(values) { const isAD = values.searchType === SEARCH_TYPE.AD; let query = isAD ? formikToAdQuery(values) : formikToQuery(values); @@ -267,6 +304,13 @@ export function formikToDocLevelQueriesUiMetadata(values) { return { queries: _.get(values, 'queries', []) }; } +export function formikToCompositeUiMetadata(values) { + return { + associatedMonitors: _.get(values, 'associatedMonitors', []), + query: _.get(values, '', ''), + }; +} + export function formikToCompositeAggregation(values) { const { aggregations, groupBy } = values; diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/helpers.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/helpers.js index a07264123..f1217cf8a 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/helpers.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/helpers.js @@ -17,7 +17,6 @@ import { } from '../../../../CreateTrigger/containers/CreateTrigger/utils/formikToTrigger'; import { triggerToFormik } from '../../../../CreateTrigger/containers/CreateTrigger/utils/triggerToFormik'; import { TRIGGER_TYPE } from '../../../../CreateTrigger/containers/CreateTrigger/utils/constants'; -import { FORMIK_INITIAL_AGG_VALUES } from '../../CreateMonitor/utils/constants'; import { getInitialTriggerValues } from '../../../../CreateTrigger/components/AddTriggerButton/utils'; import { AGGREGATION_TYPES } from '../../../components/MonitorExpressions/expressions/utils/constants'; @@ -136,6 +135,9 @@ export const prepareTriggers = ({ case MONITOR_TYPE.DOC_LEVEL: triggerType = TRIGGER_TYPE.DOC_LEVEL; break; + case MONITOR_TYPE.COMPOSITE_LEVEL: + triggerType = TRIGGER_TYPE.COMPOSITE_LEVEL; + break; default: triggerType = TRIGGER_TYPE.QUERY_LEVEL; break; @@ -178,7 +180,9 @@ export const create = async ({ const { setSubmitting } = formikBag; try { - const resp = await httpClient.post('../api/alerting/monitors', { + const isWorkflow = monitor.workflow_type === MONITOR_TYPE.COMPOSITE_LEVEL; + const creationPool = isWorkflow ? 'workflows' : 'monitors'; + const resp = await httpClient.post(`../api/alerting/${creationPool}`, { body: JSON.stringify(monitor), }); setSubmitting(false); @@ -187,7 +191,7 @@ export const create = async ({ resp: { _id }, } = resp; if (ok) { - history.push(`/monitors/${_id}`); + history.push(`/monitors/${_id}?type=${isWorkflow ? 'workflow' : 'monitor'}`); if (onSuccess) { onSuccess({ monitor: { _id, ...monitor } }); @@ -206,12 +210,13 @@ export const update = async ({ history, updateMonitor, notifications, monitor, f const { setSubmitting } = formikBag; const updatedMonitor = _.cloneDeep(monitor); try { + const isWorkflow = updatedMonitor.workflow_type === MONITOR_TYPE.COMPOSITE_LEVEL; const resp = await updateMonitor(updatedMonitor); setSubmitting(false); const { ok, id } = resp; if (ok) { notifications.toasts.addSuccess(`Monitor "${monitor.name}" successfully updated.`); - history.push(`/monitors/${id}`); + history.push(`/monitors/${id}?type=${isWorkflow ? 'workflow' : 'monitor'}`); } else { console.log('Failed to update:', resp); } diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorQueryParams.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorQueryParams.js index c696e2eb2..2dfc2bb00 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorQueryParams.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorQueryParams.js @@ -3,8 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SEARCH_TYPE } from '../../../../../utils/constants'; - export const initializeFromQueryParams = (queryParams) => { return { searchType: queryParams.searchType || undefined, diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js index 400a0bf7d..fb518ad5c 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/utils/monitorToFormik.js @@ -11,6 +11,7 @@ import { DOC_LEVEL_INPUT_FIELD, QUERY_STRING_QUERY_OPERATORS, } from '../../../components/DocumentLevelMonitorQueries/utils/constants'; +import { conditionToExpressions } from '../../../../CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder'; // Convert Monitor JSON to Formik values used in UI forms export default function monitorToFormik(monitor) { @@ -23,6 +24,7 @@ export default function monitorToFormik(monitor) { schedule: { cron: { expression: cronExpression = formikValues.cronExpression, timezone } = {} }, inputs, ui_metadata: { schedule = {}, search = {} } = {}, + monitorOptions = [], } = 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 @@ -38,6 +40,21 @@ export default function monitorToFormik(monitor) { }; case MONITOR_TYPE.DOC_LEVEL: return docLevelInputToFormik(monitor); + case MONITOR_TYPE.COMPOSITE_LEVEL: + const triggerConditions = _.get( + monitor, + 'triggers[0].chained_alert_trigger.condition.script.source', + '' + ); + + const parsedConditions = conditionToExpressions(triggerConditions, monitorOptions); + const preventVisualEditor = + !!triggerConditions.length && triggerConditions !== '()' && !parsedConditions.length; + + return { + associatedMonitors: _.get(monitor, 'inputs[0].composite_input', {}), + searchType: preventVisualEditor ? 'query' : 'graph', + }; default: return { index: indicesToFormik(inputs[0].search.indices), @@ -59,10 +76,10 @@ export default function monitorToFormik(monitor) { cronExpression, /* DEFINE MONITOR */ + searchType, ...monitorInputs(), monitor_type, ...search, - searchType, fieldName: fieldName ? [{ label: fieldName }] : [], timezone: timezone ? [{ label: timezone }] : [], detectorId: isAD ? _.get(inputs, INPUTS_DETECTOR_ID) : undefined, 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 e2735af17..63802c43b 100644 --- a/public/pages/CreateMonitor/containers/DefineMonitor/__snapshots__/DefineMonitor.test.js.snap +++ b/public/pages/CreateMonitor/containers/DefineMonitor/__snapshots__/DefineMonitor.test.js.snap @@ -12,6 +12,13 @@ exports[`DefineMonitor renders 1`] = ` Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -42,6 +49,7 @@ exports[`DefineMonitor renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, diff --git a/public/pages/CreateMonitor/containers/MonitorDetails/MonitorDetails.js b/public/pages/CreateMonitor/containers/MonitorDetails/MonitorDetails.js index a5abc19c2..04d1b10fd 100644 --- a/public/pages/CreateMonitor/containers/MonitorDetails/MonitorDetails.js +++ b/public/pages/CreateMonitor/containers/MonitorDetails/MonitorDetails.js @@ -3,16 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo } from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import React, { useMemo, Fragment } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import ContentPanel from '../../../../components/ContentPanel'; import FormikFieldText from '../../../../components/FormControls/FormikFieldText'; import { hasError, isInvalid, required, validateMonitorName } from '../../../../utils/validate'; -import Schedule from '../../components/Schedule'; import MonitorDefinitionCard from '../../components/MonitorDefinitionCard'; import MonitorType from '../../components/MonitorType'; import AnomalyDetectors from '../AnomalyDetectors/AnomalyDetectors'; import { MONITOR_TYPE } from '../../../../utils/constants'; +import Schedule from '../../components/Schedule'; const renderAnomalyDetector = ({ httpClient, values, detectorId, flyoutMode }) => ({ actions: [], @@ -54,9 +54,10 @@ const MonitorDetails = ({ const anomalyDetectorContent = isAd && renderAnomalyDetector({ httpClient, values, detectorId, flyoutMode }); const displayMonitorDefinitionCards = values.monitor_type !== MONITOR_TYPE.CLUSTER_METRICS; - const Container = useMemo(() => (flyoutMode ? ({ children }) => <>{children} : ContentPanel), [ - flyoutMode, - ]); + const Container = useMemo( + () => (flyoutMode ? ({ children }) => <>{children} : ContentPanel), + [flyoutMode] + ); return ( ) : null} + {values.preventVisualEditor && ( + + + +

+ To view or modify all of your configurations, switch to the Extraction query editor. +

+
+
+ )} {!flyoutMode && } - + {values.monitor_type !== MONITOR_TYPE.COMPOSITE_LEVEL ? ( + + ) : null}
); }; 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 9745f7cf0..2c0d6ed4f 100644 --- a/public/pages/CreateMonitor/containers/MonitorIndex/__snapshots__/MonitorIndex.test.js.snap +++ b/public/pages/CreateMonitor/containers/MonitorIndex/__snapshots__/MonitorIndex.test.js.snap @@ -6,6 +6,13 @@ exports[`MonitorIndex renders 1`] = ` Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -36,6 +43,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -143,6 +151,13 @@ exports[`MonitorIndex renders 1`] = ` "initialValues": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -173,6 +188,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -226,6 +242,13 @@ exports[`MonitorIndex renders 1`] = ` "values": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -256,6 +279,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -373,6 +397,13 @@ exports[`MonitorIndex renders 1`] = ` "initialValues": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -403,6 +434,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, @@ -456,6 +488,13 @@ exports[`MonitorIndex renders 1`] = ` "values": Object { "aggregationType": "count", "aggregations": Array [], + "associatedMonitors": Object { + "sequence": Object { + "delegates": Array [], + }, + }, + "associatedMonitorsEditor": "", + "associatedMonitorsList": Array [], "bucketUnitOfTime": "h", "bucketValue": 1, "cronExpression": "0 */1 * * *", @@ -486,6 +525,7 @@ exports[`MonitorIndex renders 1`] = ` "interval": 1, "unit": "MINUTES", }, + "preventVisualEditor": false, "queries": Array [], "query": "{ \\"size\\": 0, diff --git a/public/pages/CreateMonitor/containers/WorkflowDetails/WorkflowDetails.js b/public/pages/CreateMonitor/containers/WorkflowDetails/WorkflowDetails.js new file mode 100644 index 000000000..cb4bea732 --- /dev/null +++ b/public/pages/CreateMonitor/containers/WorkflowDetails/WorkflowDetails.js @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ContentPanel from '../../../../components/ContentPanel'; +import Schedule from '../../components/Schedule'; +import AssociateMonitors from '../../components/AssociateMonitors/AssociateMonitors'; +import { EuiSpacer } from '@elastic/eui'; + +const WorkflowDetails = ({ values, isDarkMode, httpClient, errors }) => { + return ( + + + + + + + ); +}; + +export default WorkflowDetails; diff --git a/public/pages/CreateMonitor/containers/WorkflowDetails/WorkflowDetails.test.js b/public/pages/CreateMonitor/containers/WorkflowDetails/WorkflowDetails.test.js new file mode 100644 index 000000000..6f91e7438 --- /dev/null +++ b/public/pages/CreateMonitor/containers/WorkflowDetails/WorkflowDetails.test.js @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../CreateMonitor/utils/constants'; +import WorkflowDetails from './WorkflowDetails'; + +describe('WorkflowDetails', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateMonitor/containers/WorkflowDetails/__snapshots__/WorkflowDetails.test.js.snap b/public/pages/CreateMonitor/containers/WorkflowDetails/__snapshots__/WorkflowDetails.test.js.snap new file mode 100644 index 000000000..5c36ca0fb --- /dev/null +++ b/public/pages/CreateMonitor/containers/WorkflowDetails/__snapshots__/WorkflowDetails.test.js.snap @@ -0,0 +1,335 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WorkflowDetails renders 1`] = ` +
+
+
+

+ Workflow +

+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Schedule +

+
+
+
+
+
+
+ +
+
+
+
+ +
+ +
+ EuiIconMock +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+ +
+ EuiIconMock +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ EuiIconMock +
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Delegate monitors +

+
+
+
+ Delegate two or more monitors to run as part of this workflow. The monitor types per query, per bucket, and per document are supported. + + Learn more. +
+ EuiIconMock +
+ + (opens in a new tab or window) + +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+`; diff --git a/public/pages/CreateTrigger/components/Action/actions/Message.js b/public/pages/CreateTrigger/components/Action/actions/Message.js index d754125d0..a5456d291 100644 --- a/public/pages/CreateTrigger/components/Action/actions/Message.js +++ b/public/pages/CreateTrigger/components/Action/actions/Message.js @@ -181,6 +181,10 @@ export default function Message( actionableAlertsSelections = []; _.set(action, 'action_execution_policy.action_execution_scope', actionExecutionScopeId); break; + case MONITOR_TYPE.COMPOSITE_LEVEL: + displayActionableAlertsOptions = false; + displayThrottlingSettings = true; + break; default: displayActionableAlertsOptions = false; displayThrottlingSettings = actionExecutionScopeId !== NOTIFY_OPTIONS_VALUES.PER_EXECUTION; @@ -204,6 +208,7 @@ export default function Message( preview = err.message; console.error('There was an error rendering mustache template', err); } + return (
{!isSubjectDisabled ? ( diff --git a/public/pages/CreateTrigger/components/AddTriggerButton/AddTriggerButton.js b/public/pages/CreateTrigger/components/AddTriggerButton/AddTriggerButton.js index 17bd5e6b7..16a0da244 100644 --- a/public/pages/CreateTrigger/components/AddTriggerButton/AddTriggerButton.js +++ b/public/pages/CreateTrigger/components/AddTriggerButton/AddTriggerButton.js @@ -6,7 +6,10 @@ import React from 'react'; import _ from 'lodash'; import { EuiButton } from '@elastic/eui'; -import { FORMIK_INITIAL_TRIGGER_VALUES } from '../../containers/CreateTrigger/utils/constants'; +import { + FORMIK_COMPOSITE_INITIAL_TRIGGER_VALUES, + FORMIK_INITIAL_TRIGGER_VALUES, +} from '../../containers/CreateTrigger/utils/constants'; import EnhancedAccordion from '../../../../components/FeatureAnywhereContextMenu/EnhancedAccordion'; import { getInitialTriggerValues } from './utils'; import { MONITOR_TYPE } from '../../../../utils/constants'; @@ -14,6 +17,7 @@ import { MONITOR_TYPE } from '../../../../utils/constants'; const AddTriggerButton = ({ arrayHelpers, disabled, + monitorType, script = FORMIK_INITIAL_TRIGGER_VALUES.script, flyoutMode, onPostAdd, diff --git a/public/pages/CreateTrigger/components/AddTriggerButton/utils.js b/public/pages/CreateTrigger/components/AddTriggerButton/utils.js index 1c7f4a83f..2ec9dd94d 100644 --- a/public/pages/CreateTrigger/components/AddTriggerButton/utils.js +++ b/public/pages/CreateTrigger/components/AddTriggerButton/utils.js @@ -5,8 +5,12 @@ import _ from 'lodash'; import { getDigitId, getUniqueName } from '../../../../utils/helpers'; -import { FORMIK_INITIAL_TRIGGER_VALUES } from '../../containers/CreateTrigger/utils/constants'; +import { + FORMIK_COMPOSITE_INITIAL_TRIGGER_VALUES, + FORMIK_INITIAL_TRIGGER_VALUES, +} from '../../containers/CreateTrigger/utils/constants'; import { getInitialActionValues } from '../AddActionButton/utils'; +import { MONITOR_TYPE } from '../../../../utils/constants'; export const getInitialTriggerValues = ({ script = FORMIK_INITIAL_TRIGGER_VALUES.script, @@ -14,7 +18,10 @@ export const getInitialTriggerValues = ({ triggers, monitorType, }) => { - const initialValues = _.cloneDeep({ ...FORMIK_INITIAL_TRIGGER_VALUES, script }); + const initialValues = + monitorType === MONITOR_TYPE.COMPOSITE_LEVEL + ? _.cloneDeep(FORMIK_COMPOSITE_INITIAL_TRIGGER_VALUES) + : _.cloneDeep({ ...FORMIK_INITIAL_TRIGGER_VALUES, script }); if (flyoutMode) { const id = getDigitId(); diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/CompositeTriggerCondition.js b/public/pages/CreateTrigger/components/CompositeTriggerCondition/CompositeTriggerCondition.js new file mode 100644 index 000000000..38d07f799 --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/CompositeTriggerCondition.js @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { FormikFormRow, FormikInputWrapper } from '../../../../components/FormControls'; +import ExpressionBuilder from './ExpressionBuilder'; +import ExpressionEditor from './ExpressionEditor'; + +const CompositeTriggerCondition = ({ + label, + formikFieldPath = '', + formikFieldName = 'triggerCondition', + values, + touched, + isDarkMode = false, + httpClient, + edit, + triggerIndex, +}) => { + const formikFullFieldName = `${formikFieldPath}${formikFieldName}`; + const [graphUi, setGraphUi] = useState(values.searchType === 'graph'); + + useEffect(() => { + setGraphUi(values.searchType === 'graph'); + }, [values.searchType]); + + return ( + ( + + + + {graphUi ? ( + + ) : ( + + )} + + + + )} + /> + ); +}; + +export default CompositeTriggerCondition; diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/CompositeTriggerCondition.test.js b/public/pages/CreateTrigger/components/CompositeTriggerCondition/CompositeTriggerCondition.test.js new file mode 100644 index 000000000..aa2660de7 --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/CompositeTriggerCondition.test.js @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import CompositeTriggerCondition from './CompositeTriggerCondition'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; + +describe('CompositeTriggerCondition', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder.js b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder.js new file mode 100644 index 000000000..da0c9209b --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder.js @@ -0,0 +1,386 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiComboBox, + EuiButtonIcon, + EuiExpression, + EuiToolTip, +} from '@elastic/eui'; +import * as _ from 'lodash'; +import { FormikFormRow, FormikInputWrapper } from '../../../../components/FormControls'; +import { getMonitors } from '../../../CreateMonitor/components/AssociateMonitors/AssociateMonitors'; + +export const conditionToExpressions = (condition = '', monitors) => { + if (!condition.length) return []; + + const conditionMap = { + '&&': 'AND', + '||': 'OR', + '!': 'NOT', + '': '', + '&& !': 'AND_NOT', + '|| !': 'OR_NOT', + }; + const queryToExpressionRegex = new RegExp( + /( && || \|\| || && \!|| \|\| \!)?(monitor\[id=(.*?)\])/, + 'gm' + ); + const matcher = condition.matchAll(queryToExpressionRegex); + let match; + let expressions = []; + let counter = 0; + while ((match = matcher.next().value)) { + if (counter && !match[1]) return []; // Didn't find condition after the first match + + const monitorId = match[3]?.trim(); // match [3] is the monitor_id + const monitor = monitors.filter((mon) => mon.monitor_id === monitorId); + expressions.push({ + description: conditionMap[match[1]?.trim()] || '', // match [1] is the description/condition + isOpen: false, + monitor_name: monitor[0]?.monitor_name, + monitor_id: monitorId, + }); + + counter++; + } + + return expressions; +}; + +const ExpressionBuilder = ({ + formikFieldPath = '', + formikFieldName, + values, + touched, + httpClient, + edit, + triggerIndex, +}) => { + const formikFullFieldName = `${formikFieldPath}${formikFieldName}`; + const formikFullFieldValue = _.replace(`${formikFullFieldName}_value`, /[.\[\]]/gm, '_'); + const expressionNamePrefix = `expressionQueries_${triggerIndex}`; + + const DEFAULT_CONDITION = 'AND'; + const DEFAULT_NAME = 'Select delegate monitor'; + const DEFAULT_EXPRESSION = { + description: '', + isOpen: false, + monitor_id: '', + monitor_name: DEFAULT_NAME, + }; + const DEFAULT_NEXT_EXPRESSION = { + ...DEFAULT_EXPRESSION, + description: DEFAULT_CONDITION, + }; + const FIRST_EXPRESSION_CONDITIONS_MAP = [{ description: 'NOT', label: 'NOT' }]; + const EXPRESSION_CONDITIONS_MAP = [ + { description: 'AND', label: 'AND' }, + { description: 'OR', label: 'OR' }, + { description: 'AND_NOT', label: 'AND NOT' }, + { description: 'OR_NOT', label: 'OR NOT' }, + ]; + + const [usedExpressions, setUsedExpressions] = useState([DEFAULT_EXPRESSION]); + const [options, setOptions] = useState([]); + const triggerConditions = _.get(values, formikFullFieldName, ''); + + useEffect(() => { + // initializing formik because these are generic fields and formik won't pick them up until fields is updated + !_.get(touched, formikFullFieldValue) && _.set(touched, formikFullFieldValue, false); + !_.get(values, formikFullFieldValue) && _.set(values, formikFullFieldValue, ''); + + const monitors = _.get(values, 'monitorOptions', []); + if (monitors.length) { + setInitialValues(monitors); + } else { + getMonitors(httpClient).then((monitors) => { + _.set(values, 'monitorOptions', monitors); + setInitialValues(monitors); + }); + } + }, [values.associatedMonitors?.sequence?.delegates, triggerConditions]); + + const setInitialValues = (monitors) => { + const monitorOptions = []; + const associatedMonitors = _.get(values, 'associatedMonitors', {}); + associatedMonitors.sequence.delegates.forEach((monitor) => { + const filteredOption = monitors.filter((option) => option.monitor_id === monitor.monitor_id); + monitorOptions.push({ + label: filteredOption[0]?.monitor_name || '', + monitor_id: monitor.monitor_id, + }); + }); + setOptions(monitorOptions); + + const condition = _.get(values, formikFullFieldName, ''); + + let expressions = conditionToExpressions(condition, monitors); + if ( + !edit && + !_.get(touched, formikFullFieldValue, false) && + triggerIndex === 0 && + expressions.length === 0 + ) { + expressions = []; + monitorOptions.forEach((monitor, index) => { + expressions.push({ + description: index ? 'AND' : '', + monitor_id: monitor.monitor_id, + monitor_name: monitor.label, + }); + }); + + _.set(values, formikFullFieldName, expressionsToCondition(expressions)); + } + + setUsedExpressions(expressions?.length ? expressions : [DEFAULT_EXPRESSION]); + }; + + const expressionsToCondition = (expressions) => { + const conditionMap = { + AND: '&& ', + OR: '|| ', + NOT: '!', + '': '', + AND_NOT: '&& !', + OR_NOT: '|| !', + }; + + const condition = expressions.reduce((query, expression) => { + if (expression?.monitor_id) { + query += ` ${conditionMap[expression.description]}monitor[id=${expression.monitor_id}]`; + query = query.trim(); + } + return query; + }, ''); + + return `(${condition})`; + }; + + const changeMonitor = (selection, exp, idx, form) => { + const expressions = _.cloneDeep(usedExpressions); + let monitor = selection[0]; + + if (monitor?.monitor_id) { + expressions[idx] = { + ...expressions[idx], + monitor_id: monitor.monitor_id, + monitor_name: monitor.label, + }; + } else { + expressions[idx] = idx ? DEFAULT_NEXT_EXPRESSION : DEFAULT_EXPRESSION; + } + + setUsedExpressions(expressions); + onChange(form, expressions); + }; + + const changeCondition = (selection, exp, idx, form) => { + const expressions = _.cloneDeep(usedExpressions); + + expressions[idx] = { ...expressions[idx], description: selection[0].description }; + setUsedExpressions(expressions); + onChange(form, expressions); + }; + + const onChange = (form, expressions) => { + form.setFieldValue(formikFullFieldName, expressionsToCondition(expressions)); + }; + + const onBlur = (form, expressions) => { + onChange(form, expressions); + form.setFieldTouched(formikFullFieldValue, true); + }; + + const openPopover = (idx = 0) => { + const expressions = _.cloneDeep(usedExpressions); + expressions[idx] = { ...expressions[idx], isOpen: !expressions[idx].isOpen }; + setUsedExpressions(expressions); + }; + + const closePopover = (idx, form) => { + const expressions = _.cloneDeep(usedExpressions); + expressions[idx] = { ...expressions[idx], isOpen: false }; + setUsedExpressions(expressions); + onBlur(form, expressions); + form.setFieldTouched(`${expressionNamePrefix}_${idx}`, true); + }; + + const onRemoveExpression = useCallback( + (form, idx) => { + const expressions = _.cloneDeep(usedExpressions); + expressions.splice(idx, 1); + expressions.length && (expressions[0].description = ''); + + if (!expressions?.length) { + expressions.push(DEFAULT_EXPRESSION); + } + setUsedExpressions([...expressions]); + onChange(form, expressions); + }, + [usedExpressions] + ); + + const hasInvalidExpression = () => + !!usedExpressions.filter((expression) => expression.monitor_id === '')?.length; + + const isValid = () => options.length > 1 && usedExpressions.length > 1 && !hasInvalidExpression(); + + const validate = () => { + if (options.length < 2) return 'Trigger condition requires at least two associated monitors.'; + if (usedExpressions.length < 2) + return 'Trigger condition requires at least two monitors selected.'; + if (hasInvalidExpression()) return 'Invalid expressions.'; + }; + + const renderOptions = (expression, idx = 0, form) => ( + + + changeCondition(selection, expression, idx, form)} + onBlur={() => onBlur(form, usedExpressions)} + options={idx === 0 ? FIRST_EXPRESSION_CONDITIONS_MAP : EXPRESSION_CONDITIONS_MAP} + autoFocus={false} + /> + + {renderMonitorOptions(expression, idx, form)} + + + onRemoveExpression(form, idx)} + iconType={'trash'} + color="danger" + aria-label={'Remove condition'} + style={{ marginTop: '4px' }} + /> + + + + ); + + const renderMonitorOptions = (expression, idx, form) => ( + changeMonitor(selection, expression, idx, form)} + onBlur={() => onBlur(form, usedExpressions)} + selectedOptions={[ + { + label: expression.monitor_name, + monitor_id: expression.monitor_id, + }, + ]} + style={{ width: '250px' }} + data-test-subj={`monitors-combobox-${triggerIndex}-${idx}`} + options={(() => { + const differences = _.differenceBy(options, usedExpressions, 'monitor_id'); + return [ + { + monitor_id: expression.monitor_id, + label: expression.monitor_name, + }, + ...differences.map((sel) => ({ + monitor_id: sel.monitor_id, + label: sel.label, + })), + ]; + })()} + /> + ); + + return ( + validate(), + }} + render={({ form }) => ( + form.touched[formikFullFieldValue] && !isValid(), + error: () => validate(), + style: { + maxWidth: 'inherit', + }, + }} + > + + {usedExpressions.map((expression, idx) => ( + + { + form.setFieldTouched(formikFullFieldValue, true); + openPopover(idx); + }} + data-test-subj={`select-expression_${triggerIndex}_${idx}`} + /> + } + isOpen={expression.isOpen} + closePopover={() => closePopover(idx, form)} + panelPaddingSize="s" + anchorPosition="upCenter" + > + {renderOptions(expression, idx, form)} + + + ))} + {options.length > usedExpressions.length && ( + + + { + const expressions = _.cloneDeep(usedExpressions); + expressions.push({ + ...DEFAULT_NEXT_EXPRESSION, + }); + setUsedExpressions(expressions); + }} + color={'primary'} + iconType="plusInCircleFilled" + aria-label={'Add one more condition'} + data-test-subj={`condition-add-options-btn_${triggerIndex}`} + style={{ marginTop: '1px' }} + /> + + + )} + + + )} + /> + ); +}; + +export default ExpressionBuilder; diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder.test.js b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder.test.js new file mode 100644 index 000000000..b9cefe251 --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionBuilder.test.js @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; +import ExpressionBuilder from './ExpressionBuilder'; + +describe('ExpressionBuilder', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionEditor.js b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionEditor.js new file mode 100644 index 000000000..0d80b8ac5 --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionEditor.js @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import * as _ from 'lodash'; +import { FormikCodeEditor } from '../../../../components/FormControls'; + +const ExpressionEditor = ({ + values, + formikFieldName, + formikFieldPath, + isDarkMode = false, + triggerIndex, +}) => { + const [editorValue, setEditorValue] = useState(''); + const formikFullFieldName = `${formikFieldPath}${formikFieldName}`; + const formikFullCodeFieldName = _.replace( + `${formikFullFieldName}_${triggerIndex}_code`, + /[.\[\]]/gm, + '_' + ); + + useEffect(() => { + const code = _.get(values, formikFullFieldName, ''); + _.set(values, formikFullCodeFieldName, code); + setEditorValue(code); + }, [values]); + + const isInvalid = (name, form) => !form.values[name]?.length; + + const hasError = (name, form) => { + return !form.values[name]?.length && 'Invalid condition.'; + }; + + const validate = (value) => { + if (!value?.length) return 'Invalid condition.'; + }; + + return ( + form.touched[name] && isInvalid(name, form), + error: (name, form) => hasError(name, form), + }} + inputProps={{ + isInvalid: (name, form) => form.touched[name] && isInvalid(name, form), + mode: 'text', + width: '80%', + height: '300px', + theme: isDarkMode ? 'sense-dark' : 'github', + value: editorValue, + onChange: (code, field, form) => { + form.setFieldTouched(formikFullCodeFieldName, true); + form.setFieldValue(formikFullFieldName, code); + form.setFieldValue(formikFullCodeFieldName, code); + }, + onBlur: (e, field, form) => form.setFieldTouched(field.name, true), + 'data-test-subj': `compositeTriggerConditionEditor_${triggerIndex}`, + }} + /> + ); +}; + +export default ExpressionEditor; diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionEditor.test.js b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionEditor.test.js new file mode 100644 index 000000000..ef0bf0b0d --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/ExpressionEditor.test.js @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; +import ExpressionEditor from './ExpressionEditor'; + +describe('ExpressionEditor', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/CompositeTriggerCondition.test.js.snap b/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/CompositeTriggerCondition.test.js.snap new file mode 100644 index 000000000..a6d6ba2f9 --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/CompositeTriggerCondition.test.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CompositeTriggerCondition renders 1`] = ` +
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+`; diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/ExpressionBuilder.test.js.snap b/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/ExpressionBuilder.test.js.snap new file mode 100644 index 000000000..71961f70a --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/ExpressionBuilder.test.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExpressionBuilder renders 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+`; diff --git a/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/ExpressionEditor.test.js.snap b/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/ExpressionEditor.test.js.snap new file mode 100644 index 000000000..27e0077d5 --- /dev/null +++ b/public/pages/CreateTrigger/components/CompositeTriggerCondition/__snapshots__/ExpressionEditor.test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExpressionEditor renders 1`] = ` +
+
+ +
+
+
+ +
+
+
+
+`; diff --git a/public/pages/CreateTrigger/components/TriggerEmptyPrompt/TriggerEmptyPrompt.js b/public/pages/CreateTrigger/components/TriggerEmptyPrompt/TriggerEmptyPrompt.js index 2fdaff087..651c1d504 100644 --- a/public/pages/CreateTrigger/components/TriggerEmptyPrompt/TriggerEmptyPrompt.js +++ b/public/pages/CreateTrigger/components/TriggerEmptyPrompt/TriggerEmptyPrompt.js @@ -8,11 +8,15 @@ import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; import AddTriggerButton from '../AddTriggerButton'; import { FORMIK_INITIAL_TRIGGER_VALUES } from '../../containers/CreateTrigger/utils/constants'; -const addTriggerButton = (arrayHelpers, script) => ( - +const addTriggerButton = (arrayHelpers, monitorType, script) => ( + ); -const TriggerEmptyPrompt = ({ arrayHelpers, script = FORMIK_INITIAL_TRIGGER_VALUES.script }) => ( +const TriggerEmptyPrompt = ({ + arrayHelpers, + monitorType, + script = FORMIK_INITIAL_TRIGGER_VALUES.script, +}) => ( Add a trigger to define conditions and actions.

} - actions={addTriggerButton(arrayHelpers, script)} + actions={addTriggerButton(arrayHelpers, monitorType, script)} /> ); diff --git a/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js b/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js index 9f4e46d07..e7bc19a43 100644 --- a/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js +++ b/public/pages/CreateTrigger/containers/ConfigureTriggers/ConfigureTriggers.js @@ -31,6 +31,7 @@ import { } from '../../../CreateMonitor/components/ClusterMetricsMonitor/utils/clusterMetricsMonitorHelpers'; import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; import { getDefaultScript } from '../../utils/helper'; +import DefineCompositeLevelTrigger from '../DefineCompositeLevelTrigger'; import EnhancedAccordion from '../../../../components/FeatureAnywhereContextMenu/EnhancedAccordion'; class ConfigureTriggers extends React.Component { @@ -86,7 +87,23 @@ class ConfigureTriggers extends React.Component { 'monitorValues.uri.api_type', FORMIK_INITIAL_VALUES.uri.api_type ); - if (prevSearchType !== currSearchType || prevApiType !== currApiType) { + const prevMonitorType = _.get( + prevProps, + 'monitorValues.monitor_type', + FORMIK_INITIAL_VALUES.monitor_type + ); + const currMonitorType = _.get( + this.props, + 'monitorValues.monitor_type', + FORMIK_INITIAL_VALUES.monitor_type + ); + + if ( + prevSearchType !== currSearchType || + prevApiType !== currApiType || + prevMonitorType !== currMonitorType + ) { + this.setState({ addTriggerButton: this.prepareAddTriggerButton() }); this.setState({ triggerEmptyPrompt: this.prepareTriggerEmptyPrompt() }); } @@ -119,6 +136,7 @@ class ConfigureTriggers extends React.Component { ); @@ -129,6 +147,7 @@ class ConfigureTriggers extends React.Component { return ( @@ -317,6 +336,33 @@ class ConfigureTriggers extends React.Component { ); }; + renderCompositeLevelTrigger = (triggerArrayHelpers, index) => { + const { + edit, + monitorValues, + isDarkMode, + httpClient, + notifications, + notificationService, + plugins, + touched, + } = this.props; + return ( + + ); + }; + renderTriggers = (triggerArrayHelpers) => { const { monitorValues, triggerValues, flyoutMode, errors, submitCount } = this.props; const { triggerEmptyPrompt, TriggerContainer, accordionsOpen, currentSubmitCount } = this.state; @@ -328,6 +374,8 @@ class ConfigureTriggers extends React.Component { return this.renderDefineBucketLevelTrigger(arrayHelpers, index); case MONITOR_TYPE.DOC_LEVEL: return this.renderDefineDocumentLevelTrigger(arrayHelpers, index); + case MONITOR_TYPE.COMPOSITE_LEVEL: + return this.renderCompositeLevelTrigger(arrayHelpers, index); default: return this.renderDefineTrigger(arrayHelpers, index); } @@ -387,11 +435,17 @@ class ConfigureTriggers extends React.Component { const displayAddTriggerButton = numOfTriggers > 0; const disableAddTriggerButton = numOfTriggers >= MAX_TRIGGERS; const monitorType = monitorValues.monitor_type; + const isComposite = monitorType === MONITOR_TYPE.COMPOSITE_LEVEL; return ( ] || query[name=]) && query[tag=]', diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js index dfae41837..ed9fe4ed4 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js @@ -35,6 +35,8 @@ export function formikToTriggerDefinition(values, monitorUiMetadata) { return formikToBucketLevelTrigger(values, monitorUiMetadata); case MONITOR_TYPE.DOC_LEVEL: return formikToDocumentLevelTrigger(values, monitorUiMetadata); + case MONITOR_TYPE.COMPOSITE_LEVEL: + return formikToCompositeLevelTrigger(values, monitorUiMetadata); default: return formikToQueryLevelTrigger(values, monitorUiMetadata); } @@ -86,6 +88,20 @@ export function formikToDocumentLevelTrigger(values, monitorUiMetadata) { }; } +export function formikToCompositeLevelTrigger(values, monitorUiMetadata) { + const condition = formikToCompositeTriggerCondition(values, monitorUiMetadata); + const actions = formikToCompositeTriggerAction(values); + return { + chained_alert_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); @@ -99,6 +115,17 @@ export function formikToDocumentLevelTriggerCondition(values, monitorUiMetadata) }; } +export function formikToCompositeTriggerCondition(values) { + const triggerConditions = _.get(values, 'triggerConditions', ''); + + return { + script: { + lang: 'painless', + source: triggerConditions, + }, + }; +} + export function getDocumentLevelScriptSource(conditions) { const scriptSourceContents = []; conditions.forEach((condition) => { @@ -171,6 +198,51 @@ export function formikToBucketLevelTriggerAction(values) { return actions; } +export function formikToCompositeTriggerAction(values) { + const actions = values.actions; + const executionPolicyPath = 'action_execution_policy.action_execution_scope'; + if (actions && actions.length > 0) { + return actions.map((action) => { + let formattedAction = _.cloneDeep(action); + + switch (formattedAction.throttle_enabled) { + case true: + _.set(formattedAction, 'throttle.unit', FORMIK_INITIAL_ACTION_VALUES.throttle.unit); + break; + case false: + formattedAction = _.omit(formattedAction, ['throttle']); + break; + } + + const notifyOption = _.get(formattedAction, `${executionPolicyPath}`); + const notifyOptionId = _.isString(notifyOption) ? notifyOption : _.keys(notifyOption)[0]; + switch (notifyOptionId) { + case NOTIFY_OPTIONS_VALUES.PER_ALERT: + const actionableAlerts = _.get( + formattedAction, + `${executionPolicyPath}.${NOTIFY_OPTIONS_VALUES.PER_ALERT}.actionable_alerts`, + [] + ); + _.set( + formattedAction, + `${executionPolicyPath}.${NOTIFY_OPTIONS_VALUES.PER_ALERT}.actionable_alerts`, + actionableAlerts.map((entry) => entry.value) + ); + break; + case NOTIFY_OPTIONS_VALUES.PER_EXECUTION: + _.set( + formattedAction, + `${executionPolicyPath}.${NOTIFY_OPTIONS_VALUES.PER_EXECUTION}`, + {} + ); + break; + } + return formattedAction; + }); + } + return actions; +} + export function formikToTriggerUiMetadata(values, monitorUiMetadata) { switch (monitorUiMetadata.monitor_type) { case MONITOR_TYPE.QUERY_LEVEL: @@ -210,6 +282,7 @@ export function formikToTriggerUiMetadata(values, monitorUiMetadata) { bucketLevelTriggersUiMetadata[trigger.name] = triggerMetadata; }); return bucketLevelTriggersUiMetadata; + case MONITOR_TYPE.DOC_LEVEL: const docLevelTriggersUiMetadata = {}; _.get(values, 'triggerDefinitions', []).forEach((trigger) => { @@ -221,6 +294,13 @@ export function formikToTriggerUiMetadata(values, monitorUiMetadata) { docLevelTriggersUiMetadata[trigger.name] = triggerMetadata; }); return docLevelTriggersUiMetadata; + + case MONITOR_TYPE.COMPOSITE_LEVEL: + const compositeTriggersUiMetadata = {}; + _.get(values, 'triggerDefinitions', []).forEach((trigger) => { + compositeTriggersUiMetadata[trigger.name] = _.get(trigger, 'triggerConditions', ''); + }); + return compositeTriggersUiMetadata; } } diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js index 87afacc19..04991d2b8 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js @@ -35,6 +35,8 @@ export function triggerDefinitionToFormik(trigger, monitor) { return bucketLevelTriggerToFormik(trigger, monitor); case MONITOR_TYPE.DOC_LEVEL: return documentLevelTriggerToFormik(trigger, monitor); + case MONITOR_TYPE.COMPOSITE_LEVEL: + return compositeTriggerToFormik(trigger, monitor); default: return queryLevelTriggerToFormik(trigger, monitor); } @@ -216,6 +218,32 @@ export function documentLevelTriggerToFormik(trigger, monitor) { }; } +export function compositeTriggerToFormik(trigger, monitor) { + const { + id, + name, + severity, + condition: { script }, + actions, + } = trigger[TRIGGER_TYPE.COMPOSITE_LEVEL]; + + const triggerConditions = _.get( + monitor, + 'triggers[0].chained_alert_trigger.condition.script.source', + '' + ); + + return { + ..._.cloneDeep(FORMIK_INITIAL_TRIGGER_VALUES), + id, + name, + severity, + script, + actions, + triggerConditions: triggerConditions, + }; +} + export function getExecutionPolicyActions(actions) { const executionPolicyPath = 'action_execution_policy.action_execution_scope'; return _.cloneDeep(actions).map((action) => { diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/DefineCompositeLevelTrigger.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/DefineCompositeLevelTrigger.js new file mode 100644 index 000000000..4bbace2fb --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/DefineCompositeLevelTrigger.js @@ -0,0 +1,176 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { EuiSpacer, EuiText, EuiTitle, EuiAccordion, EuiButton } from '@elastic/eui'; +import { FormikFieldText, FormikSelect } from '../../../../components/FormControls'; +import { hasError, isInvalid } from '../../../../utils/validate'; +import { DEFAULT_TRIGGER_NAME, SEVERITY_OPTIONS } from '../../utils/constants'; +import CompositeTriggerCondition from '../../components/CompositeTriggerCondition/CompositeTriggerCondition'; +import TriggerNotifications from './TriggerNotifications'; +import { FORMIK_COMPOSITE_INITIAL_TRIGGER_VALUES } from '../CreateTrigger/utils/constants'; + +export const titleTemplate = (title, subTitle) => ( + +
{title}
+ {subTitle && ( + + {subTitle} + + )} + +
+); + +const defaultRowProps = { + label: titleTemplate('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 = { + values: PropTypes.object.isRequired, + isDarkMode: PropTypes.bool.isRequired, +}; + +class DefineCompositeLevelTrigger extends Component { + render() { + const { + values, + httpClient, + notifications, + notificationService, + plugins, + touched, + edit, + triggerIndex, + triggerArrayHelpers, + } = this.props; + + const formikFieldPath = `triggerDefinitions[${triggerIndex}].`; + const triggerName = _.get(values, `${formikFieldPath}name`, 'Trigger'); + const triggerDefinitions = _.get(values, 'triggerDefinitions', []); + !triggerDefinitions.length && + _.set(values, 'triggerDefinitions', [ + { + ...FORMIK_COMPOSITE_INITIAL_TRIGGER_VALUES, + ...triggerDefinitions[triggerIndex], + severity: 1, + name: triggerName, + }, + ]); + const triggerActions = _.get(values, `${formikFieldPath}actions`, []); + + 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' }} + > +
+ + + + + + + + + + + {titleTemplate('Alert severity')} + + + + + +
+
+ ); + } +} + +DefineCompositeLevelTrigger.propTypes = propTypes; + +export default DefineCompositeLevelTrigger; diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/DefineCompositeLevelTrigger.test.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/DefineCompositeLevelTrigger.test.js new file mode 100644 index 000000000..242cb7261 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/DefineCompositeLevelTrigger.test.js @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; +import DefineCompositeLevelTrigger from './DefineCompositeLevelTrigger'; + +describe('DefineCompositeLevelTrigger', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/NotificationConfigDialog.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/NotificationConfigDialog.js new file mode 100644 index 000000000..8e365a86a --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/NotificationConfigDialog.js @@ -0,0 +1,144 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiButton, + EuiSpacer, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { titleTemplate } from './DefineCompositeLevelTrigger'; +import Message from '../../components/Action/actions'; +import { FORMIK_INITIAL_ACTION_VALUES } from '../../utils/constants'; +import { getTriggerContext } from '../../utils/helper'; +import { formikToMonitor } from '../../../CreateMonitor/containers/CreateMonitor/utils/formikToMonitor'; +import _ from 'lodash'; +import { formikToTrigger } from '../CreateTrigger/utils/formikToTrigger'; +import { backendErrorNotification } from '../../../../utils/helpers'; +import { checkForError } from '../ConfigureActions/ConfigureActions'; +import { TRIGGER_TYPE } from '../CreateTrigger/utils/constants'; + +const NotificationConfigDialog = ({ + closeModal, + triggerValues, + httpClient, + notifications, + actionIndex, + triggerIndex, + formikFieldPath, +}) => { + const monitor = formikToMonitor(triggerValues); + delete monitor.monitor_type; + const context = getTriggerContext({}, monitor, triggerValues, 0); + + const initialActionValues = _.cloneDeep(FORMIK_INITIAL_ACTION_VALUES); + let action = _.get(triggerValues, `${formikFieldPath}actions[${actionIndex}]`, { + ...initialActionValues, + }); + + const [initialValues, setInitialValues] = useState({}); + + useEffect(() => { + setInitialValues({ + [`action${actionIndex}`]: _.get( + triggerValues, + `${formikFieldPath}actions.${actionIndex}`, + '' + ), + }); + }, []); + + const sendTestMessage = async (index) => { + const monitorData = _.cloneDeep(monitor); + let testTrigger = _.cloneDeep( + formikToTrigger(triggerValues, monitorData.ui_metadata)[triggerIndex] + ); + + testTrigger = { + ...testTrigger, + name: _.get(triggerValues, `${formikFieldPath}name`, ''), + severity: _.get(triggerValues, `${formikFieldPath}severity`, ''), + }; + const action = _.get(testTrigger, `${TRIGGER_TYPE.COMPOSITE_LEVEL}.actions[${index}]`); + const condition = { + ..._.get(testTrigger, `${TRIGGER_TYPE.COMPOSITE_LEVEL}.condition`), + script: { lang: 'painless', source: 'return true' }, + }; + + delete testTrigger[TRIGGER_TYPE.COMPOSITE_LEVEL]; + + _.set(testTrigger, 'actions', [action]); + _.set(testTrigger, 'condition', condition); + + const testMonitor = { ...monitor, triggers: [{ ...testTrigger }] }; + + try { + const response = await httpClient.post('../api/alerting/monitors/_execute', { + query: { dryrun: false }, + body: JSON.stringify(testMonitor), + }); + let error = null; + if (response.ok) { + error = checkForError(response, error); + if (!_.isEmpty(action.destination_id)) + notifications.toasts.addSuccess(`Test message sent to "${action.name}."`); + } + if (error || !response.ok) { + const errorMessage = error == null ? response.resp : error; + console.error('There was an error trying to send test message', errorMessage); + backendErrorNotification(notifications, 'send', 'test message', errorMessage); + } + } catch (err) { + console.error('There was an error trying to send test message', err); + } + }; + + const clearConfig = () => { + _.set( + triggerValues, + `${formikFieldPath}actions.${actionIndex}`, + initialValues[`action${actionIndex}`] + ); + closeModal(); + }; + + return ( + clearConfig()}> + + +

Configure notification

+
+
+ + {titleTemplate('Customize message')} + + + + + + + clearConfig()}>Cancel + closeModal()} fill> + Update + + +
+ ); +}; + +export default NotificationConfigDialog; diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/NotificationConfigDialog.test.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/NotificationConfigDialog.test.js new file mode 100644 index 000000000..49fe11d6f --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/NotificationConfigDialog.test.js @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; +import NotificationConfigDialog from './NotificationConfigDialog'; + +describe('NotificationConfigDialog', () => { + test('renders', () => { + const component = ( + {}}> + {}} + triggerValues={FORMIK_INITIAL_VALUES} + httpClient={{}} + notifications={{}} + actionIndex={0} + /> + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotifications.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotifications.js new file mode 100644 index 000000000..5e6305b98 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotifications.js @@ -0,0 +1,145 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { + EuiSpacer, + EuiButton, + EuiText, + EuiAccordion, + EuiHorizontalRule, + EuiButtonIcon, +} from '@elastic/eui'; +import TriggerNotificationsContent from './TriggerNotificationsContent'; +import { titleTemplate } from './DefineCompositeLevelTrigger'; +import { MAX_CHANNELS_RESULT_SIZE, OS_NOTIFICATION_PLUGIN } from '../../../../utils/constants'; +import { CHANNEL_TYPES } from '../../utils/constants'; + +const TriggerNotifications = ({ + httpClient, + triggerActions = [], + plugins, + notifications, + notificationService, + triggerValues, + triggerIndex, + formikFieldPath, +}) => { + const [actions, setActions] = useState([]); + const [options, setOptions] = useState([]); + + useEffect(() => { + let newActions = [...triggerActions]; + if (_.isEmpty(newActions)) + newActions = [ + { + name: '', + id: '', + }, + ]; + + setActions(newActions); + + getChannels().then((channels) => setOptions(channels)); + }, [triggerValues, plugins]); + + const getChannels = async () => { + const hasNotificationPlugin = plugins.indexOf(OS_NOTIFICATION_PLUGIN) !== -1; + + let channels = []; + let index = 0; + const getChannels = async () => { + const getChannelsQuery = { + from_index: index, + max_items: MAX_CHANNELS_RESULT_SIZE, + config_type: CHANNEL_TYPES, + sort_field: 'name', + sort_order: 'asc', + }; + + const channelsResponse = await notificationService.getChannels(getChannelsQuery); + + channels = channels.concat( + channelsResponse.items.map((channel) => ({ + label: channel.name, + value: channel.config_id, + type: channel.config_type, + description: channel.description, + })) + ); + + if (channelsResponse.total && channels.length < channelsResponse.total) { + index += MAX_CHANNELS_RESULT_SIZE; + await getChannels(); + } + }; + + if (hasNotificationPlugin) { + await getChannels(); + } + + return channels; + }; + + const onAddNotification = () => { + const newActions = [...actions]; + newActions.push({ + label: '', + value: '', + }); + setActions(newActions); + }; + + const onRemoveNotification = (idx) => { + const newActions = [...actions]; + newActions.splice(idx, 1); + _.set(triggerValues, `${formikFieldPath}actions`, newActions); + setActions(newActions); + }; + + return ( + + {titleTemplate('Notifications')} + + {actions.length + ? actions.map((action, actionIndex) => ( + {`Notification ${actionIndex + 1}`}} + paddingSize={'s'} + extraAction={ + onRemoveNotification(actionIndex)} + size={'s'} + /> + } + > + + + )) + : null} + {actions.length ? : null} + onAddNotification()}>Add notification + + ); +}; + +export default TriggerNotifications; diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotifications.test.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotifications.test.js new file mode 100644 index 000000000..d06015c51 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotifications.test.js @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; +import TriggerNotifications from './TriggerNotifications'; + +describe('TriggerNotifications', () => { + test('renders', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotificationsContent.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotificationsContent.js new file mode 100644 index 000000000..839bced55 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotificationsContent.js @@ -0,0 +1,130 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { FormikComboBox } from '../../../../components/FormControls'; +import NotificationConfigDialog from './NotificationConfigDialog'; +import _ from 'lodash'; +import { FORMIK_INITIAL_ACTION_VALUES, MANAGE_CHANNELS_PATH } from '../../utils/constants'; +import { NOTIFY_OPTIONS_VALUES } from '../../components/Action/actions/Message'; +import { required } from '../../../../utils/validate'; + +const TriggerNotificationsContent = ({ + action, + options, + actionIndex, + triggerIndex, + triggerValues, + httpClient, + notifications, + hasNotifications, + formikFieldPath, +}) => { + const [selected, setSelected] = useState([]); + const [isModalVisible, setIsModalVisible] = useState(false); + + useEffect(() => { + action?.destination_id && + setSelected([ + { + label: action.name, + value: action.destination_id, + type: action.config_type, + description: action.description, + }, + ]); + }, [action]); + + const onChange = (selectedOptions) => { + setSelected(selectedOptions); + + const initialActionValues = _.cloneDeep(FORMIK_INITIAL_ACTION_VALUES); + _.set(triggerValues, `${formikFieldPath}actions[${actionIndex}]`, { + ...initialActionValues, + destination_id: selectedOptions[0]?.value, + name: selectedOptions[0]?.label, + subject_template: { + lang: 'mustache', + source: 'Monitor {{ctx.monitor.name}} triggered an alert {{ctx.trigger.name}}', + }, + action_execution_policy: { + action_execution_scope: NOTIFY_OPTIONS_VALUES.PER_ALERT, + }, + }); + }; + + const onBlur = (e, field, form) => { + form.setFieldTouched(field.name, true); + form.setFieldError(field.name, required(form.values[field.name])); + }; + + const showConfig = () => setIsModalVisible(true); + + return ( + + + + + form.touched[name] && !selected?.length, + error: (name, form) => form.touched[name] && !selected?.length && 'Required.', + }} + inputProps={{ + placeholder: 'Select a channel to get notified', + options: options, + selectedOptions: selected, + onChange: (selectedOptions) => onChange(selectedOptions), + onBlur: (e, field, form) => onBlur(e, field, form), + singleSelection: { asPlainText: true }, + }} + /> + + + window.open(httpClient.basePath.prepend(MANAGE_CHANNELS_PATH))} + > + Manage channels + + + + {selected.length ? ( + + + showConfig()}> + Configure notification + + + ) : null} + + {isModalVisible && ( + setIsModalVisible(false)} + triggerValues={triggerValues} + httpClient={httpClient} + notifications={notifications} + actionIndex={actionIndex} + triggerIndex={triggerIndex} + formikFieldPath={formikFieldPath} + /> + )} + + ); +}; + +export default TriggerNotificationsContent; diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotificationsContent.test.js b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotificationsContent.test.js new file mode 100644 index 000000000..1a4521756 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/TriggerNotificationsContent.test.js @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'enzyme'; + +import { Formik } from 'formik'; +import { FORMIK_INITIAL_VALUES } from '../../../CreateMonitor/containers/CreateMonitor/utils/constants'; +import TriggerNotificationsContent from './TriggerNotificationsContent'; + +describe('TriggerNotificationsContent', () => { + test('renders without notifications', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); + test('renders with notifications', () => { + const component = ( + {}}> + + + ); + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/__snapshots__/DefineCompositeLevelTrigger.test.js.snap b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/__snapshots__/DefineCompositeLevelTrigger.test.js.snap new file mode 100644 index 000000000..c1e75b0e4 --- /dev/null +++ b/public/pages/CreateTrigger/containers/DefineCompositeLevelTrigger/__snapshots__/DefineCompositeLevelTrigger.test.js.snap @@ -0,0 +1,332 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefineCompositeLevelTrigger renders 1`] = ` +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+