diff --git a/cypress/integration/1_detectors.spec.js b/cypress/integration/1_detectors.spec.js index 96c2fbf29..90c14235d 100644 --- a/cypress/integration/1_detectors.spec.js +++ b/cypress/integration/1_detectors.spec.js @@ -18,6 +18,128 @@ const testMappings = { const cypressDNSRule = dns_rule_data.title; +const createDetector = (detectorName, dataSource, expectFailure) => { + // Locate Create detector button click to start + cy.get('.euiButton').filter(':contains("Create detector")').click({ force: true }); + + // Check to ensure process started + cy.waitForPageLoad('create-detector', { + contains: 'Define detector', + }); + + // Enter a name for the detector in the appropriate input + cy.get(`input[placeholder="Enter a name for the detector."]`).focus().realType(detectorName); + + // Select our pre-seeded data source (check cypressIndexDns) + cy.get(`[data-test-subj="define-detector-select-data-source"]`) + .find('input') + .focus() + .realType(dataSource); + + cy.intercept({ + pathname: '/_plugins/_security_analytics/rules/_search', + query: { + prePackaged: 'true', + }, + }).as('getSigmaRules'); + + // Select threat detector type (Windows logs) + cy.get(`input[id="dns"]`).click({ force: true }); + + cy.wait('@getSigmaRules').then(() => { + // Open Detection rules accordion + cy.get('[data-test-subj="detection-rules-btn"]').click({ force: true, timeout: 5000 }); + + cy.contains('table tr', 'DNS', { + timeout: 120000, + }); + }); + + // Check that correct page now showing + cy.contains('Configure field mapping'); + + if (!expectFailure) { + // Select appropriate names to map fields to + for (let field_name in testMappings.properties) { + const mappedTo = testMappings.properties[field_name].path; + + cy.contains('tr', field_name).within(() => { + cy.get(`[data-test-subj="detector-field-mappings-select"]`).click().type(mappedTo); + }); + } + } + + // Click Next button to continue + cy.get('button').contains('Next').click({ force: true }); + + // Check that correct page now showing + cy.contains('Set up alerts'); + + // Type name of new trigger + cy.get(`input[placeholder="Enter a name for the alert condition."]`) + .focus() + .realType('test_trigger'); + + // Type in (or select) tags for the alert condition + cy.get(`[data-test-subj="alert-tags-combo-box"]`) + .find('input') + .focus() + .realType('attack.defense_evasion') + .realPress('Enter'); + + // Select applicable severity levels + cy.get(`[data-test-subj="security-levels-combo-box"]`).click({ force: true }); + cy.contains('1 (Highest)').click({ force: true }); + + // Continue to next page + cy.contains('Next').click({ force: true }); + + // Confirm page is reached + cy.contains('Review and create'); + + // Confirm field mappings registered + cy.contains('Field mapping'); + + if (!expectFailure) { + for (let field in testMappings.properties) { + const mappedTo = testMappings.properties[field].path; + + cy.contains(field); + cy.contains(mappedTo); + } + } + + // Confirm entries user has made + cy.contains('Detector details'); + cy.contains(detectorName); + cy.contains('dns'); + cy.contains(dataSource); + cy.contains('Alert on test_trigger'); + + // Create the detector + cy.get('button').contains('Create').click({ force: true }); + cy.waitForPageLoad('detector-details', { + contains: detectorName, + }); + + cy.contains('Attempting to create the detector.'); + + // Confirm detector active + cy.contains(detectorName); + cy.contains('Active'); + + if (!expectFailure) { + cy.contains('Actions'); + } + + cy.contains('Detector configuration'); + cy.contains('Field mappings'); + cy.contains('Alert triggers'); + cy.contains('Detector details'); + cy.contains('Created at'); + cy.contains('Last updated time'); +}; + describe('Detectors', () => { const cypressIndexDns = 'cypress-index-dns'; const cypressIndexWindows = 'cypress-index-windows'; @@ -86,130 +208,19 @@ describe('Detectors', () => { cy.get('.euiCallOut') .should('be.visible') - .contains('The selected log sources contain different log types'); + .contains( + 'To avoid issues with field mappings, we recommend creating separate detectors for different log types.' + ); }); it('...can be created', () => { - // Locate Create detector button click to start - cy.get('.euiButton').filter(':contains("Create detector")').click({ force: true }); - - // Check to ensure process started - cy.waitForPageLoad('create-detector', { - contains: 'Define detector', - }); - - // Enter a name for the detector in the appropriate input - cy.get(`input[placeholder="Enter a name for the detector."]`).focus().realType('test detector'); - - // Select our pre-seeded data source (check cypressIndexDns) - cy.get(`[data-test-subj="define-detector-select-data-source"]`) - .find('input') - .focus() - .realType(cypressIndexDns); - - cy.intercept({ - pathname: '/_plugins/_security_analytics/rules/_search', - query: { - prePackaged: 'true', - }, - }).as('getSigmaRules'); - - // Select threat detector type (Windows logs) - cy.get(`input[id="dns"]`).click({ force: true }); - - cy.wait('@getSigmaRules').then(() => { - // Open Detection rules accordion - cy.get('[data-test-subj="detection-rules-btn"]').click({ force: true, timeout: 5000 }); - - cy.contains('table tr', 'DNS', { - timeout: 120000, - }); - - // find search, type USB - cy.get(`input[placeholder="Search..."]`).ospSearch(cypressDNSRule); - - // Disable all rules - cy.contains('tr', cypressDNSRule, { timeout: 1000 }); - cy.get('table th').within(() => { - cy.get('button').first().click({ force: true }); - }); - - // Enable single rule - cy.contains('table tr', cypressDNSRule).within(() => { - cy.get('button').eq(1).click({ force: true, timeout: 2000 }); - }); - }); - - // Select appropriate names to map fields to - for (let field_name in testMappings.properties) { - const mappedTo = testMappings.properties[field_name].path; - - cy.contains('tr', field_name).within(() => { - cy.get(`[data-test-subj="detector-field-mappings-select"]`).click().type(mappedTo); - }); - } - - // Click Next button to continue - cy.get('button').contains('Next').click({ force: true }); - - // Check that correct page now showing - cy.contains('Set up alerts'); - - // Type name of new trigger - cy.get(`input[placeholder="Enter a name for the alert condition."]`) - .focus() - .realType('test_trigger'); - - // Type in (or select) tags for the alert condition - cy.get(`[data-test-subj="alert-tags-combo-box"]`) - .find('input') - .focus() - .realType('attack.defense_evasion') - .realPress('Enter'); - - // Select applicable severity levels - cy.get(`[data-test-subj="security-levels-combo-box"]`).click({ force: true }); - cy.contains('1 (Highest)').click({ force: true }); - - // Continue to next page - cy.contains('Next').click({ force: true }); - - // Confirm page is reached - cy.contains('Review and create'); - - // Confirm field mappings registered - cy.contains('Field mapping'); - - for (let field in testMappings.properties) { - const mappedTo = testMappings.properties[field].path; - - cy.contains(field); - cy.contains(mappedTo); - } - - // Confirm entries user has made - cy.contains('Detector details'); - cy.contains(detectorName); - cy.contains('dns'); - cy.contains(cypressIndexDns); - cy.contains('Alert on test_trigger'); - - // Create the detector - cy.get('button').contains('Create').click({ force: true }); - cy.waitForPageLoad('detector-details', { - contains: detectorName, - }); + createDetector(detectorName, cypressIndexDns, false); + cy.contains('Detector created successfully'); + }); - // Confirm detector active - cy.contains(detectorName); - cy.contains('Active'); - cy.contains('Actions'); - cy.contains('Detector configuration'); - cy.contains('Field mappings'); - cy.contains('Alert triggers'); - cy.contains('Detector details'); - cy.contains('Created at'); - cy.contains('Last updated time'); + it('...can fail creation', () => { + createDetector(`${detectorName}_fail`, '.kibana_1', true); + cy.contains('Create detector failed.'); }); it('...basic details can be edited', () => { @@ -276,7 +287,7 @@ describe('Detectors', () => { }); // Confirm number of rules before edit - cy.contains('Active rules (1)'); + cy.contains('Active rules (13)'); // Click "Edit" button in Detector rules panel cy.get(`[data-test-subj="edit-detector-rules"]`).click({ force: true }); @@ -300,7 +311,7 @@ describe('Detectors', () => { cy.get(`[data-test-subj="save-detector-rules-edits"]`).click({ force: true }); // Confirm 1 rule has been removed from detector - cy.contains('Active rules (0)'); + cy.contains('Active rules (12)'); // Click "Edit" button in Detector rules panel cy.get(`[data-test-subj="edit-detector-rules"]`).click({ force: true }); @@ -326,7 +337,7 @@ describe('Detectors', () => { }); // Confirm 1 rule has been added to detector - cy.contains('Active rules (1)'); + cy.contains('Active rules (13)'); }); it('...should update field mappings if data source is changed', () => { @@ -347,33 +358,10 @@ describe('Detectors', () => { cy.get('.reviewFieldMappings').should('not.exist'); // Change input source - cy.get(`[data-test-subj="define-detector-select-data-source"]`) - .find('input') - .ospClear() - .focus() - .realType(cypressIndexWindows) - .realPress('Enter'); - - cy.get('.reviewFieldMappings').should('be.visible'); - cy.get('.reviewFieldMappings').within(($el) => { - cy.get($el).contains('Automatically mapped fields (0)'); - }); - - // Change input source - cy.get(`[data-test-subj="define-detector-select-data-source"]`) - .find('input') - .ospClear() - .focus() - .realType(cypressIndexDns) - .realPress('Enter'); - - cy.get('.reviewFieldMappings').should('be.visible'); - cy.get('.reviewFieldMappings').within(($el) => { - cy.get($el).contains('Automatically mapped fields (1)'); - }); - - // Save changes to detector details - cy.get(`[data-test-subj="save-basic-details-edits"]`).click({ force: true }); + cy.get('.euiBadge__iconButton > .euiIcon').click({ force: true }); + cy.get(`[data-test-subj="define-detector-select-data-source"]`).type( + `${cypressIndexWindows}{enter}` + ); }); it('...should update field mappings if rule selection is changed', () => { @@ -393,61 +381,14 @@ describe('Detectors', () => { cy.get('.reviewFieldMappings').should('not.exist'); - // Search for specific rule - cy.get(`input[placeholder="Search..."]`).ospSearch(cypressDNSRule); - cy.intercept('mappings/view').as('getMappingsView'); - // Toggle single search result to unchecked - cy.contains('table tr', cypressDNSRule).within(() => { - // Of note, timeout can sometimes work instead of wait here, but is very unreliable from case to case. - cy.wait(1000); - cy.get('button').eq(1).click(); + cy.get('table th').within(() => { + cy.get('button').first().click({ force: true }); }); cy.wait('@getMappingsView'); cy.get('.reviewFieldMappings').should('be.visible'); - cy.get('.reviewFieldMappings').within(($el) => { - cy.get($el).contains('Automatically mapped fields (0)'); - }); - - //Suspicious DNS Query with B64 Encoded String - cy.get(`input[placeholder="Search..."]`).ospSearch(cypressDNSRule); - cy.contains('table tr', cypressDNSRule).within(() => { - // Of note, timeout can sometimes work instead of wait here, but is very unreliable from case to case. - cy.wait(1000); - cy.get('button').eq(1).click(); - }); - - cy.wait('@getMappingsView'); - cy.get(`input[placeholder="Search..."]`).ospSearch( - 'Suspicious DNS Query with B64 Encoded String' - ); - cy.contains('table tr', 'Suspicious DNS Query with B64 Encoded String').within(() => { - // Of note, timeout can sometimes work instead of wait here, but is very unreliable from case to case. - cy.wait(1000); - cy.get('button').eq(1).click(); - }); - - cy.wait('@getMappingsView'); - cy.get('.reviewFieldMappings').should('be.visible'); - cy.get('.reviewFieldMappings').within(($el) => { - cy.get($el).contains('Automatically mapped fields (1)'); - }); - - cy.get(`input[placeholder="Search..."]`).ospSearch('High TXT Records Requests Rate'); - cy.contains('table tr', 'High TXT Records Requests Rate').within(() => { - // Of note, timeout can sometimes work instead of wait here, but is very unreliable from case to case. - cy.wait(1000); - cy.get('button').eq(1).click(); - }); - - cy.wait('@getMappingsView'); - cy.get('.reviewFieldMappings').should('be.visible'); - cy.get('.reviewFieldMappings').within(($el) => { - cy.get($el).contains('Automatically mapped fields (1)'); - cy.get($el).contains('1 rule fields may need manual mapping'); - }); }); it('...can be deleted', () => { diff --git a/public/app.scss b/public/app.scss index 524fdfc11..37983aa06 100644 --- a/public/app.scss +++ b/public/app.scss @@ -13,6 +13,7 @@ $euiTextColor: $euiColorDarkestShade !default; @import "./components/Charts/ChartContainer.scss"; @import "./pages/Overview/components/Widgets/WidgetContainer.scss"; +@import "./pages/Main/components/Callout.scss"; @import "./pages/Detectors/components/ReviewFieldMappings/ReviewFieldMappings.scss"; .selected-radio-panel { diff --git a/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap b/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap index ac1a1d036..ea1294d09 100644 --- a/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap +++ b/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap @@ -49,6 +49,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -138,6 +141,7 @@ exports[` spec renders the component 1`] = ` "location": Object { "pathname": "", }, + "push": [MockFunction], "replace": [MockFunction], } } @@ -158,6 +162,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx b/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx index 3abb78b74..c2c7fb722 100644 --- a/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx +++ b/public/pages/CreateDetector/components/ConfigureFieldMapping/components/RequiredFieldMapping/FieldNameSelector.tsx @@ -39,7 +39,7 @@ export default class FieldNameSelector extends Component spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap b/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap index 5c1981715..735f7d5f9 100644 --- a/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap +++ b/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap @@ -392,6 +392,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -999,6 +1002,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Detectors/components/UpdateBasicDetails/__snapshots__/UpdateDetectorBasicDetails.test.tsx.snap b/public/pages/Detectors/components/UpdateBasicDetails/__snapshots__/UpdateDetectorBasicDetails.test.tsx.snap index 4cc594d1f..bd7cc18c3 100644 --- a/public/pages/Detectors/components/UpdateBasicDetails/__snapshots__/UpdateDetectorBasicDetails.test.tsx.snap +++ b/public/pages/Detectors/components/UpdateBasicDetails/__snapshots__/UpdateDetectorBasicDetails.test.tsx.snap @@ -190,6 +190,7 @@ exports[` spec renders the component 1`] = ` "location": Object { "pathname": "", }, + "push": [MockFunction], "replace": [MockFunction] { "calls": Array [ Array [ @@ -576,6 +577,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -905,6 +909,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Detectors/containers/AlertTriggersView/__snapshots__/AlertTriggersView.test.tsx.snap b/public/pages/Detectors/containers/AlertTriggersView/__snapshots__/AlertTriggersView.test.tsx.snap index 5d4b67e59..57e1239bc 100644 --- a/public/pages/Detectors/containers/AlertTriggersView/__snapshots__/AlertTriggersView.test.tsx.snap +++ b/public/pages/Detectors/containers/AlertTriggersView/__snapshots__/AlertTriggersView.test.tsx.snap @@ -183,6 +183,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Detectors/containers/Detector/DetectorDetails.tsx b/public/pages/Detectors/containers/Detector/DetectorDetails.tsx index 46e90b969..c423817b6 100644 --- a/public/pages/Detectors/containers/Detector/DetectorDetails.tsx +++ b/public/pages/Detectors/containers/Detector/DetectorDetails.tsx @@ -16,28 +16,20 @@ import { EuiTabs, EuiTitle, EuiHealth, - EuiCallOut, - EuiLoadingSpinner, - EuiPanel, } from '@elastic/eui'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { CoreServicesContext } from '../../../../components/core_services'; -import { - BREADCRUMBS, - EMPTY_DEFAULT_DETECTOR_HIT, - logTypesWithDashboards, - ROUTES, -} from '../../../../utils/constants'; -import { CreateMappingsResponse, DetectorHit } from '../../../../../server/models/interfaces'; +import { BREADCRUMBS, EMPTY_DEFAULT_DETECTOR_HIT, ROUTES } from '../../../../utils/constants'; +import { DetectorHit } from '../../../../../server/models/interfaces'; import { DetectorDetailsView } from '../DetectorDetailsView/DetectorDetailsView'; import { FieldMappingsView } from '../../components/FieldMappingsView/FieldMappingsView'; import { AlertTriggersView } from '../AlertTriggersView/AlertTriggersView'; import { RuleItem } from '../../../CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces'; import { DetectorsService } from '../../../../services'; -import { errorNotificationToast, successNotificationToast } from '../../../../utils/helpers'; +import { errorNotificationToast } from '../../../../utils/helpers'; import { NotificationsStart, SimpleSavedObject } from 'opensearch-dashboards/public'; -import { CreateDetectorResponse, ISavedObjectsService, ServerResponse } from '../../../../../types'; +import { ISavedObjectsService, ServerResponse } from '../../../../../types'; import { PENDING_DETECTOR_ID } from '../../../CreateDetector/utils/constants'; import { DataStore } from '../../../../store/DataStore'; @@ -166,11 +158,11 @@ export class DetectorDetails extends React.Component { - return this.props.savedObjectsService - .createSavedObject(detectorName, logType, detectorId, inputIndices) - .catch((error: any) => { - console.error(error); - }); - }; - getPendingDetector = async () => { - const pendingState = DataStore.detectors.getPendingState(); - const detector = pendingState?.detectorState?.detector; - const pendingRequests = pendingState?.pendingRequests; + const state = DataStore.detectors.getState(); + const detector = state?.detectorInput?.detector; + const pendingRequests = state?.pendingRequests; this.getTabs(); if (pendingRequests && detector) { @@ -226,93 +205,33 @@ export class DetectorDetails extends React.Component, - ServerResponse - ]; - - if (mappingsResponse.ok) { - if (detectorResponse.ok) { - let dashboardId; - const detectorId = detectorResponse.response._id; - if (logTypesWithDashboards.has(detector.detector_type)) { - const dashboardResponse = await this.createDashboard( - detector.name, - detector.detector_type, - detectorResponse.response._id, - detector.inputs[0].detector_input.indices - ); - if (dashboardResponse && dashboardResponse.ok) { - dashboardId = dashboardResponse.response.id; - } else { - const dashboards = await this.props.savedObjectsService.getDashboards(); - dashboards.some((dashboard) => { - if ( - dashboard.references.findIndex( - (reference) => reference.id === this.state.detectorId - ) > -1 - ) { - dashboardId = dashboard.id; - return true; - } - - return false; - }); - } - } + const pendingResponse = await DataStore.detectors.resolvePendingCreationRequest(); + if (pendingResponse.ok) { + const { detectorId, dashboardId } = pendingResponse; + + detectorId && this.setState( { detectorId, dashboardId, }, () => { - DataStore.detectors.deletePendingState(); + DataStore.detectors.deleteState(); this.props.history.push(`${ROUTES.DETECTOR_DETAILS}/${detectorId}`); this.getDetector(); } ); - - successNotificationToast( - this.props.notifications, - 'created', - `detector, "${detector.name}"` - ); - } else { - this.setState( - { - createFailed: true, - }, - () => - errorNotificationToast( - this.props.notifications, - 'create', - 'detector', - detectorResponse.error - ) - ); - } } else { - this.setState( - { - createFailed: true, - }, - () => - errorNotificationToast( - this.props.notifications, - 'create', - 'detector', - 'Double check the field mappings and try again.' - ) - ); + this.setState({ createFailed: true }); } } this.setState({ loading: false }); }; async componentDidMount() { - const pendingState = DataStore.detectors.getPendingState(); - pendingState ? this.getPendingDetector() : this.getDetector(); + const state = DataStore.detectors.getState(); + state ? this.getPendingDetector() : this.getDetector(); } getDetector = async () => { @@ -512,17 +431,6 @@ export class DetectorDetails extends React.Component { - const pendingState = DataStore.detectors.getPendingState(); - const detectorState = pendingState?.detectorState; - this.props.history.push({ - pathname: `${ROUTES.DETECTORS_CREATE}`, - // @ts-ignore - state: { detectorState }, - }); - DataStore.detectors.deletePendingState(); - }; - render() { const { _source: detector } = this.detectorHit; const { selectedTabContent, detectorId, createFailed } = this.state; @@ -548,34 +456,6 @@ export class DetectorDetails extends React.Component - {creatingDetector ? ( - <> - - {!createFailed && ( - - - - )} - - {createFailed - ? 'Detector creation failed. Please review detector configuration and try again.' - : 'Attempting to create the detector.'} - - - } - color={createFailed ? 'danger' : 'primary'} - > - {createFailed && ( - - Review detector configuration - - )} - - - - ) : null} diff --git a/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap b/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap index 2bb9ec9ab..1e105082d 100644 --- a/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap +++ b/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap @@ -195,6 +195,7 @@ exports[` spec renders the component 1`] = ` "location": Object { "pathname": "", }, + "push": [MockFunction], "replace": [MockFunction], } } @@ -207,6 +208,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -887,6 +891,7 @@ exports[` spec renders the component 1`] = ` "location": Object { "pathname": "", }, + "push": [MockFunction], "replace": [MockFunction], } } @@ -901,6 +906,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -1293,6 +1301,7 @@ exports[` spec renders the component 1`] = ` "location": Object { "pathname": "", }, + "push": [MockFunction], "replace": [MockFunction], } } @@ -1307,6 +1316,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -2642,6 +2654,7 @@ exports[` spec renders the component 1`] = ` "location": Object { "pathname": "", }, + "push": [MockFunction], "replace": [MockFunction], } } @@ -2656,6 +2669,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap b/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap index 2ae733d96..4db4c71a9 100644 --- a/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap +++ b/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap @@ -186,6 +186,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -377,6 +380,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } @@ -1511,6 +1517,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap b/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap index 7cf9f7c8f..0a66bbe88 100644 --- a/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap +++ b/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap @@ -23,6 +23,9 @@ exports[` spec renders the component 1`] = ` Object { "toasts": Object { "addDanger": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], }, } } diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index e4e3ef65b..3590d214f 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -13,7 +13,9 @@ import { EuiSideNavItemType, EuiTitle, EuiSpacer, + EuiGlobalToastList, } from '@elastic/eui'; +import { Toast } from '@opensearch-project/oui/src/eui_components/toast/global_toast_list'; import { CoreStart } from 'opensearch-dashboards/public'; import { ServicesConsumer } from '../../services'; import { BrowserServices } from '../../models/interfaces'; @@ -35,6 +37,8 @@ import { EditRule } from '../Rules/containers/EditRule/EditRule'; import { ImportRule } from '../Rules/containers/ImportRule/ImportRule'; import { DuplicateRule } from '../Rules/containers/DuplicateRule/DuplicateRule'; import { DateTimeFilter } from '../Overview/models/interfaces'; +import Callout, { ICalloutProps } from './components/Callout'; +import { DataStore } from '../../store/DataStore'; enum Navigation { SecurityAnalytics = 'Security Analytics', @@ -68,6 +72,8 @@ interface MainState { getStartedDismissedOnce: boolean; selectedNavItemIndex: number; dateTimeFilter: DateTimeFilter; + callout?: ICalloutProps; + toasts?: Toast[]; } const navItemIndexByRoute: { [route: string]: number } = { @@ -89,8 +95,22 @@ export default class Main extends Component { endTime: DEFAULT_DATE_RANGE.end, }, }; + + DataStore.detectors.setHandlers(this.showCallout, this.showToast); } + showCallout = (callout?: ICalloutProps) => { + this.setState({ + callout, + }); + }; + + showToast = (toasts?: any[]) => { + this.setState({ + toasts, + }); + }; + componentDidMount(): void { this.updateSelectedNavItem(); } @@ -151,6 +171,8 @@ export default class Main extends Component { location: { pathname }, history, } = this.props; + + const { callout } = this.state; const sideNav: EuiSideNavItemType<{ style: any }>[] = [ { name: Navigation.SecurityAnalytics, @@ -230,6 +252,7 @@ export default class Main extends Component { )} + {callout ? : null} { + ) } diff --git a/public/pages/Main/components/Callout.scss b/public/pages/Main/components/Callout.scss new file mode 100644 index 000000000..832da3e78 --- /dev/null +++ b/public/pages/Main/components/Callout.scss @@ -0,0 +1,10 @@ +.mainCallout { + .euiCallOutHeader__title { + width: 100% !important; + + .mainCalloutCloseButton { + flex-wrap: wrap; + align-content: end; + } + } +} diff --git a/public/pages/Main/components/Callout.tsx b/public/pages/Main/components/Callout.tsx new file mode 100644 index 000000000..3255ca411 --- /dev/null +++ b/public/pages/Main/components/Callout.tsx @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiCallOut, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiLoadingSpinner, +} from '@elastic/eui'; + +export type TCalloutColor = 'primary' | 'success' | 'warning' | 'danger'; +export type TCalloutIcon = 'iInCircle' | 'check' | 'help' | 'alert'; + +interface ICalloutType { + color: TCalloutColor; + iconType: TCalloutIcon; +} + +export interface ICalloutProps { + title: string | JSX.Element; + message?: string | JSX.Element; + type?: ICalloutType | TCalloutColor; + closable?: boolean; + loading?: boolean; + closeHandler?: (callout?: ICalloutProps) => void; +} + +export const toastTypes: { + [Key in TCalloutColor]: TCalloutIcon; +} = { + primary: 'iInCircle', + success: 'check', + warning: 'help', + danger: 'alert', +}; + +export const resolveType = (type?: ICalloutType | TCalloutColor): ICalloutType => { + if (type === undefined) { + return { + color: 'primary', + iconType: 'iInCircle', + }; + } else { + if (typeof type === 'string') { + return { + color: type, + iconType: toastTypes[type], + }; + } else { + return type; + } + } +}; + +export const CallOut = ({ + title, + message, + type, + closable = true, + loading = false, + closeHandler, +}: ICalloutProps) => { + const closeCallout = () => closeHandler && closeHandler(undefined); + + const getTitle = (): JSX.Element => { + return ( + + {loading && ( + + + + )} + {title} + {closable && ( + + closeCallout()} iconType="cross" aria-label="Close" /> + + )} + + ); + }; + + const { color, iconType } = resolveType(type); + return ( + <> + + {message} + + + + + ); +}; + +export default CallOut; diff --git a/public/store/DataStore.ts b/public/store/DataStore.ts index 9a0687cfd..58a6ad538 100644 --- a/public/store/DataStore.ts +++ b/public/store/DataStore.ts @@ -14,6 +14,10 @@ export class DataStore { public static init = (services: BrowserServices, notifications: NotificationsStart) => { DataStore.rules = new RulesStore(services.ruleService, notifications); - DataStore.detectors = new DetectorsStore(services.detectorsService, notifications); + DataStore.detectors = new DetectorsStore( + services.detectorsService, + notifications, + services.savedObjectsService + ); }; } diff --git a/public/store/DetectorsStore.test.ts b/public/store/DetectorsStore.test.ts new file mode 100644 index 000000000..af98aa191 --- /dev/null +++ b/public/store/DetectorsStore.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataStore } from './DataStore'; +import notificationsStartMock from '../../test/mocks/services/notifications/NotificationsStart.mock'; +import services from '../../test/mocks/services'; +import { DetectorsStore } from './DetectorsStore'; +import { expect } from '@jest/globals'; +import detectorResponseMock from '../../test/mocks/Detectors/containers/Detectors/DetectorResponse.mock'; +import browserHistoryMock from '../../test/mocks/services/browserHistory.mock'; +import { CreateDetectorState } from '../pages/CreateDetector/containers/CreateDetector'; +import DetectorMock from '../../test/mocks/Detectors/containers/Detectors/Detector.mock'; + +describe('Detectors store specs', () => { + Object.assign(services, { + detectorService: { + getRules: () => Promise.resolve(detectorResponseMock), + deleteRule: () => Promise.resolve(true), + }, + }); + + DataStore.init(services, notificationsStartMock); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('detectors store should be created', () => { + expect(DataStore.detectors instanceof DetectorsStore).toBe(true); + }); + + it('should handle the state', () => { + DataStore.detectors.setState( + { + pendingRequests: [Promise.resolve()], + detectorInput: { + detector: { detector_type: 'test_detector_type' } as typeof DetectorMock, + } as CreateDetectorState, + }, + browserHistoryMock + ); + + let state = DataStore.detectors.getState(); + expect(state?.detectorInput?.detector.detector_type).toBe('test_detector_type'); + + DataStore.detectors.deleteState(); + state = DataStore.detectors.getState(); + expect(state).toBe(undefined); + }); + + it('should get successful pending state', async () => { + DataStore.detectors.setState( + { + pendingRequests: [ + Promise.resolve({ + ok: true, + }), + Promise.resolve({ + ok: true, + response: { + _id: '', + detector: { + detector_type: '', + inputs: [ + { + detector_input: { + indices: [], + }, + }, + ], + }, + }, + }), + ], + detectorInput: { + detector: { detector_type: 'test_detector_type' } as typeof DetectorMock, + } as CreateDetectorState, + }, + browserHistoryMock + ); + const pending = await DataStore.detectors.resolvePendingCreationRequest(); + expect(pending.ok).toBe(true); + }); + + it('should get failed pending state', async () => { + DataStore.detectors.setState( + { + pendingRequests: [ + Promise.resolve({ + ok: false, + }), + ], + detectorInput: { + detector: { detector_type: 'test_detector_type' } as typeof DetectorMock, + } as CreateDetectorState, + }, + browserHistoryMock + ); + const pending = await DataStore.detectors.resolvePendingCreationRequest(); + expect(pending.ok).toBe(false); + }); +}); diff --git a/public/store/DetectorsStore.ts b/public/store/DetectorsStore.ts deleted file mode 100644 index 3a39224dd..000000000 --- a/public/store/DetectorsStore.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { DetectorsService } from '../services'; -import { NotificationsStart } from 'opensearch-dashboards/public'; -import { CreateDetectorState } from '../pages/CreateDetector/containers/CreateDetector'; - -export interface IDetectorsStore {} -export interface IDetectorsCache {} - -export interface IDetectorsState { - pendingRequests: Promise[]; - detectorState: CreateDetectorState; -} - -/** - * Class is used to make detector's API calls and cache the detectors. - * If there is a cache data requests are skipped and result is returned from the cache. - * If cache is invalidated then the request is made to get a new set of data. - * - * @class DetectorsStore - * @implements IDetectorsStore - * @param {BrowserServices} services Uses services to make API requests - */ -export class DetectorsStore implements IDetectorsStore { - /** - * Rule service instance - * - * @property {DetectorsService} service - * @readonly - */ - readonly service: DetectorsService; - - /** - * Notifications - * @property {NotificationsStart} - * @readonly - */ - readonly notifications: NotificationsStart; - - /** - * Keeps detector's data cached - * - * @property {IDetectorsCache} cache - */ - private cache: IDetectorsCache = {}; - - private state: IDetectorsState | undefined; - - constructor(service: DetectorsService, notifications: NotificationsStart) { - this.service = service; - this.notifications = notifications; - } - - /** - * Invalidates all detectors data - */ - private invalidateCache = () => { - this.cache = {}; - return this; - }; - - public setPendingState = (state: IDetectorsState) => { - this['state'] = state; - }; - - public getPendingState = () => { - if (!this.state) return undefined; - return { - ...this.state, - }; - }; - - public deletePendingState = () => { - delete this.state; - }; -} diff --git a/public/store/DetectorsStore.tsx b/public/store/DetectorsStore.tsx new file mode 100644 index 000000000..9377d06d7 --- /dev/null +++ b/public/store/DetectorsStore.tsx @@ -0,0 +1,338 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { DetectorsService } from '../services'; +import { NotificationsStart } from 'opensearch-dashboards/public'; +import { CreateDetectorState } from '../pages/CreateDetector/containers/CreateDetector'; +import { ICalloutProps, resolveType, TCalloutColor } from '../pages/Main/components/Callout'; +import { CreateDetectorResponse, ISavedObjectsService, ServerResponse } from '../../types'; +import { CreateMappingsResponse } from '../../server/models/interfaces'; +import { logTypesWithDashboards, ROUTES } from '../utils/constants'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Toast } from '@opensearch-project/oui/src/eui_components/toast/global_toast_list'; +import { RouteComponentProps } from 'react-router-dom'; +import { DataStore } from './DataStore'; +import { v4 as uuidv4 } from 'uuid'; + +export interface IDetectorsStore { + readonly service: DetectorsService; + readonly notifications: NotificationsStart; + readonly savedObjectsService: ISavedObjectsService; + readonly history: RouteComponentProps['history'] | undefined; + setState: (state: IDetectorsState, history: RouteComponentProps['history']) => void; + getState: () => IDetectorsState | undefined; + deleteState: () => void; + resolvePendingCreationRequest: () => Promise<{ + detectorId?: string; + dashboardId?: string; + ok: boolean; + }>; + setHandlers: ( + calloutHandler: (callout?: ICalloutProps) => void, + toastHandler: (toasts?: Toast[]) => void + ) => void; +} + +export interface IDetectorsCache {} + +export interface IDetectorsState { + pendingRequests: Promise[]; + detectorInput: CreateDetectorState; +} + +/** + * Class is used to make detector's API calls and cache the detectors. + * If there is a cache data requests are skipped and result is returned from the cache. + * If cache is invalidated then the request is triggered to get a new set of data. + * + * @class DetectorsStore + * @implements IDetectorsStore + * @param {BrowserServices} services Uses services to make API requests + */ +export class DetectorsStore implements IDetectorsStore { + /** + * Rule service instance + * + * @property {DetectorsService} service + * @readonly + */ + readonly service: DetectorsService; + + /** + * Notifications + * @property {NotificationsStart} + * @readonly + */ + readonly notifications: NotificationsStart; + + /** + * SavedObjectsService + * @property {ISavedObjectsService} + * @readonly + */ + readonly savedObjectsService: ISavedObjectsService; + + /** + * Router history + * @property {RouteComponentProps['history']} + * @readonly + */ + history: RouteComponentProps['history'] | undefined = undefined; + + /** + * Keeps detector's data cached + * + * @property {IDetectorsCache} cache + */ + private cache: IDetectorsCache = {}; + + /** + * Store state + * @private {IDetectorsState} + */ + private state: IDetectorsState | undefined; + + /** + * List of all shown toasts + * @private + */ + private toasts: Toast[] = []; + + constructor( + service: DetectorsService, + notifications: NotificationsStart, + savedObjectsService: ISavedObjectsService + ) { + this.service = service; + this.notifications = notifications; + this.savedObjectsService = savedObjectsService; + } + + /** + * Invalidates all detectors data + */ + private invalidateCache = (): DetectorsStore => { + this.cache = {}; + return this; + }; + + public setState = (state: IDetectorsState, history: RouteComponentProps['history']): void => { + this.state = state; + this.history = history; + + this.showNotification('Attempting to create the detector.', undefined, 'primary', true); + }; + + public getState = (): IDetectorsState | undefined => (this.state ? this.state : undefined); + + public deleteState = (): void => { + delete this.state; + }; + + private showNotification = ( + title: string, + message?: string, + type?: TCalloutColor, + loading?: boolean, + btnText?: string, + btnHandler?: (e: any) => void + ): void => { + if (!type) type = 'primary'; + + const closeAllToasts = () => { + this.toasts = []; + this.showToastCallback(this.toasts); + }; + + const btn = btnText && ( + { + btnHandler && btnHandler(e); + this.hideCallout(); + closeAllToasts(); + }} + size="s" + > + {btnText} + + ); + + const messageBody = ( + + {message && {message}} + {btn} + + ); + + this.showCalloutCallback({ + type, + title, + message: messageBody, + closeHandler: this.hideCallout, + }); + + const { color, iconType } = resolveType(type); + this.toasts.push({ + title, + color, + iconType, + id: `toastsKey_${uuidv4()}`, + text: messageBody, + }); + this.showToastCallback(this.toasts); + }; + + private viewDetectorConfiguration = (): void => { + const state = DataStore.detectors.getState(); + const detectorInput = { ...state?.detectorInput }; + DataStore.detectors.deleteState(); + + this.history?.push({ + pathname: `${ROUTES.DETECTORS_CREATE}`, + state: { detectorInput }, + }); + }; + + public resolvePendingCreationRequest = async (): Promise<{ + detectorId?: string; + dashboardId?: string; + ok: boolean; + }> => { + if (this.state?.pendingRequests) { + const [mappingsResponse, detectorResponse] = (await Promise.all( + this.state?.pendingRequests + )) as [ServerResponse, ServerResponse]; + + let title: string = `Create detector failed.`; + if (!mappingsResponse.ok) { + const message = 'Double check the field mappings and try again.'; + + this.showNotification( + title, + message, + 'danger', + false, + 'Review detector configuration', + DataStore.detectors.viewDetectorConfiguration + ); + + return Promise.resolve({ + ok: false, + error: { title, message }, + }); + } + + if (!detectorResponse.ok) { + this.showNotification( + title, + detectorResponse.error, + 'danger', + false, + 'Review detector configuration', + DataStore.detectors.viewDetectorConfiguration + ); + + return Promise.resolve({ + ok: false, + error: { + title, + message: detectorResponse.error, + }, + }); + } + + let dashboardId; + const detector = detectorResponse.response.detector; + const detectorId = detectorResponse.response._id; + if (logTypesWithDashboards.has(detector.detector_type)) { + const dashboardResponse = await this.createDashboard( + detector.name, + detector.detector_type, + detectorId, + detector.inputs[0].detector_input.indices + ); + if (dashboardResponse && dashboardResponse.ok) { + dashboardId = dashboardResponse.response.id; + } else { + const dashboards = await this.savedObjectsService.getDashboards(); + dashboards.some((dashboard) => { + if (dashboard.references.findIndex((reference) => reference.id === detectorId) > -1) { + dashboardId = dashboard.id; + return true; + } + + return false; + }); + } + } + + const goToDetectorDetails = (e: any) => { + e.preventDefault(); + DataStore.detectors.deleteState(); + this.history?.push(`${ROUTES.DETECTOR_DETAILS}/${detectorId}`); + }; + + title = `Detector created successfully: ${detectorResponse.response.detector.name}`; + this.showNotification( + title, + undefined, + 'success', + false, + 'View detector', + goToDetectorDetails + ); + + return Promise.resolve({ + detectorId: detectorId, + dashboardId: dashboardId, + ok: true, + }); + } + + return Promise.resolve({ ok: false }); + }; + + private createDashboard = ( + detectorName: string, + logType: string, + detectorId: string, + inputIndices: string[] + ) => { + return this.savedObjectsService + .createSavedObject(detectorName, logType, detectorId, inputIndices) + .catch((error: any) => { + console.error(error); + }); + }; + + /** + * A handler function that store gets from the Main component to show/hide the callout message + * @param {ICalloutProps | undefined} callout + */ + private showCalloutCallback = (callout?: ICalloutProps | undefined): void => {}; + + private hideCallout = (): void => this.showCalloutCallback(undefined); + + /** + * A handler function that store gets from the Main component to show/hide the toast message + * @param {Toast[] | undefined} toasts + */ + private showToastCallback = (toasts?: Toast[] | undefined): void => {}; + + public hideToast = (removedToast: any): void => { + this.toasts = this.toasts.filter((toast: Toast) => toast.id !== removedToast.id); + this.showToastCallback(this.toasts); + }; + + public setHandlers = ( + calloutHandler: (callout?: ICalloutProps) => void, + toastHandler: (toasts?: Toast[]) => void + ): void => { + this.showCalloutCallback = calloutHandler; + this.showToastCallback = toastHandler; + }; +} diff --git a/test/mocks/services/browserHistory.mock.ts b/test/mocks/services/browserHistory.mock.ts index a40a1a2ff..9af44b50c 100644 --- a/test/mocks/services/browserHistory.mock.ts +++ b/test/mocks/services/browserHistory.mock.ts @@ -9,4 +9,5 @@ export default ({ location: { pathname: '', }, + push: jest.fn(), } as unknown) as History; diff --git a/test/mocks/services/notifications/NotificationsStart.mock.ts b/test/mocks/services/notifications/NotificationsStart.mock.ts index 73f62608c..f1afac33f 100644 --- a/test/mocks/services/notifications/NotificationsStart.mock.ts +++ b/test/mocks/services/notifications/NotificationsStart.mock.ts @@ -8,5 +8,8 @@ import { NotificationsStart } from 'opensearch-dashboards/public'; export default ({ toasts: { addDanger: jest.fn(), + addWarning: jest.fn(), + addSuccess: jest.fn(), + addInfo: jest.fn(), }, } as unknown) as NotificationsStart;