From a8eebf8cb9919039906bc6e8a6aaaf0be584bf58 Mon Sep 17 00:00:00 2001 From: AWSHurneyt Date: Thu, 14 Apr 2022 12:37:43 -0700 Subject: [PATCH 1/7] Removed the Beta label from the bug report template. (#196) Signed-off-by: AWSHurneyt --- .github/ISSUE_TEMPLATE/bug_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md index 8af6ebb52..183ab2c49 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.md +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -2,7 +2,7 @@ name: 🐛 Bug report about: Create a report to help us improve title: "[BUG]" -labels: 'bug, untriaged, Beta' +labels: 'bug, untriaged' assignees: '' --- From eca6b409dcdd1a7acce7d8f458e7d43756ab85e4 Mon Sep 17 00:00:00 2001 From: "Daniel Doubrovkine (dB.)" Date: Fri, 15 Apr 2022 18:47:21 -0400 Subject: [PATCH 2/7] Incremented version to 2.0-rc1. (#216) Signed-off-by: dblock --- .github/workflows/cypress-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cypress-workflow.yml b/.github/workflows/cypress-workflow.yml index 5459f2fbe..55b0a28e9 100644 --- a/.github/workflows/cypress-workflow.yml +++ b/.github/workflows/cypress-workflow.yml @@ -10,7 +10,7 @@ on: - 1.x env: OPENSEARCH_DASHBOARDS_VERSION: 'main' - OPENSEARCH_VERSION: '2.0.0-alpha1-SNAPSHOT' + OPENSEARCH_VERSION: '2.0.0-rc1-SNAPSHOT' jobs: tests: name: Run Cypress E2E tests From e77eb5f6864964ebb97f33026708b44c5314bce4 Mon Sep 17 00:00:00 2001 From: "Daniel Doubrovkine (dB.)" Date: Fri, 15 Apr 2022 19:50:24 -0400 Subject: [PATCH 3/7] Updated issue templates from .github. (#205) Signed-off-by: dblock --- .github/ISSUE_TEMPLATE/bug_report.md | 31 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 7 +++++ .github/ISSUE_TEMPLATE/feature_request.md | 17 ++++++------- 3 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..29eddb95e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: 🐛 Bug report +about: Create a report to help us improve +title: '[BUG]' +labels: 'bug, untriaged' +assignees: '' +--- + +**What is the bug?** +A clear and concise description of the bug. + +**How can one reproduce the bug?** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**What is the expected behavior?** +A clear and concise description of what you expected to happen. + +**What is your host/environment?** + - OS: [e.g. iOS] + - Version [e.g. 22] + - Plugins + +**Do you have any screenshots?** +If applicable, add screenshots to help explain your problem. + +**Do you have any additional context?** +Add any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a8199a104 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: OpenSearch Community Support + url: https://discuss.opendistrocommunity.dev/ + about: Please ask and answer questions here. + - name: AWS/Amazon Security + url: https://aws.amazon.com/security/vulnerability-reporting/ + about: Please report security vulnerabilities here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2791b8081..6198f3383 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,19 +1,18 @@ --- name: 🎆 Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement +about: Request a feature in this project +title: '[FEATURE]' +labels: 'enhancement, untriaged' assignees: '' --- +**Is your feature request related to a problem?** +A clear and concise description of what the problem is, e.g. _I'm always frustrated when [...]_ -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** +**What solution would you like?** A clear and concise description of what you want to happen. -**Describe alternatives you've considered** +**What alternatives have you considered?** A clear and concise description of any alternative solutions or features you've considered. -**Additional context** +**Do you have any additional context?** Add any other context or screenshots about the feature request here. \ No newline at end of file From 1b6bd84f87e51f17e6cabeef372da80d3fb9f6d1 Mon Sep 17 00:00:00 2001 From: AWSHurneyt Date: Mon, 18 Apr 2022 09:21:42 -0700 Subject: [PATCH 4/7] Fixed a bug that was causing the UX to reset visual editor trigger conditions to their default values when a trigger name contained periods. (#204) * Fixed a bug that was causing the UX to reset visual editor trigger conditions to their default values when a trigger name contained periods. Signed-off-by: AWSHurneyt * Fixed flaky test. Seems the fix was overwritten by changes to the file made in this bug fix. Signed-off-by: AWSHurneyt --- .../integration/query_level_monitor_spec.js | 136 +++++++++++++++++- .../TriggerExpressions/TriggerExpressions.js | 12 +- .../CreateTrigger/utils/formikToTrigger.js | 4 +- .../CreateTrigger/utils/triggerToFormik.js | 83 ++++++----- .../containers/DefineTrigger/DefineTrigger.js | 2 +- 5 files changed, 194 insertions(+), 43 deletions(-) diff --git a/cypress/integration/query_level_monitor_spec.js b/cypress/integration/query_level_monitor_spec.js index d68ee992d..e31a23bae 100644 --- a/cypress/integration/query_level_monitor_spec.js +++ b/cypress/integration/query_level_monitor_spec.js @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PLUGIN_NAME } from '../support/constants'; +import _ from 'lodash'; +import { INDEX, PLUGIN_NAME } from '../support/constants'; import sampleQueryLevelMonitor from '../fixtures/sample_query_level_monitor'; import sampleQueryLevelMonitorWithAlwaysTrueTrigger from '../fixtures/sample_query_level_monitor_with_always_true_trigger'; import sampleDestination from '../fixtures/sample_destination_custom_webhook.json'; @@ -13,6 +14,54 @@ const UPDATED_MONITOR = 'updated_query_level_monitor'; const SAMPLE_MONITOR_WITH_ANOTHER_NAME = 'sample_query_level_monitor_with_always_true_trigger'; const SAMPLE_TRIGGER = 'sample_trigger'; const SAMPLE_ACTION = 'sample_action'; +const SAMPLE_DESTINATION = 'sample_destination'; + +const addVisualQueryLevelTrigger = ( + triggerName, + triggerIndex, + isEdit = true, + thresholdEnum, + thresholdValue +) => { + // Click 'Add trigger' button + cy.contains('Add trigger', { timeout: 20000 }).click({ force: true }); + + if (isEdit) { + // TODO: Passing button props in EUI accordion was added in newer versions (31.7.0+). + // If this ever becomes available, it can be used to pass data-test-subj for the button. + // Since the above is currently not possible, referring to the accordion button using its content + cy.get('button').contains('New trigger').click(); + } + + // Type in the trigger name + cy.get(`input[name="triggerDefinitions[${triggerIndex}].name"]`).type(triggerName); + + // Type in the condition thresholdEnum + cy.get( + `[data-test-subj="triggerDefinitions[${triggerIndex}].thresholdEnum_conditionEnumField"]` + ).select(thresholdEnum); + + // Type in the condition thresholdValue + cy.get( + `[data-test-subj="triggerDefinitions[${triggerIndex}].thresholdValue_conditionValueField"]` + ) + .clear() + .type(`${thresholdValue}{enter}`); + + // FIXME: Temporarily removing destination creation to resolve flakiness. It seems deleteAllDestinations() + // is executing mid-testing. Need to further investigate a more ideal solution. Destination creation should + // ideally take place in the before() block, and clearing should occur in the after() block. + // // Type in the action name + // cy.get( + // `input[name="triggerDefinitions[${triggerIndex}].actions.0.name"]` + // ).type(`${triggerName}-${triggerIndex}-action1`, { force: true }); + // + // // Click the combo box to list all the destinations + // // Using key typing instead of clicking the menu option to avoid occasional failure + // cy.get(`[data-test-subj="triggerDefinitions[${triggerIndex}].actions.0_actionDestination"]`) + // .click({ force: true }) + // .type(`${SAMPLE_DESTINATION}{downarrow}{enter}`); +}; describe('Query-Level Monitors', () => { beforeEach(() => { @@ -180,9 +229,94 @@ describe('Query-Level Monitors', () => { }); }); + describe('can have triggers', () => { + before(() => { + cy.deleteAllMonitors(); + cy.loadSampleEcommerceData(); + cy.createMonitor(sampleQueryLevelMonitor); + }); + + it('with names that contain periods', () => { + const triggers = _.orderBy( + [ + { name: '.trigger', enum: 'ABOVE' }, + { name: 'trigger.', enum: 'BELOW' }, + { name: '.trigger.', enum: 'EXACTLY' }, + { name: '..trigger', enum: 'ABOVE' }, + { name: 'trigger..', enum: 'BELOW' }, + { name: '.trigger..', enum: 'EXACTLY' }, + { name: '..trigger.', enum: 'ABOVE' }, + { name: '.trigger.name', enum: 'BELOW' }, + { name: 'trigger.name.', enum: 'EXACTLY' }, + { name: '.trigger.name.', enum: 'ABOVE' }, + ], + (trigger) => trigger.name + ); + + // Confirm we can see the created monitor in the list + cy.contains(SAMPLE_MONITOR); + + // Select the existing monitor + cy.get('a').contains(SAMPLE_MONITOR).click(); + + // Click Edit button + cy.contains('Edit').click({ force: true }); + + // Wait for input to load and then type in the new monitor name + cy.get('input[name="name"]').should('have.value', SAMPLE_MONITOR); + + // Select visual editor + cy.get('[data-test-subj="visualEditorRadioCard"]').click(); + + // Wait for input to load and then type in the index name + cy.get('#index').type(`{backspace}${INDEX.SAMPLE_DATA_ECOMMERCE}{enter}`, { force: true }); + + // Enter the time field + cy.get('#timeField').type('order_date{downArrow}{enter}', { force: true }); + + // Add the test triggers + // For simplicity, the 'value' number is used in this test for the thresholdValue, and the trigger index number. + for (let i = 0; i < triggers.length; i++) { + const trigger = triggers[i]; + triggers[i].value = i; + addVisualQueryLevelTrigger(trigger.name, i, true, `IS ${trigger.enum}`, `${i}`); + } + + // Click Update button + cy.get('button').contains('Update').last().click({ force: true }); + + // Confirm we can see the correct number of rows in the trigger list by checking element + cy.contains(`This table contains ${triggers.length} rows`, { timeout: 20000 }); + + // Click Edit button + cy.contains('Edit').click({ force: true }); + + triggers.forEach((trigger) => { + const triggerIndex = trigger.value; + // Click the trigger accordion to expand it + cy.get(`[data-test-subj="triggerDefinitions[${triggerIndex}]._triggerAccordion"]`).click(); + + // Confirm each trigger exists with the expected name and values + cy.get(`input[name="triggerDefinitions[${triggerIndex}].name"]`).should( + 'have.value', + trigger.name + ); + cy.get( + `[data-test-subj="triggerDefinitions[${triggerIndex}].thresholdEnum_conditionEnumField"]` + ).should('have.value', trigger.enum); + cy.get( + `[data-test-subj="triggerDefinitions[${triggerIndex}].thresholdValue_conditionValueField"]` + ).should('have.value', `${trigger.value}`); + }); + }); + }); + after(() => { // Delete all existing monitors and destinations cy.deleteAllMonitors(); cy.deleteAllDestinations(); + + // Delete sample data + cy.deleteIndexByName(`${INDEX.SAMPLE_DATA_ECOMMERCE}`); }); }); diff --git a/public/pages/CreateTrigger/components/TriggerExpressions/TriggerExpressions.js b/public/pages/CreateTrigger/components/TriggerExpressions/TriggerExpressions.js index a96414e7a..294597db4 100644 --- a/public/pages/CreateTrigger/components/TriggerExpressions/TriggerExpressions.js +++ b/public/pages/CreateTrigger/components/TriggerExpressions/TriggerExpressions.js @@ -22,7 +22,6 @@ class TriggerExpressions extends Component { render() { const { label, keyFieldName, valueFieldName } = this.props; - return ( @@ -33,7 +32,11 @@ class TriggerExpressions extends Component { isInvalid={touched.thresholdEnum && !!errors.thresholdEnum} error={errors.thresholdEnum} > - + )} @@ -46,7 +49,10 @@ class TriggerExpressions extends Component { isInvalid={touched.thresholdValue && !!errors.thresholdValue} error={errors.thresholdValue} > - + )} diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js index 845499f18..b2d39e2bd 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/formikToTrigger.js @@ -150,7 +150,7 @@ export function formikToTriggerUiMetadata(values, monitorUiMetadata) { }; } - _.set(queryLevelTriggersUiMetadata, `${trigger.name}`, triggerMetadata); + queryLevelTriggersUiMetadata[trigger.name] = triggerMetadata; }); return queryLevelTriggersUiMetadata; @@ -161,7 +161,7 @@ export function formikToTriggerUiMetadata(values, monitorUiMetadata) { value: condition.thresholdValue, enum: condition.thresholdEnum, })); - _.set(bucketLevelTriggersUiMetadata, `${trigger.name}`, triggerMetadata); + bucketLevelTriggersUiMetadata[trigger.name] = triggerMetadata; }); return bucketLevelTriggersUiMetadata; } diff --git a/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js b/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js index 66c4b60dc..5c9e7e5e8 100644 --- a/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js +++ b/public/pages/CreateTrigger/containers/CreateTrigger/utils/triggerToFormik.js @@ -46,37 +46,47 @@ export function queryLevelTriggerToFormik(trigger, monitor) { min_time_between_executions: minTimeBetweenExecutions, rolling_window_size: rollingWindowSize, } = trigger.query_level_trigger; + + const triggersUiMetadata = _.get(monitor, 'ui_metadata.triggers', {}); const thresholdEnum = _.get( - monitor, - `ui_metadata.triggers[${name}].enum`, + triggersUiMetadata[name], + 'enum', FORMIK_INITIAL_TRIGGER_VALUES.thresholdEnum ); const thresholdValue = _.get( - monitor, - `ui_metadata.triggers[${name}].value`, + triggersUiMetadata[name], + 'value', FORMIK_INITIAL_TRIGGER_VALUES.thresholdValue ); + + const adTriggerMetadata = _.get(triggersUiMetadata[name], 'adTriggerMetadata', {}); + /* + If trigger type doesn't exist fallback to query trigger with following reasons + 1. User has changed monitory type from normal monitor to AD monitor. + 2. User has created / updated from API and visiting OpenSearch Dashboards to do other operations. + */ + const triggerType = _.get(adTriggerMetadata, 'triggerType', TRIGGER_TYPE.ALERT_TRIGGER); const anomalyConfidenceThresholdValue = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyConfidence.value`, + adTriggerMetadata, + 'anomalyConfidence.value', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyConfidenceThresholdValue ); const anomalyConfidenceThresholdEnum = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyConfidence.enum`, + adTriggerMetadata, + 'anomalyConfidence.enum', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyConfidenceThresholdEnum ); const anomalyGradeThresholdValue = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyGrade.value`, + adTriggerMetadata, + 'anomalyGrade.value', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyGradeThresholdValue ); const anomalyGradeThresholdEnum = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyGrade.enum`, + adTriggerMetadata, + 'anomalyGrade.enum', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyGradeThresholdEnum ); - const triggerType = _.get(monitor, `ui_metadata.triggers[${name}].adTriggerMetadata.triggerType`); + return { ..._.cloneDeep(FORMIK_INITIAL_TRIGGER_VALUES), id, @@ -89,11 +99,7 @@ export function queryLevelTriggerToFormik(trigger, monitor) { thresholdEnum, thresholdValue, anomalyDetector: { - /*If trigger type doesn't exist fallback to query trigger with following reasons - 1. User has changed monitory type from normal monitor to AD monitor. - 2. User has created / updated from API and visiting OpenSearch Dashboards to do other operations. - */ - triggerType: triggerType ? triggerType : TRIGGER_TYPE.ALERT_TRIGGER, + triggerType, anomalyGradeThresholdValue, anomalyGradeThresholdEnum, anomalyConfidenceThresholdValue, @@ -118,37 +124,46 @@ export function bucketLevelTriggerToFormik(trigger, monitor) { const triggerConditions = getBucketLevelTriggerConditions(condition); const where = getWhereExpression(composite_agg_filter); + const triggersUiMetadata = _.get(monitor, 'ui_metadata.triggers', {}); const thresholdEnum = _.get( - monitor, - `ui_metadata.triggers[${name}].enum`, + triggersUiMetadata[name], + 'enum', FORMIK_INITIAL_TRIGGER_VALUES.thresholdEnum ); const thresholdValue = _.get( - monitor, - `ui_metadata.triggers[${name}].value`, + triggersUiMetadata, + 'value', FORMIK_INITIAL_TRIGGER_VALUES.thresholdValue ); + + const adTriggerMetadata = _.get(triggersUiMetadata[name], 'adTriggerMetadata', {}); + /* + If trigger type doesn't exist fallback to query trigger with following reasons + 1. User has changed monitory type from normal monitor to AD monitor. + 2. User has created / updated from API and visiting OpenSearch Dashboards to do other operations. + */ + const triggerType = _.get(adTriggerMetadata, 'triggerType', TRIGGER_TYPE.ALERT_TRIGGER); const anomalyConfidenceThresholdValue = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyConfidence.value`, + adTriggerMetadata, + 'anomalyConfidence.value', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyConfidenceThresholdValue ); const anomalyConfidenceThresholdEnum = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyConfidence.enum`, + adTriggerMetadata, + 'anomalyConfidence.enum', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyConfidenceThresholdEnum ); const anomalyGradeThresholdValue = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyGrade.value`, + adTriggerMetadata, + 'anomalyGrade.value', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyGradeThresholdValue ); const anomalyGradeThresholdEnum = _.get( - monitor, - `ui_metadata.triggers[${name}].adTriggerMetadata.anomalyGrade.enum`, + adTriggerMetadata, + 'anomalyGrade.enum', FORMIK_INITIAL_TRIGGER_VALUES.anomalyDetector.anomalyGradeThresholdEnum ); - const triggerType = _.get(monitor, `ui_metadata.triggers[${name}].adTriggerMetadata.triggerType`); + return { ..._.cloneDeep(FORMIK_INITIAL_TRIGGER_VALUES), id, @@ -164,11 +179,7 @@ export function bucketLevelTriggerToFormik(trigger, monitor) { thresholdValue, where, anomalyDetector: { - /*If trigger type doesn't exist fallback to query trigger with following reasons - 1. User has changed monitory type from normal monitor to AD monitor. - 2. User has created / updated from API and visiting OpenSearch Dashboards to do other operations. - */ - triggerType: triggerType ? triggerType : TRIGGER_TYPE.ALERT_TRIGGER, + triggerType, anomalyGradeThresholdValue, anomalyGradeThresholdEnum, anomalyConfidenceThresholdValue, diff --git a/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js b/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js index cf77a8d50..0bef72907 100644 --- a/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js +++ b/public/pages/CreateTrigger/containers/DefineTrigger/DefineTrigger.js @@ -201,7 +201,7 @@ class DefineTrigger extends Component { +

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

} From 259335a44ce58d3e8a5fc7ae7d76f89670bec950 Mon Sep 17 00:00:00 2001 From: AWSHurneyt Date: Mon, 18 Apr 2022 19:33:21 -0700 Subject: [PATCH 5/7] Implemented UX support for configuring doc level monitors. (#218) * Implemented UX support for configuring doc level monitors. Signed-off-by: AWSHurneyt * Fixed unit tests. Signed-off-by: AWSHurneyt * Update snapshot files Signed-off-by: Ashish Agrawal * Increased max trigger conditions for doc level triggers. Refactored style of perform action section of Action element. Signed-off-by: AWSHurneyt * Updated snapshot. Signed-off-by: AWSHurneyt * Updated snapshot. Signed-off-by: AWSHurneyt * Updated snapshot. Signed-off-by: AWSHurneyt * Updated snapshot. Signed-off-by: AWSHurneyt Co-authored-by: Ashish Agrawal --- .../AlertsDashboardFlyoutComponent.js | 418 +++++++++++------- ...lertsDashboardFlyoutComponent.test.js.snap | 285 ++++++------ .../ConfigureDocumentLevelQueries.js | 68 +++ .../ConfigureDocumentLevelQueryTags.js | 67 +++ .../DocumentLevelQuery.js | 156 +++++++ .../DocumentLevelQueryTag.js | 123 ++++++ .../components/MonitorType/MonitorType.js | 52 ++- .../__snapshots__/MonitorType.test.js.snap | 76 +++- .../AnomalyDetector.test.js.snap | 12 + .../containers/CreateMonitor/CreateMonitor.js | 12 +- .../__snapshots__/CreateMonitor.test.js.snap | 2 + .../formikToMonitor.test.js.snap | 13 +- .../CreateMonitor/utils/constants.js | 28 ++ .../CreateMonitor/utils/formikToMonitor.js | 93 +++- .../CreateMonitor/utils/monitorToFormik.js | 84 +++- .../utils/monitorToFormik.test.js | 7 +- .../containers/DataSource/DataSource.js | 11 +- .../__snapshots__/DataSource.test.js.snap | 1 + .../containers/DefineMonitor/DefineMonitor.js | 132 ++++-- .../__snapshots__/DefineMonitor.test.js.snap | 4 +- .../DefineMonitor/utils/searchRequests.js | 27 +- .../containers/MonitorIndex/MonitorIndex.js | 8 +- .../__snapshots__/MonitorIndex.test.js.snap | 12 + .../components/Action/actions/Message.js | 5 +- .../__snapshots__/Message.test.js.snap | 14 + .../BucketLevelTriggerExpression.js | 12 +- .../components/BucketLevelTriggerGraph.js | 6 +- .../TriggerExpressions/TriggerExpressions.js | 7 +- .../components/TriggerQuery/TriggerQuery.js | 9 +- .../ConfigureTriggers/ConfigureTriggers.js | 169 +++++-- .../CreateTrigger/CreateTrigger.js | 4 +- .../CreateTrigger/utils/constants.js | 2 + .../CreateTrigger/utils/formikToTrigger.js | 69 ++- .../CreateTrigger/utils/triggerToFormik.js | 38 +- .../DefineBucketLevelTrigger.js | 8 +- .../DefineDocumentLevelTrigger.js | 313 +++++++++++++ .../DocumentLevelTriggerExpression.js | 115 +++++ .../containers/DefineTrigger/DefineTrigger.js | 18 +- public/pages/CreateTrigger/utils/constants.js | 21 + .../AcknowledgeAlertsModal.test.js.snap | 2 + .../FindingsDashboard/FindingFlyout.js | 146 ++++++ .../FindingsDashboard/QueriesPopover.js | 36 ++ .../components/FindingsDashboard/utils.js | 114 +++++ .../Dashboard/containers/FindingsDashboard.js | 195 ++++++++ public/pages/Dashboard/utils/helpers.js | 5 +- public/pages/Dashboard/utils/helpers.test.js | 3 +- public/pages/Dashboard/utils/tableUtils.js | 2 +- .../MonitorOverview/utils/getOverviewStats.js | 5 +- .../containers/MonitorDetails.js | 112 ++++- .../containers/Triggers/Triggers.js | 19 +- public/utils/constants.js | 1 + server/clusters/alerting/alertingPlugin.js | 9 + server/plugin.js | 6 +- server/routes/findings.js | 27 ++ server/routes/index.js | 3 +- server/services/AlertService.js | 25 +- server/services/FindingService.js | 95 ++++ server/services/index.js | 2 + 58 files changed, 2774 insertions(+), 534 deletions(-) create mode 100644 public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueries.js create mode 100644 public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/ConfigureDocumentLevelQueryTags.js create mode 100644 public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQuery.js create mode 100644 public/pages/CreateMonitor/components/DocumentLevelMonitorQueries/DocumentLevelQueryTag.js create mode 100644 public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DefineDocumentLevelTrigger.js create mode 100644 public/pages/CreateTrigger/containers/DefineDocumentLevelTrigger/DocumentLevelTriggerExpression.js create mode 100644 public/pages/Dashboard/components/FindingsDashboard/FindingFlyout.js create mode 100644 public/pages/Dashboard/components/FindingsDashboard/QueriesPopover.js create mode 100644 public/pages/Dashboard/components/FindingsDashboard/utils.js create mode 100644 public/pages/Dashboard/containers/FindingsDashboard.js create mode 100644 server/routes/findings.js create mode 100644 server/services/FindingService.js diff --git a/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js b/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js index 9d72546d8..730e2b535 100644 --- a/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js +++ b/public/components/Flyout/flyouts/components/AlertsDashboardFlyoutComponent.js @@ -14,6 +14,8 @@ import { EuiIcon, EuiLink, EuiSpacer, + EuiTab, + EuiTabs, EuiText, } from '@elastic/eui'; import { getTime } from '../../../../pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats'; @@ -27,7 +29,6 @@ import { SEARCH_TYPE, } from '../../../../utils/constants'; import { TRIGGER_TYPE } from '../../../../pages/CreateTrigger/containers/CreateTrigger/utils/constants'; -import { SEVERITY_OPTIONS } from '../../../../pages/CreateTrigger/containers/DefineTrigger/DefineTrigger'; import { UNITS_OF_TIME } from '../../../../pages/CreateMonitor/components/MonitorExpressions/expressions/utils/constants'; import { DEFAULT_WHERE_EXPRESSION_TEXT } from '../../../../pages/CreateMonitor/components/MonitorExpressions/expressions/utils/whereHelpers'; import { backendErrorNotification } from '../../../../utils/helpers'; @@ -45,14 +46,18 @@ import { queryColumns } from '../../../../pages/Dashboard/utils/tableUtils'; import { DEFAULT_PAGE_SIZE_OPTIONS } from '../../../../pages/Monitors/containers/Monitors/utils/constants'; import queryString from 'query-string'; import { MAX_ALERT_COUNT } from '../../../../pages/Dashboard/utils/constants'; +import { SEVERITY_OPTIONS } from '../../../../pages/CreateTrigger/utils/constants'; +import { TABLE_TAB_IDS } from '../../../../pages/Dashboard/components/FindingsDashboard/utils'; +import FindingsDashboard from '../../../../pages/Dashboard/containers/FindingsDashboard'; export const DEFAULT_NUM_FLYOUT_ROWS = 10; export default class AlertsDashboardFlyoutComponent extends Component { constructor(props) { super(props); - const { location, monitor_id } = this.props; - + const { location, monitors, monitor_id } = this.props; + const monitor = _.get(_.find(monitors, { _id: monitor_id }), '_source'); + const monitorType = _.get(monitor, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL); const { alertState, from, @@ -67,8 +72,9 @@ export default class AlertsDashboardFlyoutComponent extends Component { alerts: [], alertState: alertState, loading: true, - monitors: [], + monitor: monitor, monitorIds: [monitor_id], + monitorType: monitorType, page: Math.floor(from / size), search: search, selectable: true, @@ -77,6 +83,8 @@ export default class AlertsDashboardFlyoutComponent extends Component { size: DEFAULT_NUM_FLYOUT_ROWS, sortDirection: sortDirection, sortField: sortField, + tabContent: undefined, + tabId: TABLE_TAB_IDS.ALERTS.id, totalAlerts: 0, }; } @@ -129,6 +137,12 @@ export default class AlertsDashboardFlyoutComponent extends Component { monitorIds ); } + const { monitorType } = this.state; + if ( + monitorType === MONITOR_TYPE.DOC_LEVEL && + !_.isEqual(prevState.selectedItems, this.state.selectedItems) + ) + this.setState({ tabContent: this.renderAlertsTable() }); } getBucketLevelGraphConditions = (trigger) => { @@ -153,7 +167,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { }; getAlerts = async () => { - this.setState({ ...this.state, loading: true }); + this.setState({ loading: true }); const { from, search, @@ -193,9 +207,9 @@ export default class AlertsDashboardFlyoutComponent extends Component { console.log('error getting alerts:', resp); backendErrorNotification(notifications, 'get', 'alerts', resp.err); } + this.setState({ tabContent: this.renderAlertsTable() }); }); - - this.setState({ ...this.state, loading: false }); + this.setState({ loading: false }); }; acknowledgeAlerts = async () => { @@ -249,7 +263,8 @@ export default class AlertsDashboardFlyoutComponent extends Component { alertState, monitorIds ); - this.setState({ ...this.state, selectedItems: [] }); + this.setState({ selectedItems: [], tabContent: undefined }); + this.setState({ tabContent: this.renderAlertsTable() }); this.props.refreshDashboard(); }; @@ -284,89 +299,24 @@ export default class AlertsDashboardFlyoutComponent extends Component { this.setState({ alerts }); }; - render() { - const { - last_notification_time, - loadingMonitors, - monitors, - monitor_id, - monitor_name, - start_time, - triggerID, - trigger_name, - } = this.props; - const monitor = _.get(_.find(monitors, { _id: monitor_id }), '_source'); - const monitorType = _.get(monitor, 'monitor_type', MONITOR_TYPE.QUERY_LEVEL); - const searchType = _.get(monitor, 'ui_metadata.search.searchType', SEARCH_TYPE.GRAPH); - const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID); - - const triggerType = - monitorType === MONITOR_TYPE.BUCKET_LEVEL - ? TRIGGER_TYPE.BUCKET_LEVEL - : TRIGGER_TYPE.QUERY_LEVEL; - - let trigger = _.get(monitor, 'triggers', []).find( - (trigger) => trigger[triggerType].id === triggerID - ); - trigger = _.get(trigger, triggerType); + getTriggerType() { + const { monitorType } = this.state; + switch (monitorType) { + case MONITOR_TYPE.BUCKET_LEVEL: + return TRIGGER_TYPE.BUCKET_LEVEL; + case MONITOR_TYPE.DOC_LEVEL: + return TRIGGER_TYPE.DOC_LEVEL; + default: + return TRIGGER_TYPE.QUERY_LEVEL; + } + } - const severity = _.get(trigger, 'severity'); + renderAlertsTable() { + const { trigger_name } = this.props; + const { monitor, monitorType } = this.state; + const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID); const groupBy = _.get(monitor, MONITOR_GROUP_BY); - const condition = - monitorType === MONITOR_TYPE.BUCKET_LEVEL && searchType === SEARCH_TYPE.GRAPH - ? this.getBucketLevelGraphConditions(trigger) - : _.get(trigger, 'condition.script.source', '-'); - - const filters = - monitorType === MONITOR_TYPE.BUCKET_LEVEL && searchType === SEARCH_TYPE.GRAPH - ? this.getBucketLevelGraphFilter(trigger) - : '-'; - - const bucketValue = _.get(monitor, 'ui_metadata.search.bucketValue'); - let bucketUnitOfTime = _.get(monitor, 'ui_metadata.search.bucketUnitOfTime'); - UNITS_OF_TIME.map((entry) => { - if (entry.value === bucketUnitOfTime) bucketUnitOfTime = entry.text; - }); - const timeRangeForLast = - bucketValue !== undefined && !_.isEmpty(bucketUnitOfTime) - ? `${bucketValue} ${bucketUnitOfTime}` - : '-'; - - const actions = () => { - const { selectedItems } = this.state; - const actions = [ - - Acknowledge - , - ]; - if (!_.isEmpty(detectorId)) { - actions.unshift( - - View detector - - ); - } - return actions; - }; - - const getItemId = (item) => { - switch (monitorType) { - case MONITOR_TYPE.QUERY_LEVEL: - case MONITOR_TYPE.CLUSTER_METRICS: - return `${item.id}-${item.version}`; - case MONITOR_TYPE.BUCKET_LEVEL: - return item.id; - } - }; - const { alerts, alertState, @@ -374,6 +324,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { page, search, selectable, + selectedItems, severityLevel, size, sortDirection, @@ -381,16 +332,26 @@ export default class AlertsDashboardFlyoutComponent extends Component { totalAlerts, } = this.state; - const columnType = () => { - let columns = []; + const getItemId = (item) => { switch (monitorType) { case MONITOR_TYPE.QUERY_LEVEL: case MONITOR_TYPE.CLUSTER_METRICS: - columns = queryColumns; - break; + case MONITOR_TYPE.DOC_LEVEL: + return `${item.id}-${item.version}`; + case MONITOR_TYPE.BUCKET_LEVEL: + return item.id; + } + }; + + const columnType = () => { + let columns; + switch (monitorType) { case MONITOR_TYPE.BUCKET_LEVEL: columns = insertGroupByColumn(groupBy); break; + default: + columns = queryColumns; + break; } return removeColumns(['severity', 'trigger_name'], columns); }; @@ -403,6 +364,7 @@ export default class AlertsDashboardFlyoutComponent extends Component { }; const selection = { + initialSelected: selectedItems, onSelectionChange: this.onSelectionChange, selectable: (item) => item.state === ALERT_STATE.ACTIVE, selectableMessage: (selectable) => @@ -416,8 +378,153 @@ export default class AlertsDashboardFlyoutComponent extends Component { }, }; + const actions = () => { + const actions = [ + + Acknowledge + , + ]; + if (!_.isEmpty(detectorId)) { + actions.unshift( + + View detector + + ); + } + return actions; + }; + const trimmedAlerts = alerts.slice(page * size, page * size + size); + return ( + + + + + + ); + } + renderFindingsTable() { + const { httpClient, history, location, monitor_id, notifications } = this.props; + return ( + + ); + } + + renderTableTabs() { + const { tabId } = this.state; + const tabs = [ + { ...TABLE_TAB_IDS.ALERTS, content: this.renderAlertsTable() }, + { ...TABLE_TAB_IDS.FINDINGS, content: this.renderFindingsTable() }, + ]; + + return tabs.map((tab, index) => ( + { + this.setState({ + tabId: tab.id, + tabContent: tab.content, + }); + }} + > + {tab.name} + + )); + } + + render() { + const { + last_notification_time, + loadingMonitors, + monitor_id, + monitor_name, + start_time, + triggerID, + trigger_name, + } = this.props; + const { loading, monitor, monitorType, tabContent } = this.state; + const searchType = _.get(monitor, 'ui_metadata.search.searchType', SEARCH_TYPE.GRAPH); + const triggerType = this.getTriggerType(monitorType); + + let trigger = _.get(monitor, 'triggers', []).find( + (trigger) => trigger[triggerType].id === triggerID + ); + trigger = _.get(trigger, triggerType); + + const severity = _.get(trigger, 'severity'); + const groupBy = _.get(monitor, MONITOR_GROUP_BY); + + const condition = + (searchType === SEARCH_TYPE.GRAPH && monitorType === MONITOR_TYPE.BUCKET_LEVEL) || + MONITOR_TYPE.DOC_LEVEL + ? this.getBucketLevelGraphConditions(trigger) + : _.get(trigger, 'condition.script.source', '-'); + + const filters = + monitorType === MONITOR_TYPE.BUCKET_LEVEL && searchType === SEARCH_TYPE.GRAPH + ? this.getBucketLevelGraphFilter(trigger) + : '-'; + + const bucketValue = _.get(monitor, 'ui_metadata.search.bucketValue'); + let bucketUnitOfTime = _.get(monitor, 'ui_metadata.search.bucketUnitOfTime'); + UNITS_OF_TIME.map((entry) => { + if (entry.value === bucketUnitOfTime) bucketUnitOfTime = entry.text; + }); + const timeRangeForLast = + bucketValue !== undefined && !_.isEmpty(bucketUnitOfTime) + ? `${bucketValue} ${bucketUnitOfTime}` + : '-'; + + const displayTableTabs = monitorType === MONITOR_TYPE.DOC_LEVEL; return (
@@ -481,82 +588,65 @@ export default class AlertsDashboardFlyoutComponent extends Component {

- - - Time range for the last -

{timeRangeForLast}

-
-
-
- - - - - - Filters -

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

-
-
- - - Group by -

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

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

{timeRangeForLast}

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

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

+
+
+ + + Group by +

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

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

+ Loading filters... +

+
+
+ + + + Group by + +

+ Loading groups... +

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

- Loading filters... -

-
-
- - - - Group by - -

- Loading groups... -

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

Trigger conditions

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

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

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

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

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

Document finding

+
+
+ + + + + + Document ID +

{docId}

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

{findingId}

+
+
+ )} + + + + Index +

{index}

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

{query.query}

+
+
+ ); + }); + + return ( + {`${queries.queries.length} Queries`}} + isOpen={isPopoverOpen} + closePopover={closePopover} + > + {popoverContent} + + ); +} diff --git a/public/pages/Dashboard/components/FindingsDashboard/utils.js b/public/pages/Dashboard/components/FindingsDashboard/utils.js new file mode 100644 index 000000000..7ec7a6478 --- /dev/null +++ b/public/pages/Dashboard/components/FindingsDashboard/utils.js @@ -0,0 +1,114 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import _ from 'lodash'; +import { renderTime } from '../../utils/tableUtils'; +import FindingFlyout from './FindingFlyout'; +import QueryPopover from './QueriesPopover'; + +export const TABLE_TAB_IDS = { + ALERTS: { id: 'alerts', name: 'Alerts' }, + FINDINGS: { id: 'findings', name: 'Document findings' }, +}; + +export const findingsColumnTypes = (isAlertsFlyout) => [ + { + field: 'document_list', + name: 'Document', + sortable: true, + truncateText: true, + render: (document_list, finding) => { + // TODO FIXME: ExecuteMonitor API currently only returns a list of query names/IDs and the relevant docIds. + // As a result, the preview dashboard cannot display document contents. + return _.isEmpty(document_list) ? ( + finding.related_doc_id + ) : ( + + ); + }, + }, + { + field: 'queries', + name: 'Query', + sortable: true, + truncateText: false, + render: (queries) => { + if (_.isEmpty(queries)) + console.log('Findings index contains an entry with 0 queries:', queries); + return queries.length > 1 ? ( + + ) : ( + `${queries[0].name} (${queries[0].query})` + ); + }, + }, + { + field: 'timestamp', + name: 'Time found', + sortable: true, + truncateText: false, + render: renderTime, + dataType: 'date', + }, +]; + +export const getFindingsForMonitor = (findings, monitorId) => { + const monitorFindings = []; + findings.map((finding) => { + const findingId = _.keys(finding)[0]; + const findingValues = _.get(finding, `${findingId}.finding`); + const findingMonitorId = findingValues.monitor_id; + if (!_.isEmpty(findingValues) && findingMonitorId === monitorId) + monitorFindings.push({ ...findingValues, document_list: finding[findingId].document_list }); + }); + return { findings: monitorFindings, totalFindings: monitorFindings.length }; +}; + +export const parseFindingsForPreview = (previewResponse, index, queries = []) => { + // TODO FIXME: ExecuteMonitor API currently only returns a list of query names/IDs and the relevant docIds. + // As a result, the preview dashboard cannot display document contents. + const timestamp = Date.now(); + const findings = []; + const docIdsToQueries = {}; + + _.keys(previewResponse).forEach((queryName) => { + _.get(previewResponse, queryName, []).forEach((id) => { + if (_.includes(_.keys(docIdsToQueries), id)) { + const query = _.find(queries, { queryName: queryName }); + docIdsToQueries[id].push({ name: queryName, query: query.query }); + } else { + const query = _.find(queries, { queryName: queryName }); + docIdsToQueries[id] = [{ name: queryName, query: query.query }]; + } + }); + }); + + _.keys(docIdsToQueries).forEach((docId) => { + const finding = { + index: index, + related_doc_id: docId, + queries: docIdsToQueries[docId], + timestamp: timestamp, + }; + findings.push(finding); + }); + return findings; +}; + +export const getPreviewResponseDocIds = (response) => { + const docIds = []; + _.keys(response).map((queryId) => { + const docIdsList = _.get(response, queryId, []); + docIdsList.forEach((docId) => { + if (!_.includes(docIds, docId)) docIds.push(docId); + }); + }); + return docIds; +}; diff --git a/public/pages/Dashboard/containers/FindingsDashboard.js b/public/pages/Dashboard/containers/FindingsDashboard.js new file mode 100644 index 000000000..d62d8593b --- /dev/null +++ b/public/pages/Dashboard/containers/FindingsDashboard.js @@ -0,0 +1,195 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import _ from 'lodash'; +import queryString from 'query-string'; +import { EuiBasicTable } from '@elastic/eui'; +import ContentPanel from '../../../components/ContentPanel'; +import { backendErrorNotification } from '../../../utils/helpers'; +import { DEFAULT_PAGE_SIZE_OPTIONS } from '../../Monitors/containers/Monitors/utils/constants'; +import { + DEFAULT_GET_FINDINGS_PARAMS, + GET_FINDINGS_SORT_FIELDS, +} from '../../../../server/services/FindingService'; +import { + findingsColumnTypes, + getFindingsForMonitor, + parseFindingsForPreview, +} from '../components/FindingsDashboard/utils'; + +export const GET_FINDINGS_PREVIEW_PARAMS = { + id: DEFAULT_GET_FINDINGS_PARAMS.id, + from: DEFAULT_GET_FINDINGS_PARAMS.from, + search: DEFAULT_GET_FINDINGS_PARAMS.search, + size: 10, + sortDirection: DEFAULT_GET_FINDINGS_PARAMS.sortDirection, + sortField: GET_FINDINGS_SORT_FIELDS.TIMESTAMP, +}; + +export default class FindingsDashboard extends Component { + constructor(props) { + super(props); + + const { isPreview = false } = props; + const { id, from, size, search, sortField, sortDirection } = isPreview + ? GET_FINDINGS_PREVIEW_PARAMS + : this.getURLQueryParams(); + + this.state = { + loadingFindings: true, + findings: [], + totalFindings: 0, + page: Math.floor(from / size), + id, + from, + size, + search, + sortField, + sortDirection, + }; + } + + componentDidMount() { + const { isPreview = false } = this.props; + if (isPreview) { + this.getPreviewFindingsDocuments(); + } else { + const { id, from, size, search, sortField, sortDirection } = this.state; + this.getFindings(id, from, size, search, sortDirection, sortField); + } + } + + componentDidUpdate(prevProps, prevState) { + const prevQuery = this.getQueryObjectFromState(prevState); + const currQuery = this.getQueryObjectFromState(this.state); + if (!_.isEqual(prevQuery, currQuery)) this.componentDidMount(); + } + + getURLQueryParams() { + const { location } = this.props; + const { + id = DEFAULT_GET_FINDINGS_PARAMS.id, + from = DEFAULT_GET_FINDINGS_PARAMS.from, + size = DEFAULT_GET_FINDINGS_PARAMS.size, + search = DEFAULT_GET_FINDINGS_PARAMS.search, + sortField = DEFAULT_GET_FINDINGS_PARAMS.sortField, + sortDirection = DEFAULT_GET_FINDINGS_PARAMS.sortDirection, + } = queryString.parse(location.search); + return { + id, + from: isNaN(parseInt(from, 10)) ? DEFAULT_GET_FINDINGS_PARAMS.from : parseInt(from, 10), + size: isNaN(parseInt(size, 10)) ? DEFAULT_GET_FINDINGS_PARAMS.size : parseInt(size, 10), + search, + sortField: _.includes(_.values(GET_FINDINGS_SORT_FIELDS), sortField) + ? sortField + : DEFAULT_GET_FINDINGS_PARAMS.sortField, + sortDirection, + }; + } + + getQueryObjectFromState({ id, from, size, search, sortField, sortDirection }) { + return { id, from, size, search, sortField, sortDirection }; + } + + getFindings = _.debounce( + (id, from, size, search, sortDirection, sortField) => { + this.setState({ loadingFindings: true }); + const params = { + id, + from, + size, + search, + sortDirection, + sortField, + }; + const queryParamsString = queryString.stringify(params); + location.search; + const { httpClient, history, monitorId, notifications } = this.props; + history.replace({ ...this.props.location, search: queryParamsString }); + + httpClient.get('../api/alerting/findings/_search', { query: params }).then((resp) => { + if (resp.ok) { + this.setState({ ...getFindingsForMonitor(resp.findings, monitorId) }); + } else { + console.log('Error getting findings:', resp); + backendErrorNotification(notifications, 'get', 'findings', resp.err); + } + }); + this.setState({ loadingFindings: false }); + }, + 500, + { leading: true } + ); + + getPreviewFindingsDocuments() { + this.setState({ loadingFindings: true }); + const { index, queries, previewResponse } = this.props; + this.setState({ + loadingFindings: false, + findings: parseFindingsForPreview(previewResponse, index, queries), + }); + } + + onTableChange = ({ page: tablePage = {}, sort = {} }) => { + const { index: page, size } = tablePage; + const { field: sortField, direction: sortDirection } = sort; + this.setState({ page, size, sortField, sortDirection }); + }; + + render() { + const { isAlertsFlyout = false, isPreview = false } = this.props; + const { + loadingFindings, + findings, + totalFindings, + size, + sortField, + sortDirection, + page, + } = this.state; + + const pagination = { + pageIndex: page, + pageSize: size, + totalItemCount: Math.min(size, totalFindings), + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + }; + + const sorting = { + sort: { + direction: sortDirection, + field: sortField, + }, + }; + + const getItemId = (item) => item.id; + + return ( + + + + ); + } +} diff --git a/public/pages/Dashboard/utils/helpers.js b/public/pages/Dashboard/utils/helpers.js index c3e1b9094..b8c260e4b 100644 --- a/public/pages/Dashboard/utils/helpers.js +++ b/public/pages/Dashboard/utils/helpers.js @@ -8,6 +8,7 @@ import { DEFAULT_GET_ALERTS_QUERY_PARAMS, EMPTY_ALERT_LIST, MAX_ALERT_COUNT } fr import { bucketColumns } from './tableUtils'; import { ALERT_STATE, DEFAULT_EMPTY_DATA } from '../../../utils/constants'; import queryString from 'query-string'; +import { GET_ALERTS_SORT_FILTERS } from '../../../../server/services/AlertService'; export function groupAlertsByTrigger(alerts) { if (_.isUndefined(alerts)) return _.cloneDeep(EMPTY_ALERT_LIST.alerts); @@ -142,7 +143,9 @@ export function getURLQueryParams(location) { from: isNaN(parseInt(from, 10)) ? DEFAULT_GET_ALERTS_QUERY_PARAMS.from : parseInt(from, 10), size: isNaN(parseInt(size, 10)) ? DEFAULT_GET_ALERTS_QUERY_PARAMS.size : parseInt(size, 10), search, - sortField, + sortField: _.includes(_.values(GET_ALERTS_SORT_FILTERS), sortField) + ? sortField + : DEFAULT_GET_ALERTS_QUERY_PARAMS.sortField, sortDirection, severityLevel, alertState, diff --git a/public/pages/Dashboard/utils/helpers.test.js b/public/pages/Dashboard/utils/helpers.test.js index 57c906561..08bdc2d7b 100644 --- a/public/pages/Dashboard/utils/helpers.test.js +++ b/public/pages/Dashboard/utils/helpers.test.js @@ -695,7 +695,8 @@ describe('Dashboard/utils/helpers', () => { }); }); describe('when perAlertView is true', () => { - const perAlertView = test('when defaultSize is undefined', () => { + const perAlertView = true; + test('when defaultSize is undefined', () => { const defaultSize = undefined; expect(getInitialSize(perAlertView, defaultSize)).toEqual( DEFAULT_GET_ALERTS_QUERY_PARAMS.size diff --git a/public/pages/Dashboard/utils/tableUtils.js b/public/pages/Dashboard/utils/tableUtils.js index d18a57fb6..76fcf8a76 100644 --- a/public/pages/Dashboard/utils/tableUtils.js +++ b/public/pages/Dashboard/utils/tableUtils.js @@ -10,7 +10,7 @@ import moment from 'moment'; import { ALERT_STATE, DEFAULT_EMPTY_DATA } from '../../../utils/constants'; import { PLUGIN_NAME } from '../../../../utils/constants'; -const renderTime = (time) => { +export const renderTime = (time) => { const momentTime = moment(time); if (time && momentTime.isValid()) return momentTime.format('MM/DD/YY h:mm a'); return DEFAULT_EMPTY_DATA; diff --git a/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js b/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js index aa0768a5e..3b6d574fc 100644 --- a/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js +++ b/public/pages/MonitorDetails/components/MonitorOverview/utils/getOverviewStats.js @@ -34,8 +34,7 @@ function getMonitorType(searchType, monitor) { case SEARCH_TYPE.CLUSTER_METRICS: const uri = _.get(monitor, 'inputs.0.uri'); const apiType = getApiType(uri); - const apiTypeLabel = _.get(API_TYPES, `${apiType}.label`); - return apiTypeLabel; + return _.get(API_TYPES, `${apiType}.label`); default: return 'Extraction Query'; } @@ -49,6 +48,8 @@ function getMonitorLevelType(monitorType) { return 'Per bucket monitor'; case MONITOR_TYPE.CLUSTER_METRICS: return 'Per cluster metrics monitor'; + case MONITOR_TYPE.DOC_LEVEL: + return 'Per document monitor'; default: // TODO: May be valuable to implement a toast that displays in this case. console.log('Unexpected monitor type:', monitorType); diff --git a/public/pages/MonitorDetails/containers/MonitorDetails.js b/public/pages/MonitorDetails/containers/MonitorDetails.js index 52a62df0c..c999df5f7 100644 --- a/public/pages/MonitorDetails/containers/MonitorDetails.js +++ b/public/pages/MonitorDetails/containers/MonitorDetails.js @@ -4,7 +4,7 @@ */ import React, { Component, Fragment } from 'react'; -import { get } from 'lodash'; +import _ from 'lodash'; import queryString from 'query-string'; import { EuiButton, @@ -23,6 +23,8 @@ import { EuiModalHeaderTitle, EuiOverlayMask, EuiSpacer, + EuiTab, + EuiTabs, EuiTitle, } from '@elastic/eui'; import CreateMonitor from '../../CreateMonitor'; @@ -34,6 +36,7 @@ import { MONITOR_ACTIONS, MONITOR_GROUP_BY, MONITOR_INPUT_DETECTOR_ID, + MONITOR_TYPE, TRIGGER_ACTIONS, } from '../../../utils/constants'; import { migrateTriggerMetadata } from './utils/helpers'; @@ -41,6 +44,8 @@ import { backendErrorNotification } from '../../../utils/helpers'; import { getUnwrappedTriggers } from './Triggers/Triggers'; import { formikToMonitor } from '../../CreateMonitor/containers/CreateMonitor/utils/formikToMonitor'; import monitorToFormik from '../../CreateMonitor/containers/CreateMonitor/utils/monitorToFormik'; +import FindingsDashboard from '../../Dashboard/containers/FindingsDashboard'; +import { TABLE_TAB_IDS } from '../../Dashboard/components/FindingsDashboard/utils'; export default class MonitorDetails extends Component { constructor(props) { @@ -63,6 +68,7 @@ export default class MonitorDetails extends Component { }); }, isJsonModalOpen: false, + tabId: TABLE_TAB_IDS.ALERTS.id, }; } @@ -128,10 +134,11 @@ export default class MonitorDetails extends Component { loading: false, error: null, }); - const adId = get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); + const adId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); if (adId) { this.getDetector(adId); } + this.setState({ tabContent: this.renderAlertsTable() }); } else { // TODO: 404 handling this.props.history.push('/monitors'); @@ -237,6 +244,79 @@ export default class MonitorDetails extends Component { return { ...formikToMonitor(monitorValues), triggers }; }; + renderAlertsTable = () => { + const { monitor, editMonitor } = this.state; + const { + location, + match: { + params: { monitorId }, + }, + history, + httpClient, + notifications, + } = this.props; + const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); + const groupBy = _.get(monitor, MONITOR_GROUP_BY); + return ( + + ); + }; + + renderFindingsTable = () => { + const { + httpClient, + history, + location, + notifications, + match: { + params: { monitorId }, + }, + } = this.props; + return ( + + ); + }; + + renderTableTabs = () => { + const { tabId } = this.state; + const tabs = [ + { ...TABLE_TAB_IDS.ALERTS, content: this.renderAlertsTable() }, + { ...TABLE_TAB_IDS.FINDINGS, content: this.renderFindingsTable() }, + ]; + return tabs.map((tab, index) => ( + { + this.setState({ + tabId: tab.id, + tabContent: tab.content, + }); + }} + style={{ paddingTop: '0px' }} + > + {tab.name} + + )); + }; + render() { const { monitor, @@ -253,15 +333,14 @@ export default class MonitorDetails extends Component { match: { params: { monitorId }, }, - history, httpClient, notifications, isDarkMode, } = this.props; const { action } = queryString.parse(location.search); const updatingMonitor = action === MONITOR_ACTIONS.UPDATE_MONITOR; - const detectorId = get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); - const groupBy = get(monitor, MONITOR_GROUP_BY); + const detectorId = _.get(monitor, MONITOR_INPUT_DETECTOR_ID, undefined); + if (loading) { return ( @@ -283,6 +362,7 @@ export default class MonitorDetails extends Component { ); } + const displayTableTabs = monitor.monitor_type === MONITOR_TYPE.DOC_LEVEL; return (
{this.renderNoTriggersCallOut()} @@ -352,18 +432,16 @@ export default class MonitorDetails extends Component { />
- + + {displayTableTabs ? ( +
+ {this.renderTableTabs()} + {this.state.tabContent} +
+ ) : ( + this.renderAlertsTable() + )} + {isJsonModalOpen && ( diff --git a/public/pages/MonitorDetails/containers/Triggers/Triggers.js b/public/pages/MonitorDetails/containers/Triggers/Triggers.js index 37cdca1c9..8a12a5373 100644 --- a/public/pages/MonitorDetails/containers/Triggers/Triggers.js +++ b/public/pages/MonitorDetails/containers/Triggers/Triggers.js @@ -11,20 +11,23 @@ import _ from 'lodash'; import ContentPanel from '../../../../components/ContentPanel'; import { MONITOR_TYPE } from '../../../../utils/constants'; +import { TRIGGER_TYPE } from '../../../CreateTrigger/containers/CreateTrigger/utils/constants'; export const MAX_TRIGGERS = 10; // TODO: For now, unwrapping all the Triggers since it's conflicting with the table // retrieving the 'id' and causing it to behave strangely export function getUnwrappedTriggers(monitor) { - const isBucketLevelMonitor = monitor.monitor_type === MONITOR_TYPE.BUCKET_LEVEL; - return isBucketLevelMonitor - ? monitor.triggers.map((trigger) => { - return trigger.bucket_level_trigger; - }) - : monitor.triggers.map((trigger) => { - return trigger.query_level_trigger; - }); + return monitor.triggers.map((trigger) => { + switch (monitor.monitor_type) { + case MONITOR_TYPE.BUCKET_LEVEL: + return trigger[TRIGGER_TYPE.BUCKET_LEVEL]; + case MONITOR_TYPE.DOC_LEVEL: + return trigger[TRIGGER_TYPE.DOC_LEVEL]; + default: + return trigger[TRIGGER_TYPE.QUERY_LEVEL]; + } + }); } export default class Triggers extends Component { diff --git a/public/utils/constants.js b/public/utils/constants.js index 21900fae4..f8692cf4e 100644 --- a/public/utils/constants.js +++ b/public/utils/constants.js @@ -29,6 +29,7 @@ export const MONITOR_TYPE = { QUERY_LEVEL: 'query_level_monitor', BUCKET_LEVEL: 'bucket_level_monitor', CLUSTER_METRICS: 'cluster_metrics_monitor', + DOC_LEVEL: 'doc_level_monitor', }; export const DESTINATION_ACTIONS = { diff --git a/server/clusters/alerting/alertingPlugin.js b/server/clusters/alerting/alertingPlugin.js index 8004da57a..6a056fed4 100644 --- a/server/clusters/alerting/alertingPlugin.js +++ b/server/clusters/alerting/alertingPlugin.js @@ -4,6 +4,7 @@ */ import { + API_ROUTE_PREFIX, MONITOR_BASE_API, DESTINATION_BASE_API, EMAIL_ACCOUNT_BASE_API, @@ -16,6 +17,14 @@ export default function alertingPlugin(Client, config, components) { Client.prototype.alerting = components.clientAction.namespaceFactory(); const alerting = Client.prototype.alerting.prototype; + alerting.getFindings = ca({ + url: { + fmt: `${API_ROUTE_PREFIX}/findings/_search`, + }, + needBody: true, + method: 'GET', + }); + alerting.getMonitor = ca({ url: { fmt: `${MONITOR_BASE_API}/<%=monitorId%>`, diff --git a/server/plugin.js b/server/plugin.js index 647eb1114..b21b66ac5 100644 --- a/server/plugin.js +++ b/server/plugin.js @@ -11,8 +11,9 @@ import { OpensearchService, MonitorService, AnomalyDetectorService, + FindingService, } from './services'; -import { alerts, destinations, opensearch, monitors, detectors } from '../server/routes'; +import { alerts, destinations, opensearch, monitors, detectors, findings } from '../server/routes'; export class AlertingPlugin { constructor(initializerContext) { @@ -34,12 +35,14 @@ export class AlertingPlugin { const monitorService = new MonitorService(alertingESClient); const destinationsService = new DestinationsService(alertingESClient); const anomalyDetectorService = new AnomalyDetectorService(adESClient); + const findingService = new FindingService(alertingESClient); const services = { alertService, destinationsService, opensearchService, monitorService, anomalyDetectorService, + findingService, }; // Create router @@ -50,6 +53,7 @@ export class AlertingPlugin { opensearch(services, router); monitors(services, router); detectors(services, router); + findings(services, router); return {}; } diff --git a/server/routes/findings.js b/server/routes/findings.js new file mode 100644 index 000000000..fe8fa924c --- /dev/null +++ b/server/routes/findings.js @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; + +export default function (services, router) { + const { findingService } = services; + + router.get( + { + path: '/api/alerting/findings/_search', + validate: { + query: schema.object({ + id: schema.maybe(schema.string()), + from: schema.number(), + size: schema.number(), + search: schema.string(), + sortField: schema.string(), + sortDirection: schema.string(), + }), + }, + }, + findingService.getFindings + ); +} diff --git a/server/routes/index.js b/server/routes/index.js index aca1baea4..029623900 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -8,5 +8,6 @@ import destinations from './destinations'; import opensearch from './opensearch'; import monitors from './monitors'; import detectors from './anomalyDetector'; +import findings from './findings'; -export { alerts, destinations, opensearch, monitors, detectors }; +export { alerts, destinations, opensearch, monitors, detectors, findings }; diff --git a/server/services/AlertService.js b/server/services/AlertService.js index c52ae917a..cf2f1ba9a 100644 --- a/server/services/AlertService.js +++ b/server/services/AlertService.js @@ -3,6 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import _ from 'lodash'; + +export const GET_ALERTS_SORT_FILTERS = { + MONITOR_NAME: 'monitor_name', + TRIGGER_NAME: 'trigger_name', + START_TIME: 'start_time', + END_TIME: 'end_time', + ACKNOWLEDGE_TIME: 'acknowledged_time', +}; + export default class AlertService { constructor(esDriver) { this.esDriver = esDriver; @@ -14,7 +24,10 @@ export default class AlertService { size = 20, search = '', sortDirection = 'desc', - sortField = 'start_time', + // If the sortField parsed from the URL isn't a valid option for this API, use a default option. + sortField = _.includes(_.values(GET_ALERTS_SORT_FILTERS), req.query.sortField) + ? req.query.sortField + : GET_ALERTS_SORT_FILTERS.START_TIME, severityLevel = 'ALL', alertState = 'ALL', monitorIds = [], @@ -22,32 +35,32 @@ export default class AlertService { var params; switch (sortField) { - case 'monitor_name': + case GET_ALERTS_SORT_FILTERS.MONITOR_NAME: params = { sortString: `${sortField}.keyword`, sortOrder: sortDirection, }; break; - case 'trigger_name': + case GET_ALERTS_SORT_FILTERS.TRIGGER_NAME: params = { sortString: `${sortField}.keyword`, sortOrder: sortDirection, }; break; - case 'start_time': + case GET_ALERTS_SORT_FILTERS.START_TIME: params = { sortString: sortField, sortOrder: sortDirection, }; break; - case 'end_time': + case GET_ALERTS_SORT_FILTERS.END_TIME: params = { sortString: sortField, sortOrder: sortDirection, missing: sortDirection === 'asc' ? '_last' : '_first', }; break; - case 'acknowledged_time': + case GET_ALERTS_SORT_FILTERS.ACKNOWLEDGE_TIME: params = { sortString: sortField, sortOrder: sortDirection, diff --git a/server/services/FindingService.js b/server/services/FindingService.js new file mode 100644 index 000000000..177c3be29 --- /dev/null +++ b/server/services/FindingService.js @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from 'lodash'; + +// TODO DRAFT: Are these sortField options appropriate? +export const GET_FINDINGS_SORT_FIELDS = { + INDEX: 'index', + MONITOR_NAME: 'monitor_name', + TIMESTAMP: 'timestamp', +}; + +// TODO DRAFT: RestGetFindingsAction.kt in the backend references a `missing` field in params. +// Investigate if/how we should make use of that. +export const DEFAULT_GET_FINDINGS_PARAMS = { + // TODO DRAFT: Does providing a finding ID serve a particular function? Results with/without the ID seemed the same. + id: undefined, + from: 0, + search: '', + size: 20, + sortDirection: 'desc', + sortField: GET_FINDINGS_SORT_FIELDS.TIMESTAMP, +}; + +export default class FindingService { + constructor(esDriver) { + this.esDriver = esDriver; + } + + getFindings = async (context, req, res) => { + const { + id = DEFAULT_GET_FINDINGS_PARAMS.id, + from = DEFAULT_GET_FINDINGS_PARAMS.from, + size = DEFAULT_GET_FINDINGS_PARAMS.size, + search = DEFAULT_GET_FINDINGS_PARAMS.search, + sortDirection = DEFAULT_GET_FINDINGS_PARAMS.sortDirection, + // If the sortField parsed from the URL isn't a valid option for this API, use a default option. + sortField = _.includes(_.values(GET_FINDINGS_SORT_FIELDS), req.query.sortField) + ? req.query.sortField + : DEFAULT_GET_FINDINGS_PARAMS.sortField, + } = req.query; + + var params; + switch (sortField) { + case GET_FINDINGS_SORT_FIELDS.INDEX: + params = { + sortString: `${sortField}.keyword`, + sortOrder: sortDirection, + }; + break; + case GET_FINDINGS_SORT_FIELDS.MONITOR_NAME: + params = { + sortString: `${sortField}.keyword`, + sortOrder: sortDirection, + }; + break; + case GET_FINDINGS_SORT_FIELDS.TIMESTAMP: + params = { + sortString: sortField, + sortOrder: sortDirection, + }; + break; + } + + if (!_.isEmpty(id)) params.findingId = id; + params.startIndex = from; + params.size = size; + params.searchString = search; + if (search.trim()) params.searchString = `*${search.trim().split(' ').join('* *')}*`; + + const { callAsCurrentUser } = this.esDriver.asScoped(req); + try { + const resp = await callAsCurrentUser('alerting.getFindings', params); + const findings = resp.findings.map((result) => ({ [result.finding.id]: { ...result } })); + const totalFindings = resp.totalFindings; + return res.ok({ + body: { + ok: true, + findings, + totalFindings, + }, + }); + } catch (err) { + console.log(err.message); + return res.ok({ + body: { + ok: false, + err: err.message, + }, + }); + } + }; +} diff --git a/server/services/index.js b/server/services/index.js index ce75d617f..6e3da1d6e 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -8,6 +8,7 @@ import DestinationsService from './DestinationsService'; import OpensearchService from './OpensearchService'; import MonitorService from './MonitorService'; import AnomalyDetectorService from './AnomalyDetectorService'; +import FindingService from './FindingService'; export { AlertService, @@ -15,4 +16,5 @@ export { OpensearchService, MonitorService, AnomalyDetectorService, + FindingService, }; From fbea210eb50343f63304a8585f7f7bf83486de02 Mon Sep 17 00:00:00 2001 From: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> Date: Tue, 19 Apr 2022 07:44:32 -0700 Subject: [PATCH 6/7] Integrate Alerting Dashboards with Notifications Plugin (#220) * Add NotificationsCallOut and NotificationsInfoCallOut components Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> * Add NotificationService Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> * Add NotificationsInfoCallOut on Destinations page and disable creating/editing Destination objects Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> * Add option to select both Destinations and Notification Channels when defining Action Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> * Disable unused buttons and fix issue with channels loading on existing Monitor Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> * Update Jest snapshots Signed-off-by: Ashish Agrawal * Remove Destination related Cypress tests Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> * Remove leftover deletion of Destinations in Cypress test clean-up Signed-off-by: Mohammad Qureshi <47198598+qreshi@users.noreply.github.com> Co-authored-by: Ashish Agrawal --- babel.config.js | 6 +- .../fixtures/sample_destination_chime.json | 7 - .../sample_destination_custom_webhook.json | 10 - .../integration/bucket_level_monitor_spec.js | 2 - .../cluster_metrics_monitor_spec.js | 2 - cypress/integration/destination_spec.js | 137 -- .../integration/query_level_monitor_spec.js | 2 - cypress/support/commands.js | 23 - public/app.js | 16 +- public/models/interfaces.ts | 10 + .../containers/CreateMonitor/CreateMonitor.js | 3 + .../CreateTrigger/components/Action/Action.js | 104 +- .../components/Action/Action.test.js | 70 + .../Action/__snapshots__/Action.test.js.snap | 1108 ++++++++++ .../components/Action/utils/constants.js | 4 + .../ActionEmptyPrompt/ActionEmptyPrompt.js | 30 +- .../ActionEmptyPrompt.test.js.snap | 9 +- .../NotificationsCallOut.js | 27 + .../NotificationsCallOut.test.js | 17 + .../NotificationsCallOut.test.js.snap | 50 + .../components/NotificationsCallOut/index.js | 8 + .../ConfigureActions/ConfigureActions.js | 167 +- .../ConfigureTriggers/ConfigureTriggers.js | 12 + .../CreateTrigger/CreateTrigger.js | 17 +- .../DefineBucketLevelTrigger.js | 4 + .../DefineDocumentLevelTrigger.js | 4 + .../containers/DefineTrigger/DefineTrigger.js | 4 + public/pages/CreateTrigger/utils/constants.js | 4 + public/pages/CreateTrigger/utils/helper.js | 23 + .../DestinationsActions.js | 2 +- .../DestinationsActions.test.js.snap | 10 +- .../EmptyDestinations/EmptyDestinations.js | 12 +- .../EmptyDestinations.test.js.snap | 23 +- .../NotificationsInfoCallOut.js | 28 + .../NotificationsInfoCallOut.test.js | 22 + .../NotificationsInfoCallOut.test.js.snap | 95 + .../NotificationsInfoCallOut/index.js | 8 + .../AddEmailGroupButton.js | 5 +- .../AddSenderButton/AddSenderButton.js | 5 +- .../ManageEmailGroups/ManageEmailGroups.js | 7 +- .../ManageSenders/ManageSenders.js | 7 +- .../DestinationsList/DestinationsList.js | 61 +- .../DestinationsList/DestinationsList.test.js | 53 +- .../DestinationsList.test.js.snap | 1898 +++++++++++++---- public/pages/Main/Main.js | 143 +- .../Main/__snapshots__/Main.test.js.snap | 58 +- public/services/NotificationService.ts | 52 + public/services/index.ts | 9 + public/services/models/interfaces.ts | 39 + public/services/services.ts | 13 + public/services/utils/helper.ts | 79 + public/utils/constants.js | 21 + 52 files changed, 3656 insertions(+), 874 deletions(-) delete mode 100644 cypress/fixtures/sample_destination_chime.json delete mode 100644 cypress/fixtures/sample_destination_custom_webhook.json delete mode 100644 cypress/integration/destination_spec.js create mode 100644 public/models/interfaces.ts create mode 100644 public/pages/CreateTrigger/components/Action/Action.test.js create mode 100644 public/pages/CreateTrigger/components/Action/__snapshots__/Action.test.js.snap create mode 100644 public/pages/CreateTrigger/components/NotificationsCallOut/NotificationsCallOut.js create mode 100644 public/pages/CreateTrigger/components/NotificationsCallOut/NotificationsCallOut.test.js create mode 100644 public/pages/CreateTrigger/components/NotificationsCallOut/__snapshots__/NotificationsCallOut.test.js.snap create mode 100644 public/pages/CreateTrigger/components/NotificationsCallOut/index.js create mode 100644 public/pages/CreateTrigger/utils/helper.js create mode 100644 public/pages/Destinations/components/NotificationsInfoCallOut/NotificationsInfoCallOut.js create mode 100644 public/pages/Destinations/components/NotificationsInfoCallOut/NotificationsInfoCallOut.test.js create mode 100644 public/pages/Destinations/components/NotificationsInfoCallOut/__snapshots__/NotificationsInfoCallOut.test.js.snap create mode 100644 public/pages/Destinations/components/NotificationsInfoCallOut/index.js create mode 100644 public/services/NotificationService.ts create mode 100644 public/services/index.ts create mode 100644 public/services/models/interfaces.ts create mode 100644 public/services/services.ts create mode 100644 public/services/utils/helper.ts diff --git a/babel.config.js b/babel.config.js index 54e99d34a..b0b98d6f6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,11 @@ // babelrc doesn't respect NODE_PATH anymore but using require does. // Alternative to install them locally in node_modules module.exports = { - presets: [require('@babel/preset-env'), require('@babel/preset-react')], + presets: [ + require('@babel/preset-env'), + require('@babel/preset-react'), + require('@babel/preset-typescript'), + ], plugins: [ require('@babel/plugin-proposal-class-properties'), require('@babel/plugin-proposal-object-rest-spread'), diff --git a/cypress/fixtures/sample_destination_chime.json b/cypress/fixtures/sample_destination_chime.json deleted file mode 100644 index 147b589cf..000000000 --- a/cypress/fixtures/sample_destination_chime.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "sample_destination_chime", - "type": "chime", - "chime": { - "url": "https://hooks.chime.aws/incomingwebhooks/XXX?token=XXX" - } -} diff --git a/cypress/fixtures/sample_destination_custom_webhook.json b/cypress/fixtures/sample_destination_custom_webhook.json deleted file mode 100644 index 657b60c50..000000000 --- a/cypress/fixtures/sample_destination_custom_webhook.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "sample_destination", - "type": "custom_webhook", - "custom_webhook": { - "header_params": { - "Content-Type": "application/json" - }, - "url": "http://www.sampledestination.com" - } -} diff --git a/cypress/integration/bucket_level_monitor_spec.js b/cypress/integration/bucket_level_monitor_spec.js index 4b52523cb..dc679bdbe 100644 --- a/cypress/integration/bucket_level_monitor_spec.js +++ b/cypress/integration/bucket_level_monitor_spec.js @@ -5,7 +5,6 @@ import { INDEX, PLUGIN_NAME } from '../support/constants'; import sampleAggregationQuery from '../fixtures/sample_aggregation_query'; -import sampleDestination from '../fixtures/sample_destination_custom_webhook'; import sampleExtractionQueryMonitor from '../fixtures/sample_extraction_query_bucket_level_monitor'; import sampleVisualEditorMonitor from '../fixtures/sample_visual_editor_bucket_level_monitor'; @@ -309,7 +308,6 @@ describe('Bucket-Level Monitors', () => { after(() => { // Delete all monitors and destinations cy.deleteAllMonitors(); - cy.deleteAllDestinations(); // Delete sample data cy.deleteIndexByName(`${INDEX.SAMPLE_DATA_ECOMMERCE}`); diff --git a/cypress/integration/cluster_metrics_monitor_spec.js b/cypress/integration/cluster_metrics_monitor_spec.js index 9800d1630..50d05aaf3 100644 --- a/cypress/integration/cluster_metrics_monitor_spec.js +++ b/cypress/integration/cluster_metrics_monitor_spec.js @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import sampleDestination from '../fixtures/sample_destination_custom_webhook'; import sampleClusterMetricsMonitor from '../fixtures/sample_cluster_metrics_monitor.json'; import { INDEX, PLUGIN_NAME } from '../../cypress/support/constants'; @@ -392,7 +391,6 @@ describe('ClusterMetricsMonitor', () => { after(() => { // Delete all monitors and destinations cy.deleteAllMonitors(); - cy.deleteAllDestinations(); // Delete sample data cy.deleteIndexByName(`${INDEX.SAMPLE_DATA_ECOMMERCE}`); diff --git a/cypress/integration/destination_spec.js b/cypress/integration/destination_spec.js deleted file mode 100644 index a30f9101d..000000000 --- a/cypress/integration/destination_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { PLUGIN_NAME } from '../support/constants'; -import sampleDestination from '../fixtures/sample_destination_custom_webhook'; -import sampleDestinationChime from '../fixtures/sample_destination_chime'; - -const SAMPLE_DESTINATION = 'sample_destination'; -const SAMPLE_DESTINATION_WITH_ANOTHER_NAME = 'sample_destination_chime'; -const UPDATED_DESTINATION = 'updated_destination'; -const SAMPLE_URL = 'http://www.sampledestination.com'; - -describe('Destinations', () => { - beforeEach(() => { - // Set welcome screen tracking to false - localStorage.setItem('home:welcome:show', 'false'); - - // Visit Alerting OpenSearch Dashboards - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/destinations`); - - // Common text to wait for to confirm page loaded, give up to 20 seconds for initial load - cy.contains('Add destination', { timeout: 20000 }); - }); - - describe('can be created', () => { - before(() => { - cy.deleteAllDestinations(); - }); - - it('with a custom webhook', () => { - // Confirm we loaded empty destination list - cy.contains('There are no existing destinations'); - - // Route us to create destination page - cy.contains('Add destination').click({ force: true }); - - // Wait for input to load and then type in the destination name - cy.get('input[name="name"]').type(SAMPLE_DESTINATION, { force: true }); - - // Select the type of destination - cy.get('#type').select('custom_webhook', { force: true }); - - // Wait for input to load and then type in the index name - cy.get('input[name="custom_webhook.url"]').type(SAMPLE_URL, { force: true }); - - // Click the create button - cy.get('button').contains('Create').click({ force: true }); - - // Confirm we can see the created destination in the list - cy.contains(SAMPLE_DESTINATION); - }); - }); - - describe('can be updated', () => { - before(() => { - cy.deleteAllDestinations(); - cy.createDestination(sampleDestination); - }); - - it('by changing the name', () => { - // Confirm we can see the created destination in the list - cy.contains(SAMPLE_DESTINATION); - - // Click the Edit button - cy.get('button').contains('Edit').click({ force: true }); - - // Wait for input to load and then type in the destination name - // should() is used to wait for input loading before clearing - cy.get('input[name="name"]') - .should('have.value', SAMPLE_DESTINATION) - .clear() - .type(UPDATED_DESTINATION, { force: true }); - - // Click the create button - cy.get('button').contains('Update').click({ force: true }); - - // Confirm we can see the updated destination in the list - cy.contains(UPDATED_DESTINATION); - }); - }); - - describe('can be deleted', () => { - before(() => { - cy.deleteAllDestinations(); - cy.createDestination(sampleDestination); - }); - - it('by clicking the button under "Actions"', () => { - // Confirm we can see the created destination in the list - cy.contains(SAMPLE_DESTINATION); - - // Click the Delete button - cy.contains('Delete').click({ force: true }); - - // Click the delete confirmation button in modal - cy.get(`[data-test-subj="confirmModalConfirmButton"]`).click(); - - // Confirm we can see an empty destination list - cy.contains('There are no existing destinations'); - }); - }); - - describe('can be searched', () => { - before(() => { - cy.deleteAllDestinations(); - // Create 21 destinations so that a monitor will not appear in the first page - for (let i = 0; i < 20; i++) { - cy.createDestination(sampleDestination); - } - cy.createDestination(sampleDestinationChime); - }); - - it('by name', () => { - // Sort the table by monitor name in alphabetical order - cy.get('thead > tr > th').contains('Destination name').click({ force: true }); - - // Confirm the monitor with a different name does not exist - cy.contains(SAMPLE_DESTINATION_WITH_ANOTHER_NAME).should('not.exist'); - - // Type in monitor name in search box - cy.get(`input[type="search"]`).focus().type(SAMPLE_DESTINATION_WITH_ANOTHER_NAME); - - // Confirm we filtered down to our one and only destination - cy.get('tbody > tr').should(($tr) => { - expect($tr, '1 row').to.have.length(1); - expect($tr, 'item').to.contain(SAMPLE_DESTINATION_WITH_ANOTHER_NAME); - }); - }); - }); - - after(() => { - // Delete all existing destinations - cy.deleteAllDestinations(); - }); -}); diff --git a/cypress/integration/query_level_monitor_spec.js b/cypress/integration/query_level_monitor_spec.js index e31a23bae..057b14820 100644 --- a/cypress/integration/query_level_monitor_spec.js +++ b/cypress/integration/query_level_monitor_spec.js @@ -7,7 +7,6 @@ import _ from 'lodash'; import { INDEX, PLUGIN_NAME } from '../support/constants'; import sampleQueryLevelMonitor from '../fixtures/sample_query_level_monitor'; import sampleQueryLevelMonitorWithAlwaysTrueTrigger from '../fixtures/sample_query_level_monitor_with_always_true_trigger'; -import sampleDestination from '../fixtures/sample_destination_custom_webhook.json'; const SAMPLE_MONITOR = 'sample_query_level_monitor'; const UPDATED_MONITOR = 'updated_query_level_monitor'; @@ -314,7 +313,6 @@ describe('Query-Level Monitors', () => { after(() => { // Delete all existing monitors and destinations cy.deleteAllMonitors(); - cy.deleteAllDestinations(); // Delete sample data cy.deleteIndexByName(`${INDEX.SAMPLE_DATA_ECOMMERCE}`); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 0188b9925..260b1587c 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -74,10 +74,6 @@ Cypress.Commands.add('createMonitor', (monitorJSON) => { cy.request('POST', `${Cypress.env('opensearch')}${API.MONITOR_BASE}`, monitorJSON); }); -Cypress.Commands.add('createDestination', (destinationJSON) => { - cy.request('POST', `${Cypress.env('opensearch')}${API.DESTINATION_BASE}`, destinationJSON); -}); - Cypress.Commands.add('createAndExecuteMonitor', (monitorJSON) => { cy.request('POST', `${Cypress.env('opensearch')}${API.MONITOR_BASE}`, monitorJSON).then( (response) => { @@ -138,25 +134,6 @@ Cypress.Commands.add('deleteAllMonitors', () => { }); }); -Cypress.Commands.add('deleteAllDestinations', () => { - cy.request({ - method: 'GET', - url: `${Cypress.env('opensearch')}${API.DESTINATION_BASE}?size=200`, - failOnStatusCode: false, // In case there is no alerting config index in cluster, where the status code is 404 - }).then((response) => { - if (response.status === 200) { - for (let i = 0; i < response.body.totalDestinations; i++) { - cy.request( - 'DELETE', - `${Cypress.env('opensearch')}${API.DESTINATION_BASE}/${response.body.destinations[i].id}` - ); - } - } else { - cy.log('Failed to get all destinations.', response); - } - }); -}); - Cypress.Commands.add('createIndexByName', (indexName) => { cy.request('PUT', `${Cypress.env('opensearch')}/${indexName}`); }); diff --git a/public/app.js b/public/app.js index 6d5e98abc..3f0049057 100644 --- a/public/app.js +++ b/public/app.js @@ -12,10 +12,14 @@ import 'react-vis/dist/style.css'; // import './less/main.less'; import Main from './pages/Main'; import { CoreContext } from './utils/CoreContext'; +import { ServicesContext, NotificationService } from './services'; export function renderApp(coreStart, params) { const isDarkMode = coreStart.uiSettings.get('theme:darkMode') || false; + const http = coreStart.http; coreStart.chrome.setBreadcrumbs([{ text: 'Alerting' }]); // Set Breadcrumbs for the plugin + const notificationService = new NotificationService(http); + const services = { notificationService }; // Load Chart's dark mode CSS if (isDarkMode) { @@ -27,11 +31,13 @@ export function renderApp(coreStart, params) { // render react to DOM ReactDOM.render( - -
} /> - + + +
} /> + + , params.element ); diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts new file mode 100644 index 000000000..ae48e39a9 --- /dev/null +++ b/public/models/interfaces.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NotificationService } from '../services'; + +export interface BrowserServices { + notificationService: NotificationService; +} diff --git a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js index 57e4a102f..579efebec 100644 --- a/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js +++ b/public/pages/CreateMonitor/containers/CreateMonitor/CreateMonitor.js @@ -242,6 +242,7 @@ export default class CreateMonitor extends Component { monitorToEdit, notifications, isDarkMode, + notificationService, } = this.props; const { initialValues, plugins } = this.state; @@ -296,6 +297,8 @@ export default class CreateMonitor extends Component { isDarkMode={this.props.isDarkMode} httpClient={httpClient} notifications={notifications} + notificationService={notificationService} + plugins={plugins} /> )} diff --git a/public/pages/CreateTrigger/components/Action/Action.js b/public/pages/CreateTrigger/components/Action/Action.js index f8bf7179c..70d9d3e20 100644 --- a/public/pages/CreateTrigger/components/Action/Action.js +++ b/public/pages/CreateTrigger/components/Action/Action.js @@ -11,32 +11,103 @@ import { EuiHorizontalRule, EuiPanel, EuiSpacer, + EuiFlexGroup, + EuiFlexItem, EuiText, } from '@elastic/eui'; import { FormikFieldText, FormikComboBox } from '../../../../components/FormControls'; import { isInvalid, hasError, validateActionName } from '../../../../utils/validate'; import { ActionsMap } from './utils/constants'; import { validateDestination } from './utils/validate'; -import { DEFAULT_ACTION_TYPE } from '../../utils/constants'; +import { DEFAULT_ACTION_TYPE, MANAGE_CHANNELS_PATH } from '../../utils/constants'; +import NotificationsCallOut from '../NotificationsCallOut'; const Action = ({ action, arrayHelpers, context, destinations, + flattenedDestinations, index, onDelete, sendTestMessage, setFlyout, + httpClient, fieldPath, values, + hasNotificationPlugin, }) => { - const selectedDestination = destinations.filter((item) => item.value === action.destination_id); + const selectedDestination = flattenedDestinations.filter( + (item) => item.value === action.destination_id + ); const type = _.get(selectedDestination, '0.type', DEFAULT_ACTION_TYPE); const { name } = action; const ActionComponent = ActionsMap[type].component; const actionLabel = ActionsMap[type].label; + const manageChannelsUrl = httpClient.basePath.prepend(MANAGE_CHANNELS_PATH); const isFirstAction = index !== undefined && index === 0; + + const renderChannels = () => { + return ( +
+ + + { + // Just a swap correct fields. + arrayHelpers.replace(index, { + ...action, + destination_id: options[0].value, + }); + }, + onBlur: (e, field, form) => { + form.setFieldTouched(`${fieldPath}actions.${index}.destination_id`, true); + }, + singleSelection: { asPlainText: true }, + isClearable: false, + renderOption: (option) => ( + + {option.label} + + {option.description} + + + ), + rowHeight: 45, + }} + /> + + + + window.open(manageChannelsUrl)} + > + Manage channels + + + + + {!hasNotificationPlugin && } +
+ ); + }; + return (
@@ -77,35 +148,8 @@ const Action = ({ isInvalid, }} /> - { - // Just a swap correct fields. - arrayHelpers.replace(index, { - ...action, - destination_id: options[0].value, - }); - }, - onBlur: (e, field, form) => { - form.setFieldTouched(`${fieldPath}actions.${index}.destination_id`, true); - }, - singleSelection: { asPlainText: true }, - isClearable: false, - 'data-test-subj': `${fieldPath}actions.${index}_actionDestination`, - }} - /> + {renderChannels()} { + test('renders with Notifications plugin installed', () => { + const httpClient = { + basePath: { prepend: jest.fn() }, + }; + const context = { ctx: { monitor: {}, trigger: {} } }; + const component = ( + + {}} + sendTestMessage={() => {}} + setFlyout={() => {}} + httpClient={httpClient} + fieldPath="testPath" + values={{}} + hasNotificationPlugin={true} + /> + + ); + + expect(render(component)).toMatchSnapshot(); + }); + + test('renders without Notifications plugin installed', () => { + const httpClient = { + basePath: { prepend: jest.fn() }, + }; + const context = { ctx: { monitor: {}, trigger: {} } }; + const component = ( + + {}} + sendTestMessage={() => {}} + setFlyout={() => {}} + httpClient={httpClient} + fieldPath="testPath" + values={{}} + hasNotificationPlugin={false} + /> + + ); + + expect(render(component)).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateTrigger/components/Action/__snapshots__/Action.test.js.snap b/public/pages/CreateTrigger/components/Action/__snapshots__/Action.test.js.snap new file mode 100644 index 000000000..fd01e21ee --- /dev/null +++ b/public/pages/CreateTrigger/components/Action/__snapshots__/Action.test.js.snap @@ -0,0 +1,1108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Action renders with Notifications plugin installed 1`] = ` +
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ Names can only contain letters, numbers, and special characters +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+