From 2baf1abf164c75898546ea6b04b2e793bdd3549a Mon Sep 17 00:00:00 2001 From: Jackie Han Date: Mon, 10 Jul 2023 18:17:51 -0700 Subject: [PATCH] Merge featureAnywhere branch into main (#539) * Add branch constants in CI workflow (#345) Signed-off-by: Tyler Ohlsen * Bump decode-uri-component (#359) Signed-off-by: Tyler Ohlsen * removed duplicate popout icon and ran prettier (#382) Signed-off-by: Amit Galitzky * Change detector out of time range modal warning into a callout warning (#384) Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Fix undefined entity list when heatmap is empty (#383) Signed-off-by: Tyler Ohlsen * Updated MAINTAINERS.md to match recommended opensearch-project format. (#388) Signed-off-by: dblock * Add windows env to integration test workflow (#390) Signed-off-by: Tyler Ohlsen * Bump json5 to 2.2.3 (#393) Signed-off-by: Tyler Ohlsen * Add 2.5 release notes (#395) Signed-off-by: Tyler Ohlsen * Update cold start message (#398) Signed-off-by: Kaituo Li * upgrade filter bug (#402) Previously, we didn't actually add filter type when loading old detector. This PR fixed that. Testing done: 1. added a unit tes 2. verified e2et Signed-off-by: Kaituo Li * Changed required minimum intervals in cold start message (#411) Required minimum intervals to finish cold start is related to shingle size. This PR adds the shingle size in the computation and puts the result in the cold start message. Testing done: 1. verified changing shingle size will change the message. Signed-off-by: Kaituo Li * Bump @sideway/formula to 3.0.1 (#418) Signed-off-by: Tyler Ohlsen * Remove auto_expand_replicas override in sample data indices (#423) Signed-off-by: Tyler Ohlsen * Created untriaged issue workflow. (#422) Signed-off-by: dblock * Add 2.6 release notes (#429) Signed-off-by: Tyler Ohlsen * Fix Node.js and Yarn installation in CI (#433) Signed-off-by: Miki * run prettier command against all files Signed-off-by: Jackie Han * Adding expression function to retrieve anomalies (#448) * adding an AD expression function Signed-off-by: Amit Galitzky * making expression fn changes only to plugin.ts Signed-off-by: Amit Galitzky * addressing comments, changed a few constants Signed-off-by: Amit Galitzky * moving getDetectorName into try catch Signed-off-by: Amit Galitzky --------- Signed-off-by: Amit Galitzky * Register AD as dashboard context menu option (#482) * Register AD as dashboard context menu option Signed-off-by: Jackie Han * addressing comments Signed-off-by: Jackie Han * add getActions props Signed-off-by: Jackie Han * add EmbeddableStart Signed-off-by: Jackie Han * remove spread operator Signed-off-by: Jackie Han * clenaup Signed-off-by: Jackie Han * add overlay getter and setter Signed-off-by: Jackie Han --------- Signed-off-by: Jackie Han * Associated detectors flyout through UI actions (#449) * working js manage detectors Signed-off-by: Amit Galitzky * adding associated detectors page Signed-off-by: Amit Galitzky * adding unlink modal confirmation Signed-off-by: Amit Galitzky * prettier formating and merge conflicts Signed-off-by: Amit Galitzky * add unlinking capability Signed-off-by: Amit Galitzky * adding message for no search results Signed-off-by: Amit Galitzky * clean up files Signed-off-by: Amit Galitzky * more cleanup Signed-off-by: Amit Galitzky * making changes based on new upper container Signed-off-by: Amit Galitzky * ran prettier Signed-off-by: Amit Galitzky * fix notification and clean up associated detectors Signed-off-by: Amit Galitzky * addressing comments Signed-off-by: Amit Galitzky * renaming some files and adding index.ts Signed-off-by: Amit Galitzky * Added license to new files Signed-off-by: Amit Galitzky * clean up after rebase Signed-off-by: Amit Galitzky * addressed more comments Signed-off-by: Amit Galitzky * added notifications service as a getter-setter Signed-off-by: Amit Galitzky --------- Signed-off-by: Amit Galitzky * Feature anywhere create detector flyout page (#487) * Add create detector flyout page Signed-off-by: Jackie Han * cleanup Signed-off-by: Jackie Han * run prettier Signed-off-by: Jackie Han * remove enzyme usage Signed-off-by: Jackie Han * reuse existing helper function when creating detector Signed-off-by: Jackie Han * cleanup Signed-off-by: Jackie Han * rebase with associated detector change Signed-off-by: Jackie Han * move helper functions to helper file Signed-off-by: Jackie Han * use VisLayerTypes for VisLayerExpressionFn Signed-off-by: Jackie Han * address comments Signed-off-by: Jackie Han * use OVERLAY_ANOMALIES constant Signed-off-by: Jackie Han --------- Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Adding functionality to associate existing detector with a visualization (#484) * Adding associate existing Signed-off-by: Amit Galitzky * removed usememo, addressed other comments Signed-off-by: Amit Galitzky * merge cleanup Signed-off-by: Amit Galitzky * added integration to call on alerting Signed-off-by: Amit Galitzky * cleaned up files and added changes to check if detector is deleted in expr fn Signed-off-by: Amit Galitzky * fixing dependency and notifcations issues Signed-off-by: Amit Galitzky * removed long toast life time Signed-off-by: Amit Galitzky --------- Signed-off-by: Amit Galitzky * switched argument order (#494) Signed-off-by: Amit Galitzky * add jest transform config (#497) * add jest transform config Signed-off-by: Jackie Han * add license header Signed-off-by: Jackie Han * update file as .ts file Signed-off-by: Jackie Han * add comment on jest config file Signed-off-by: Jackie Han * add more comments Signed-off-by: Jackie Han * cleanup Signed-off-by: Jackie Han * update file export Signed-off-by: Jackie Han --------- Signed-off-by: Jackie Han * Merging main branch into featureAnywhere (#499) * Add branch constants in CI workflow (#345) Signed-off-by: Tyler Ohlsen * Bump decode-uri-component (#359) Signed-off-by: Tyler Ohlsen * removed duplicate popout icon and ran prettier (#382) Signed-off-by: Amit Galitzky * Change detector out of time range modal warning into a callout warning (#384) Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Fix undefined entity list when heatmap is empty (#383) Signed-off-by: Tyler Ohlsen * Updated MAINTAINERS.md to match recommended opensearch-project format. (#388) Signed-off-by: dblock * Add windows env to integration test workflow (#390) Signed-off-by: Tyler Ohlsen * Bump json5 to 2.2.3 (#393) Signed-off-by: Tyler Ohlsen * Add 2.5 release notes (#395) Signed-off-by: Tyler Ohlsen * Update cold start message (#398) Signed-off-by: Kaituo Li * upgrade filter bug (#402) Previously, we didn't actually add filter type when loading old detector. This PR fixed that. Testing done: 1. added a unit tes 2. verified e2et Signed-off-by: Kaituo Li * Changed required minimum intervals in cold start message (#411) Required minimum intervals to finish cold start is related to shingle size. This PR adds the shingle size in the computation and puts the result in the cold start message. Testing done: 1. verified changing shingle size will change the message. Signed-off-by: Kaituo Li * Bump @sideway/formula to 3.0.1 (#418) Signed-off-by: Tyler Ohlsen * Remove auto_expand_replicas override in sample data indices (#423) Signed-off-by: Tyler Ohlsen * Created untriaged issue workflow. (#422) Signed-off-by: dblock * Add 2.6 release notes (#429) Signed-off-by: Tyler Ohlsen * Fix Node.js and Yarn installation in CI (#433) Signed-off-by: Miki * run prettier command against all files (#444) Signed-off-by: Jackie Han * Add 2.7 release notes (#456) Signed-off-by: Jackie Han * updating maintainers and code owners (#476) Signed-off-by: Amit Galitzky * fixing test to pass with node 18 (#491) Signed-off-by: Amit Galitzky --------- Signed-off-by: Tyler Ohlsen Signed-off-by: Amit Galitzky Signed-off-by: Jackie Han Signed-off-by: dblock Signed-off-by: Kaituo Li Signed-off-by: Miki Co-authored-by: Tyler Ohlsen Co-authored-by: Jackie Han Co-authored-by: Daniel (dB.) Doubrovkine Co-authored-by: Kaituo Li Co-authored-by: Miki * Unit tests for expression function and additional components (#503) * adding UT for expression function and some components Signed-off-by: Amit Galitzky * moved helper functions to separate files, cleaned up other tests Signed-off-by: Amit Galitzky * custom result bug fix along with a few others Signed-off-by: Amit Galitzky * revert historical boolean Signed-off-by: Amit Galitzky * add pluginEventType when no error Signed-off-by: Amit Galitzky --------- Signed-off-by: Amit Galitzky * Fix various bugs (#504) * fix bug bash bugs Signed-off-by: Jackie Han * bug fix Signed-off-by: Jackie Han * yarn prettier Signed-off-by: Jackie Han * bug fix Signed-off-by: Jackie Han * bug fixes Signed-off-by: Jackie Han * clean up code Signed-off-by: Jackie Han * removed unused snapshot Signed-off-by: Jackie Han --------- Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Various bug fixes and unit tests for AssociatedDetectors (#505) * associated detectors unit tests Signed-off-by: Amit Galitzky * fixed some bugs and added unit tests for associated detectors Signed-off-by: Amit Galitzky --------- Signed-off-by: Amit Galitzky * make feature anywhere formatted detector name an assgiend value (#506) * make feature anywhere formatted detector name an assgiend value to return Signed-off-by: Jackie Han * update feature anywhere documentation link Signed-off-by: Jackie Han * correct type Signed-off-by: Jackie Han * correct typo Signed-off-by: Jackie Han --------- Signed-off-by: Jackie Han * various bug fixes for anywhere features (#507) Signed-off-by: Amit Galitzky * add group category and order for context menu (#495) Signed-off-by: David Sinclair * Update Advanced configuration accordion title (#513) Signed-off-by: Jackie Han * update categorical field text on Feature Anywhere flyout (#516) * update categorical field text on Feature Anywhere flyout Signed-off-by: Jackie Han * Use non-breaking space character to prevent removing needed empty space by yarn prettier Signed-off-by: Jackie Han * Fix the max width of associate detector flyout to 740px Signed-off-by: Jackie Han --------- Signed-off-by: Jackie Han * fixing vis eligibility (#519) Signed-off-by: Amit Galitzky * render embeddable by using vis embeddable (#521) Signed-off-by: Jackie Han * Change to anomaly start time; add test ids (#522) Signed-off-by: Tyler Ohlsen * Adding a callout on flyout when association limit has been reached (#524) * adding a callout when association limit has been reached Signed-off-by: Amit Galitzky * fixed limit check and ran prettier Signed-off-by: Amit Galitzky --------- Signed-off-by: Amit Galitzky * Merge main into featureAnywhere (#525) * Add branch constants in CI workflow (#345) Signed-off-by: Tyler Ohlsen * Bump decode-uri-component (#359) Signed-off-by: Tyler Ohlsen * removed duplicate popout icon and ran prettier (#382) Signed-off-by: Amit Galitzky * Change detector out of time range modal warning into a callout warning (#384) Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Fix undefined entity list when heatmap is empty (#383) Signed-off-by: Tyler Ohlsen * Updated MAINTAINERS.md to match recommended opensearch-project format. (#388) Signed-off-by: dblock * Add windows env to integration test workflow (#390) Signed-off-by: Tyler Ohlsen * Bump json5 to 2.2.3 (#393) Signed-off-by: Tyler Ohlsen * Add 2.5 release notes (#395) Signed-off-by: Tyler Ohlsen * Update cold start message (#398) Signed-off-by: Kaituo Li * upgrade filter bug (#402) Previously, we didn't actually add filter type when loading old detector. This PR fixed that. Testing done: 1. added a unit tes 2. verified e2et Signed-off-by: Kaituo Li * Changed required minimum intervals in cold start message (#411) Required minimum intervals to finish cold start is related to shingle size. This PR adds the shingle size in the computation and puts the result in the cold start message. Testing done: 1. verified changing shingle size will change the message. Signed-off-by: Kaituo Li * Bump @sideway/formula to 3.0.1 (#418) Signed-off-by: Tyler Ohlsen * Remove auto_expand_replicas override in sample data indices (#423) Signed-off-by: Tyler Ohlsen * Created untriaged issue workflow. (#422) Signed-off-by: dblock * Add 2.6 release notes (#429) Signed-off-by: Tyler Ohlsen * Fix Node.js and Yarn installation in CI (#433) Signed-off-by: Miki * run prettier command against all files (#444) Signed-off-by: Jackie Han * Add 2.7 release notes (#456) Signed-off-by: Jackie Han * updating maintainers and code owners (#476) Signed-off-by: Amit Galitzky * fixing test to pass with node 18 (#491) Signed-off-by: Amit Galitzky * add 2.8.0 release notes (#500) Signed-off-by: Kaituo Li * Remove invalid link (#470) Signed-off-by: Peter Nied Signed-off-by: Peter Nied * Remove global OUI style override (#511) Signed-off-by: Matt Provost --------- Signed-off-by: Tyler Ohlsen Signed-off-by: Amit Galitzky Signed-off-by: Jackie Han Signed-off-by: dblock Signed-off-by: Kaituo Li Signed-off-by: Miki Signed-off-by: Peter Nied Signed-off-by: Peter Nied Signed-off-by: Matt Provost Co-authored-by: Tyler Ohlsen Co-authored-by: Jackie Han Co-authored-by: Daniel (dB.) Doubrovkine Co-authored-by: Kaituo Li Co-authored-by: Miki Co-authored-by: Peter Nied Co-authored-by: Matt Provost * Merging main into featureAnywhere with conflicts resolved (#527) * Add branch constants in CI workflow (#345) Signed-off-by: Tyler Ohlsen * Bump decode-uri-component (#359) Signed-off-by: Tyler Ohlsen * removed duplicate popout icon and ran prettier (#382) Signed-off-by: Amit Galitzky * Change detector out of time range modal warning into a callout warning (#384) Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Fix undefined entity list when heatmap is empty (#383) Signed-off-by: Tyler Ohlsen * Updated MAINTAINERS.md to match recommended opensearch-project format. (#388) Signed-off-by: dblock * Add windows env to integration test workflow (#390) Signed-off-by: Tyler Ohlsen * Bump json5 to 2.2.3 (#393) Signed-off-by: Tyler Ohlsen * Add 2.5 release notes (#395) Signed-off-by: Tyler Ohlsen * Update cold start message (#398) Signed-off-by: Kaituo Li * upgrade filter bug (#402) Previously, we didn't actually add filter type when loading old detector. This PR fixed that. Testing done: 1. added a unit tes 2. verified e2et Signed-off-by: Kaituo Li * Changed required minimum intervals in cold start message (#411) Required minimum intervals to finish cold start is related to shingle size. This PR adds the shingle size in the computation and puts the result in the cold start message. Testing done: 1. verified changing shingle size will change the message. Signed-off-by: Kaituo Li * Bump @sideway/formula to 3.0.1 (#418) Signed-off-by: Tyler Ohlsen * Remove auto_expand_replicas override in sample data indices (#423) Signed-off-by: Tyler Ohlsen * Created untriaged issue workflow. (#422) Signed-off-by: dblock * Add 2.6 release notes (#429) Signed-off-by: Tyler Ohlsen * Fix Node.js and Yarn installation in CI (#433) Signed-off-by: Miki * run prettier command against all files (#444) Signed-off-by: Jackie Han * Add 2.7 release notes (#456) Signed-off-by: Jackie Han * updating maintainers and code owners (#476) Signed-off-by: Amit Galitzky * fixing test to pass with node 18 (#491) Signed-off-by: Amit Galitzky * add 2.8.0 release notes (#500) Signed-off-by: Kaituo Li * Remove invalid link (#470) Signed-off-by: Peter Nied Signed-off-by: Peter Nied * Remove global OUI style override (#511) Signed-off-by: Matt Provost --------- Signed-off-by: Tyler Ohlsen Signed-off-by: Amit Galitzky Signed-off-by: Jackie Han Signed-off-by: dblock Signed-off-by: Kaituo Li Signed-off-by: Miki Signed-off-by: Peter Nied Signed-off-by: Peter Nied Signed-off-by: Matt Provost Co-authored-by: Tyler Ohlsen Co-authored-by: Jackie Han Co-authored-by: Daniel (dB.) Doubrovkine Co-authored-by: Kaituo Li Co-authored-by: Miki Co-authored-by: Peter Nied Co-authored-by: Matt Provost * copy files from main to resolve conflicts (#533) Signed-off-by: Jackie Han * copy files to resolve conflicts (#538) Signed-off-by: Jackie Han * Feature anywhere (#540) * Add branch constants in CI workflow (#345) Signed-off-by: Tyler Ohlsen * Bump decode-uri-component (#359) Signed-off-by: Tyler Ohlsen * removed duplicate popout icon and ran prettier (#382) Signed-off-by: Amit Galitzky * Change detector out of time range modal warning into a callout warning (#384) Signed-off-by: Jackie Han Signed-off-by: Jackie Han * Fix undefined entity list when heatmap is empty (#383) Signed-off-by: Tyler Ohlsen * Updated MAINTAINERS.md to match recommended opensearch-project format. (#388) Signed-off-by: dblock * Add windows env to integration test workflow (#390) Signed-off-by: Tyler Ohlsen * Bump json5 to 2.2.3 (#393) Signed-off-by: Tyler Ohlsen * Add 2.5 release notes (#395) Signed-off-by: Tyler Ohlsen * Update cold start message (#398) Signed-off-by: Kaituo Li * upgrade filter bug (#402) Previously, we didn't actually add filter type when loading old detector. This PR fixed that. Testing done: 1. added a unit tes 2. verified e2et Signed-off-by: Kaituo Li * Changed required minimum intervals in cold start message (#411) Required minimum intervals to finish cold start is related to shingle size. This PR adds the shingle size in the computation and puts the result in the cold start message. Testing done: 1. verified changing shingle size will change the message. Signed-off-by: Kaituo Li * Bump @sideway/formula to 3.0.1 (#418) Signed-off-by: Tyler Ohlsen * Remove auto_expand_replicas override in sample data indices (#423) Signed-off-by: Tyler Ohlsen * Created untriaged issue workflow. (#422) Signed-off-by: dblock * Add 2.6 release notes (#429) Signed-off-by: Tyler Ohlsen * Fix Node.js and Yarn installation in CI (#433) Signed-off-by: Miki * run prettier command against all files (#444) Signed-off-by: Jackie Han * Add 2.7 release notes (#456) Signed-off-by: Jackie Han * updating maintainers and code owners (#476) Signed-off-by: Amit Galitzky * fixing test to pass with node 18 (#491) Signed-off-by: Amit Galitzky * add 2.8.0 release notes (#500) Signed-off-by: Kaituo Li * Remove invalid link (#470) Signed-off-by: Peter Nied Signed-off-by: Peter Nied * Remove global OUI style override (#511) Signed-off-by: Matt Provost --------- Signed-off-by: Tyler Ohlsen Signed-off-by: Amit Galitzky Signed-off-by: Jackie Han Signed-off-by: dblock Signed-off-by: Kaituo Li Signed-off-by: Miki Signed-off-by: Peter Nied Signed-off-by: Peter Nied Signed-off-by: Matt Provost Co-authored-by: Tyler Ohlsen Co-authored-by: Amit Galitzky Co-authored-by: Daniel (dB.) Doubrovkine Co-authored-by: Kaituo Li Co-authored-by: Miki Co-authored-by: Peter Nied Co-authored-by: Matt Provost --------- Signed-off-by: Tyler Ohlsen Signed-off-by: Amit Galitzky Signed-off-by: Jackie Han Signed-off-by: dblock Signed-off-by: Kaituo Li Signed-off-by: Miki Signed-off-by: Jackie Han Signed-off-by: David Sinclair Signed-off-by: Peter Nied Signed-off-by: Peter Nied Signed-off-by: Matt Provost Co-authored-by: Tyler Ohlsen Co-authored-by: Amit Galitzky Co-authored-by: Daniel (dB.) Doubrovkine Co-authored-by: Kaituo Li Co-authored-by: Miki Co-authored-by: David Sinclair <24573542+sikhote@users.noreply.github.com> Co-authored-by: Peter Nied Co-authored-by: Matt Provost (cherry picked from commit 1e0d783e624f7b3c37ba094c781fd62fa394fe51) --- opensearch_dashboards.json | 15 +- public/action/ad_dashboard_action.tsx | 78 ++ .../AnywhereParentFlyout.tsx | 41 + .../AnywhereParentFlyout/constants.ts | 13 + .../AnywhereParentFlyout/index.tsx | 8 + .../ConfirmUnlinkDetectorModal.tsx | 78 ++ .../EmptyAssociatedDetectorMessage.tsx | 32 + .../ConfirmUnlinkDetectorModal.test.tsx | 69 ++ .../EmptyAssociatedDetectorMessage.test.tsx | 40 + ...ptyAssociatedDetectorMessage.test.tsx.snap | 69 ++ .../AssociatedDetectors/components/index.ts | 7 + .../containers/AssociatedDetectors.tsx | 366 ++++++ .../__tests__/AssociatedDetectors.test.tsx | 389 +++++++ .../AssociatedDetectors/index.ts | 6 + .../AssociatedDetectors/styles.scss | 23 + .../AssociatedDetectors/utils/constants.tsx | 8 + .../AssociatedDetectors/utils/helpers.tsx | 76 ++ .../AddAnomalyDetector.tsx | 1006 +++++++++++++++++ .../containers/AssociateExisting.tsx | 281 +++++ .../AssociateExisting/index.ts | 6 + .../CreateAnomalyDetector/helpers.tsx | 87 ++ .../CreateAnomalyDetector/index.tsx | 8 + .../CreateAnomalyDetector/styles.scss | 68 ++ .../containers/DocumentationTitle.tsx | 28 + .../DocumentationTitle/index.tsx | 8 + .../EnhancedAccordion/EnhancedAccordion.tsx | 88 ++ .../EnhancedAccordion/index.tsx | 8 + .../EnhancedAccordion/styles.scss | 32 + .../MinimalAccordion/MinimalAccordion.tsx | 64 ++ .../MinimalAccordion/index.tsx | 8 + .../MinimalAccordion/styles.scss | 28 + .../__tests__/overlay_anomalies.test.ts | 154 +++ public/expressions/constants.ts | 25 + public/expressions/helpers.ts | 139 +++ public/expressions/overlay_anomalies.ts | 161 +++ public/models/interfaces.ts | 1 + .../FeatureAccordion/FeatureAccordion.tsx | 41 +- .../components/FeatureAccordion/styles.scss | 3 + .../__tests__/AnomaliesLiveCharts.test.tsx | 4 +- .../Dashboard/utils/__tests__/utils.test.tsx | 18 +- .../DetectorConfig/containers/Features.tsx | 1 + public/pages/utils/__tests__/constants.ts | 177 ++- public/plugin.ts | 98 +- public/services.ts | 52 + public/utils/constants.ts | 11 + public/utils/contextMenu/getActions.tsx | 89 ++ server/utils/helpers.ts | 4 + test/mocks/transformMock.ts | 27 + 48 files changed, 3999 insertions(+), 44 deletions(-) create mode 100644 public/action/ad_dashboard_action.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts create mode 100644 public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts create mode 100644 public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss create mode 100644 public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss create mode 100644 public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx create mode 100644 public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss create mode 100644 public/expressions/__tests__/overlay_anomalies.test.ts create mode 100644 public/expressions/constants.ts create mode 100644 public/expressions/helpers.ts create mode 100644 public/expressions/overlay_anomalies.ts create mode 100644 public/pages/ConfigureModel/components/FeatureAccordion/styles.scss create mode 100644 public/services.ts create mode 100644 public/utils/contextMenu/getActions.tsx create mode 100644 test/mocks/transformMock.ts diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 293c8f6a..7c1909fd 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,8 +3,19 @@ "version": "2.9.0.0", "opensearchDashboardsVersion": "2.9.0", "configPath": ["anomaly_detection_dashboards"], - "requiredPlugins": ["navigation"], - "optionalPlugins": [], + "requiredPlugins": [ + "opensearchDashboardsUtils", + "expressions", + "data", + "visAugmenter", + "uiActions", + "dashboard", + "embeddable", + "opensearchDashboardsReact", + "savedObjects", + "visAugmenter", + "opensearchDashboardsUtils" + ], "server": true, "ui": true } diff --git a/public/action/ad_dashboard_action.tsx b/public/action/ad_dashboard_action.tsx new file mode 100644 index 00000000..2cde952b --- /dev/null +++ b/public/action/ad_dashboard_action.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { IEmbeddable } from '../../../../src/plugins/dashboard/public/embeddable_plugin'; +import { + DASHBOARD_CONTAINER_TYPE, + DashboardContainer, +} from '../../../../src/plugins/dashboard/public'; +import { + IncompatibleActionError, + createAction, + Action, +} from '../../../../src/plugins/ui_actions/public'; +import { isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { VisualizeEmbeddable } from '../../../../src/plugins/visualizations/public'; +import { isEligibleForVisLayers } from '../../../../src/plugins/vis_augmenter/public'; +import { getUISettings } from '../services'; + +export const ACTION_AD = 'ad'; + +function isDashboard( + embeddable: IEmbeddable +): embeddable is DashboardContainer { + return embeddable.type === DASHBOARD_CONTAINER_TYPE; +} + +export interface ActionContext { + embeddable: IEmbeddable; +} + +export interface CreateOptions { + grouping: Action['grouping']; + title: string; + icon: EuiIconType; + id: string; + order: number; + onClick: Function; +} + +export const createADAction = ({ + grouping, + title, + icon, + id, + order, + onClick, +}: CreateOptions) => + createAction({ + id, + order, + getDisplayName: ({ embeddable }: ActionContext) => { + if (!embeddable.parent || !isDashboard(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return title; + }, + getIconType: () => icon, + type: ACTION_AD, + grouping, + isCompatible: async ({ embeddable }: ActionContext) => { + const vis = (embeddable as VisualizeEmbeddable).vis; + return Boolean( + embeddable.parent && + embeddable.getInput()?.viewMode === 'view' && + isDashboard(embeddable.parent) && + vis !== undefined && + isEligibleForVisLayers(vis, getUISettings()) + ); + }, + execute: async ({ embeddable }: ActionContext) => { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + onClick({ embeddable }); + }, + }); diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx new file mode 100644 index 00000000..5ab72b2d --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { useState } from 'react'; +import { get } from 'lodash'; +import AssociatedDetectors from '../AssociatedDetectors/containers/AssociatedDetectors'; +import { getEmbeddable } from '../../../../public/services'; +import AddAnomalyDetector from '../CreateAnomalyDetector'; +import { FLYOUT_MODES } from './constants'; + +const AnywhereParentFlyout = ({ startingFlyout, ...props }) => { + const embeddable = getEmbeddable().getEmbeddableFactory; + const indices: { label: string }[] = [ + { label: get(embeddable, 'vis.data.indexPattern.title', '') }, + ]; + + const [mode, setMode] = useState(startingFlyout); + const [selectedDetector, setSelectedDetector] = useState(undefined); + + const AnywhereFlyout = { + [FLYOUT_MODES.create]: AddAnomalyDetector, + [FLYOUT_MODES.associated]: AssociatedDetectors, + [FLYOUT_MODES.existing]: AddAnomalyDetector, + }[mode]; + + return ( + + ); +}; + +export default AnywhereParentFlyout; diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts new file mode 100644 index 00000000..fa470962 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +//created: Flyout for creating a new anomaly detector from a visualization +//associated: Flyout for listing all the associated detectors to the given visualization +//existing: Flyout for associating existing detectors with the current visualizations +export enum FLYOUT_MODES { + create = 'create', + associated = 'associated', + existing = 'existing', +} diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx new file mode 100644 index 00000000..591d4b6d --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import AnywhereParentFlyout from './AnywhereParentFlyout'; + +export default AnywhereParentFlyout; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx new file mode 100644 index 00000000..98d5d155 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiOverlayMask, + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalHeader, + EuiModalFooter, + EuiModalBody, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { DetectorListItem } from '../../../../../models/interfaces'; +import { EuiSpacer } from '@elastic/eui'; + +interface ConfirmUnlinkDetectorModalProps { + detector: DetectorListItem; + onUnlinkDetector(): void; + onHide(): void; + onConfirm(): void; + isListLoading: boolean; +} + +export const ConfirmUnlinkDetectorModal = ( + props: ConfirmUnlinkDetectorModalProps +) => { + const [isModalLoading, setIsModalLoading] = useState(false); + const isLoading = isModalLoading || props.isListLoading; + return ( + + + + {'Remove association?'} + + + + Removing association unlinks {props.detector.name} detector from the + visualization but does not delete it. The detector association can + be restored. + + + + + {isLoading ? null : ( + + Cancel + + )} + { + setIsModalLoading(true); + props.onUnlinkDetector(); + props.onConfirm(); + }} + > + {'Remove association'} + + + + + ); +}; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx new file mode 100644 index 00000000..d005e087 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import React from 'react'; + +const FILTER_TEXT = 'There are no detectors matching your search'; + +interface EmptyDetectorProps { + isFilterApplied: boolean; + embeddableTitle: string; +} + +export const EmptyAssociatedDetectorMessage = (props: EmptyDetectorProps) => ( + No anomaly detectors to display} + titleSize="s" + data-test-subj="emptyAssociatedDetectorFlyoutMessage" + style={{ maxWidth: '45em' }} + body={ + +

+ {props.isFilterApplied + ? FILTER_TEXT + : `There are no anomaly detectors associated with ${props.embeddableTitle} visualization.`} +

+
+ } + /> +); diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx new file mode 100644 index 00000000..ed055dec --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { findAllByTestId, render, waitFor } from '@testing-library/react'; +import { ConfirmUnlinkDetectorModal } from '../index'; +import { getRandomDetector } from '../../../../../../public/redux/reducers/__tests__/utils'; +import { DetectorListItem } from '../../../../../../public/models/interfaces'; +import userEvent from '@testing-library/user-event'; + +describe('ConfirmUnlinkDetectorModal spec', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testDetectors = [ + { + id: 'detectorId1', + name: 'test-detector-1', + }, + { + id: 'detectorId2', + name: 'test-detector-2', + }, + ] as DetectorListItem[]; + + const ConfirmUnlinkDetectorModalProps = { + detector: testDetectors[0], + onHide: jest.fn(), + onConfirm: jest.fn(), + onUnlinkDetector: jest.fn(), + isListLoading: false, + }; + + test('renders the component correctly', () => { + const { container, getByText } = render( + + ); + getByText('Remove association?'); + getByText( + 'Removing association unlinks test-detector-1 detector from the visualization but does not delete it. The detector association can be restored.' + ); + }); + test('should call onConfirm() when closing', async () => { + const { container, getByText, getByTestId } = render( + + ); + getByText('Remove association?'); + userEvent.click(getByTestId('confirmUnlinkButton')); + expect(ConfirmUnlinkDetectorModalProps.onConfirm).toHaveBeenCalled(); + }); + test('should call onConfirm() when closing', async () => { + const { container, getByText, getByTestId } = render( + + ); + getByText('Remove association?'); + userEvent.click(getByTestId('confirmUnlinkButton')); + expect(ConfirmUnlinkDetectorModalProps.onConfirm).toHaveBeenCalled(); + }); + test('should call onHide() when closing', async () => { + const { getByTestId } = render( + + ); + userEvent.click(getByTestId('cancelUnlinkButton')); + expect(ConfirmUnlinkDetectorModalProps.onHide).toHaveBeenCalled(); + }); +}); diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx new file mode 100644 index 00000000..21b684be --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { findAllByTestId, render, waitFor } from '@testing-library/react'; +import { EmptyAssociatedDetectorMessage } from '../index'; +import { getRandomDetector } from '../../../../../../public/redux/reducers/__tests__/utils'; +import { DetectorListItem } from '../../../../../../public/models/interfaces'; +import userEvent from '@testing-library/user-event'; + +describe('ConfirmUnlinkDetectorModal spec', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders the component with filter applied', () => { + const { container, getByText } = render( + + ); + getByText('There are no detectors matching your search'); + expect(container).toMatchSnapshot(); + }); + test('renders the component with filter applied', () => { + const { container, getByText } = render( + + ); + getByText( + 'There are no anomaly detectors associated with test-title visualization.' + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap new file mode 100644 index 00000000..15c1a6c3 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConfirmUnlinkDetectorModal spec renders the component with filter applied 1`] = ` +
+
+

+ No anomaly detectors to display +

+ +
+
+
+

+ There are no detectors matching your search +

+
+
+ +
+
+`; + +exports[`ConfirmUnlinkDetectorModal spec renders the component with filter applied 2`] = ` +
+
+

+ No anomaly detectors to display +

+ +
+
+
+

+ There are no anomaly detectors associated with test-title visualization. +

+
+
+ +
+
+`; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts new file mode 100644 index 00000000..92d619eb --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ConfirmUnlinkDetectorModal } from './ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal'; +export { EmptyAssociatedDetectorMessage } from './EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage'; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx new file mode 100644 index 00000000..1cbdbc82 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx @@ -0,0 +1,366 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useEffect, useState } from 'react'; +import { + EuiFlyoutHeader, + EuiTitle, + EuiSpacer, + EuiInMemoryTable, + EuiFlyoutBody, + EuiButton, + EuiFlyout, + EuiFlexItem, + EuiFlexGroup, + EuiCallOut, +} from '@elastic/eui'; +import { get, isEmpty } from 'lodash'; +import '../styles.scss'; +import { getColumns } from '../utils/helpers'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppState } from '../../../../redux/reducers'; +import { DetectorListItem } from '../../../../models/interfaces'; +import { + getSavedFeatureAnywhereLoader, + getNotifications, + getUISettings, +} from '../../../../services'; +import { + GET_ALL_DETECTORS_QUERY_PARAMS, + SINGLE_DETECTOR_NOT_FOUND_MSG, +} from '../../../../pages/utils/constants'; +import { getDetectorList } from '../../../../redux/reducers/ad'; +import { + prettifyErrorMessage, + NO_PERMISSIONS_KEY_WORD, +} from '../../../../../server/utils/helpers'; +import { + EmptyAssociatedDetectorMessage, + ConfirmUnlinkDetectorModal, +} from '../components'; +import { + ISavedAugmentVis, + SavedAugmentVisLoader, + getAugmentVisSavedObjs, +} from '../../../../../../../src/plugins/vis_augmenter/public'; +import { ASSOCIATED_DETECTOR_ACTION } from '../utils/constants'; +import { PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING } from '../../../../../public/expressions/constants'; + +interface ConfirmModalState { + isOpen: boolean; + action: ASSOCIATED_DETECTOR_ACTION; + isListLoading: boolean; + isRequestingToClose: boolean; + affectedDetector: DetectorListItem; +} + +function AssociatedDetectors({ embeddable, closeFlyout, setMode }) { + const dispatch = useDispatch(); + const allDetectors = useSelector((state: AppState) => state.ad.detectorList); + const isRequestingFromES = useSelector( + (state: AppState) => state.ad.requesting + ); + const [isLoadingFinalDetectors, setIsLoadingFinalDetectors] = + useState(true); + const isLoading = isRequestingFromES || isLoadingFinalDetectors; + const errorGettingDetectors = useSelector( + (state: AppState) => state.ad.errorMessage + ); + const embeddableTitle = embeddable.getTitle(); + const [selectedDetectors, setSelectedDetectors] = useState( + [] as DetectorListItem[] + ); + + const [detectorToUnlink, setDetectorToUnlink] = useState( + {} as DetectorListItem + ); + const [associationLimitReached, setAssociationLimitReached] = + useState(false); + const [confirmModalState, setConfirmModalState] = useState( + { + isOpen: false, + //@ts-ignore + action: null, + isListLoading: false, + isRequestingToClose: false, + affectedDetector: {} as DetectorListItem, + } + ); + + // Establish savedObjectLoader for all operations on vis_augment saved objects + const savedObjectLoader: SavedAugmentVisLoader = + getSavedFeatureAnywhereLoader(); + + const uiSettings = getUISettings(); + const notifications = getNotifications(); + let maxAssociatedCount = uiSettings.get( + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING + ); + + useEffect(() => { + if ( + errorGettingDetectors && + !errorGettingDetectors.includes(SINGLE_DETECTOR_NOT_FOUND_MSG) + ) { + console.error(errorGettingDetectors); + notifications.toasts.addDanger( + typeof errorGettingDetectors === 'string' && + errorGettingDetectors.includes(NO_PERMISSIONS_KEY_WORD) + ? prettifyErrorMessage(errorGettingDetectors) + : 'Unable to get all detectors' + ); + setIsLoadingFinalDetectors(false); + } + }, [errorGettingDetectors]); + + // Update modal state if user decides to close modal + useEffect(() => { + if (confirmModalState.isRequestingToClose) { + if (isLoading) { + setConfirmModalState({ + ...confirmModalState, + isListLoading: true, + }); + } else { + setConfirmModalState({ + ...confirmModalState, + isOpen: false, + isListLoading: false, + isRequestingToClose: false, + }); + } + } + }, [confirmModalState.isRequestingToClose, isLoading]); + + useEffect(() => { + getDetectors(); + }, []); + + // Handles all changes in the assoicated detectors such as unlinking or new detectors associated + useEffect(() => { + // Gets all augmented saved objects that are associated to the given visualization + getAugmentVisSavedObjs(embeddable.vis.id, savedObjectLoader, uiSettings) + .then((savedAugmentObjectsArr: any) => { + if (savedAugmentObjectsArr != undefined) { + if (maxAssociatedCount <= savedAugmentObjectsArr.length) { + setAssociationLimitReached(true); + } else { + setAssociationLimitReached(false); + } + const curSelectedDetectors = getAssociatedDetectors( + Object.values(allDetectors), + savedAugmentObjectsArr + ); + setSelectedDetectors(curSelectedDetectors); + maxAssociatedCount = uiSettings.get( + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING + ); + setIsLoadingFinalDetectors(false); + } + }) + .catch((error) => { + notifications.toasts.addDanger( + prettifyErrorMessage(`Unable to fetch associated detectors: ${error}`) + ); + }); + }, [allDetectors]); + + // cross checks all the detectors that exist with all the savedAugment Objects to only display ones + // that are associated to the current visualization + const getAssociatedDetectors = ( + detectors: DetectorListItem[], + savedAugmentForThisVisualization: ISavedAugmentVis[] + ) => { + // Map all detector IDs for all the found augmented vis objects + const savedAugmentDetectorsSet = new Set( + savedAugmentForThisVisualization.map((savedObject) => + get(savedObject, 'pluginResource.id', '') + ) + ); + + // filter out any detectors that aren't on the set of detectors IDs from the augmented vis objects. + const detectorsToDisplay = detectors.filter((detector) => + savedAugmentDetectorsSet.has(detector.id) + ); + return detectorsToDisplay; + }; + + const onUnlinkDetector = async () => { + setIsLoadingFinalDetectors(true); + // Gets all augmented saved objects that are associated to the given visualization + await getAugmentVisSavedObjs( + embeddable.vis.id, + savedObjectLoader, + uiSettings + ).then(async (savedAugmentForThisVisualization: any) => { + if (savedAugmentForThisVisualization != undefined) { + // find saved augment object matching detector we want to unlink + // There should only be one detector and vis pairing + const savedAugmentToUnlink = savedAugmentForThisVisualization.find( + (savedObject) => + get(savedObject, 'pluginResource.id', '') === detectorToUnlink.id + ); + await savedObjectLoader + .delete(get(savedAugmentToUnlink, 'id', '')) + .then(async (resp: any) => { + notifications.toasts.addSuccess({ + title: `Association removed between the ${detectorToUnlink.name} + and the ${embeddableTitle} visualization`, + text: "The detector's anomalies do not appear on the visualization. Refresh your dashboard to update the visualization", + }); + }) + .catch((error) => { + notifications.toasts.addDanger( + prettifyErrorMessage( + `Error unlinking selected detector: ${error}` + ) + ); + }) + .finally(() => { + getDetectors(); + }); + } + }); + }; + + const handleHideModal = () => { + setConfirmModalState({ + ...confirmModalState, + isOpen: false, + }); + }; + + const handleConfirmModal = () => { + setConfirmModalState({ + ...confirmModalState, + isRequestingToClose: true, + }); + }; + + const getDetectors = async () => { + dispatch(getDetectorList(GET_ALL_DETECTORS_QUERY_PARAMS)); + }; + + const handleUnlinkDetectorAction = (detector: DetectorListItem) => { + setDetectorToUnlink(detector); + setConfirmModalState({ + isOpen: true, + action: ASSOCIATED_DETECTOR_ACTION.UNLINK, + isListLoading: false, + isRequestingToClose: false, + affectedDetector: detector, + }); + }; + + const columns = useMemo( + () => getColumns({ handleUnlinkDetectorAction }), + [handleUnlinkDetectorAction] + ); + + const renderEmptyMessage = () => { + if (isLoading) { + return 'Loading detectors...'; + } else if (!isEmpty(selectedDetectors)) { + return ( + + ); + } else { + return ( + + ); + } + }; + + const tableProps = { + items: selectedDetectors, + columns, + search: { + box: { + disabled: selectedDetectors.length === 0, + incremental: true, + schema: true, + }, + }, + hasActions: true, + pagination: true, + sorting: true, + message: renderEmptyMessage(), + }; + return ( +
+ + + +

+ Associated anomaly detectors +

+
+
+ {associationLimitReached ? ( + + Adding more objects may affect cluster performance and prevent + dashboards from rendering properly. Remove associations before + adding new ones. + + ) : null} + + {confirmModalState.isOpen ? ( + + ) : null} + + + +

Visualization: {embeddableTitle}

+
+
+ +
+ { + setMode('existing'); + }} + > + Associate a detector + +
+
+
+ + +
+
+
+ ); +} + +export default AssociatedDetectors; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx new file mode 100644 index 00000000..7ee94119 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx @@ -0,0 +1,389 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import AssociatedDetectors from '../AssociatedDetectors'; +import { createMockVisEmbeddable } from '../../../../../../../../src/plugins/vis_augmenter/public/mocks'; +import { FLYOUT_MODES } from '../../../../../../public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants'; +import { CoreServicesContext } from '../../../../../../public/components/CoreServices/CoreServices'; +import { coreServicesMock, httpClientMock } from '../../../../../../test/mocks'; +import { + HashRouter as Router, + RouteComponentProps, + Route, + Switch, +} from 'react-router-dom'; +import { Provider } from 'react-redux'; +import configureStore from '../../../../../../public/redux/configureStore'; +import { VisualizeEmbeddable } from '../../../../../../../../src/plugins/visualizations/public'; +import { + setSavedFeatureAnywhereLoader, + setUISettings, +} from '../../../../../services'; +import { + generateAugmentVisSavedObject, + VisLayerExpressionFn, + VisLayerTypes, + createSavedAugmentVisLoader, + setUISettings as setVisAugUISettings, + getMockAugmentVisSavedObjectClient, + SavedObjectLoaderAugmentVis, +} from '../../../../../../../../src/plugins/vis_augmenter/public'; +import { getAugmentVisSavedObjs } from '../../../../../../../../src/plugins/vis_augmenter/public/utils'; +import { uiSettingsServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + PLUGIN_AUGMENTATION_ENABLE_SETTING, + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING, +} from '../../../../../../../../src/plugins/vis_augmenter/common'; +import userEvent from '@testing-library/user-event'; +const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, +} as VisLayerExpressionFn; +const originPlugin = 'test-plugin'; + +const uiSettingsMock = uiSettingsServiceMock.createStartContract(); +setUISettings(uiSettingsMock); +setVisAugUISettings(uiSettingsMock); +const setUIAugSettings = (isEnabled = true, maxCount = 10) => { + uiSettingsMock.get.mockImplementation((key: string) => { + if (key === PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING) return maxCount; + else if (key === PLUGIN_AUGMENTATION_ENABLE_SETTING) return isEnabled; + else return false; + }); +}; + +setUIAugSettings(); + +jest.mock('../../../../../services', () => ({ + ...jest.requireActual('../../../../../services'), + + getUISettings: () => { + return { + get: (config: string) => { + switch (config) { + case 'visualization:enablePluginAugmentation': + return true; + case 'visualization:enablePluginAugmentation.maxPluginObjects': + return 10; + default: + throw new Error( + `Accessing ${config} is not supported in the mock.` + ); + } + }, + }; + }, + getNotifications: () => { + return { + toasts: { + addDanger: jest.fn().mockName('addDanger'), + addSuccess: jest.fn().mockName('addSuccess'), + }, + }; + }, +})); + +jest.mock( + '../../../../../../../../src/plugins/vis_augmenter/public/utils', + () => ({ + getAugmentVisSavedObjs: jest.fn(), + }) +); +const visEmbeddable = createMockVisEmbeddable( + 'test-saved-obj-id', + 'test-title', + false +); + +const renderWithRouter = (visEmbeddable: VisualizeEmbeddable) => ({ + ...render( + + + + ( + + + + )} + /> + + + + ), +}); +describe('AssociatedDetectors spec', () => { + let augmentVisLoader: SavedObjectLoaderAugmentVis; + let mockDeleteFn: jest.Mock; + let detectorsToAssociate = new Array(2).fill(null).map((_, index) => { + return { + id: `detector_id_${index}`, + name: `detector_name_${index}`, + indices: [`index_${index}`], + totalAnomalies: 5, + lastActiveAnomaly: Date.now() + index, + }; + }); + //change one of the two detectors to have an ID not matching the ID in saved object + detectorsToAssociate[1].id = '5'; + + const savedObjects = new Array(2).fill(null).map((_, index) => { + const pluginResource = { + type: 'test-plugin', + id: `detector_id_${index}`, + }; + return generateAugmentVisSavedObject( + `valid-obj-id-${index}`, + fn, + `vis-id-${index}`, + originPlugin, + pluginResource + ); + }); + beforeEach(() => { + mockDeleteFn = jest.fn().mockResolvedValue('someValue'); + augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(savedObjects), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + setSavedFeatureAnywhereLoader(augmentVisLoader); + }); + describe('Renders loading component', () => { + test('renders the detector is loading', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { detectorList: [], totalDetectors: 0 }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText } = renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + getByText('Real-time state'); + getByText('Associate a detector'); + }); + }); + + describe('renders either one or zero detectors', () => { + test('renders one associated detector', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText, queryByText } = renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + await waitFor(() => getByText('detector_name_0')); + getByText('5'); + expect(queryByText('detector_name_1')).toBeNull(); + }, 80000); + test('renders no associated detectors', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: [detectorsToAssociate[1]], + totalDetectors: 1, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText, findByText } = renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + await waitFor(() => + findByText( + 'There are no anomaly detectors associated with test-title visualization.', + undefined, + { timeout: 100000 } + ) + ); + }, 150000); + }); + + describe('tests unlink functionality', () => { + test('unlinks a single detector', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText, queryByText, getAllByTestId } = + renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + await waitFor(() => getByText('detector_name_0')); + getByText('5'); + expect(queryByText('detector_name_1')).toBeNull(); + userEvent.click(getAllByTestId('unlinkButton')[0]); + await waitFor(() => + getByText( + 'Removing association unlinks detector_name_0 detector from the visualization but does not delete it. The detector association can be restored.' + ) + ); + userEvent.click(getAllByTestId('confirmUnlinkButton')[0]); + expect( + ( + await getAugmentVisSavedObjs( + 'valid-obj-id-0', + augmentVisLoader, + uiSettingsMock + ) + ).length + ).toEqual(2); + await waitFor(() => expect(mockDeleteFn).toHaveBeenCalledTimes(1)); + }, 100000); + }); +}); + +//I have a new beforeEach because I making a lot more detectors and saved objects for these tests +describe('test over 10 associated objects functionality', () => { + let augmentVisLoader: SavedObjectLoaderAugmentVis; + let mockDeleteFn: jest.Mock; + const detectorsToAssociate = new Array(16).fill(null).map((_, index) => { + const hasAnomaly = Math.random() > 0.5; + return { + id: `detector_id_${index}`, + name: `detector_name_${index}`, + indices: [`index_${index}`], + totalAnomalies: hasAnomaly ? Math.floor(Math.random() * 10) : 0, + lastActiveAnomaly: hasAnomaly ? Date.now() + index : 0, + }; + }); + + const savedObjects = new Array(16).fill(null).map((_, index) => { + const pluginResource = { + type: 'test-plugin', + id: `detector_id_${index}`, + }; + return generateAugmentVisSavedObject( + `valid-obj-id-${index}`, + fn, + `vis-id-${index}`, + originPlugin, + pluginResource + ); + }); + beforeEach(() => { + mockDeleteFn = jest.fn().mockResolvedValue('someValue'); + augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(savedObjects), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + setSavedFeatureAnywhereLoader(augmentVisLoader); + }); + test('create 20 detectors and saved objects', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + + const { getByText, queryByText, getAllByTestId, findByText } = + renderWithRouter(visEmbeddable); + + await waitFor(() => + findByText('detector_name_1', undefined, { timeout: 200000 }) + ); + expect(queryByText('detector_name_15')).toBeNull(); + // Navigate to next page + await waitFor(() => + userEvent.click(getAllByTestId('pagination-button-next')[0]) + ); + await waitFor(() => findByText('detector_name_15')); + + expect(queryByText('detector_name_0')).toBeNull(); + // Navigate to previous page + await waitFor(() => + userEvent.click(getAllByTestId('pagination-button-previous')[0]) + ); + getByText('detector_name_0'); + expect(queryByText('detector_name_15')).toBeNull(); + }, 200000); + + test('searching functionality', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + + const { queryByText, getByPlaceholderText, findByText } = + renderWithRouter(visEmbeddable); + + // initial load only first 10 detectors + await waitFor(() => + findByText('detector_name_1', undefined, { timeout: 60000 }) + ); + expect(queryByText('detector_name_15')).toBeNull(); + + //Input search event + userEvent.type(getByPlaceholderText('Search...'), 'detector_name_15'); + await waitFor(() => { + findByText('detector_name_15'); + }); + expect(queryByText('detector_name_1')).toBeNull(); + }, 100000); + + test('sorting functionality', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + + const { queryByText, getAllByTestId, findByText } = + renderWithRouter(visEmbeddable); + + // initial load only first 10 detectors (string sort means detector_name_0 -> detector_name_9 show up) + await waitFor(() => + findByText('detector_name_0', undefined, { timeout: 100000 }) + ); + expect(queryByText('detector_name_15')).toBeNull(); + + // Sort by name (string sorting) + userEvent.click(getAllByTestId('tableHeaderSortButton')[0]); + await waitFor(() => + findByText('detector_name_15', undefined, { timeout: 150000 }) + ); + expect(queryByText('detector_name_9')).toBeNull(); + }, 200000); +}); diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts new file mode 100644 index 00000000..39483649 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { AssociatedDetectors } from './containers/AssociatedDetectors'; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss new file mode 100644 index 00000000..0c3fe230 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.associated-detectors { + height: 100%; + display: flex; + flex-direction: column; + + .euiFlyoutBody__overflowContent { + height: 100%; + padding-bottom: 0; + } + + &__flex-group { + height: 100%; + } + + &__associate-button { + flex: 0 0 auto; + } +} diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx new file mode 100644 index 00000000..37236349 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum ASSOCIATED_DETECTOR_ACTION { + UNLINK, +} diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx new file mode 100644 index 00000000..e01a4505 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiBasicTableColumn, EuiHealth, EuiLink } from '@elastic/eui'; +import { DETECTOR_STATE } from 'server/utils/constants'; +import { stateToColorMap } from '../../../../pages/utils/constants'; +import { PLUGIN_NAME } from '../../../../utils/constants'; +import { Detector } from '../../../../models/interfaces'; + +export const renderState = (state: DETECTOR_STATE) => { + return ( + //@ts-ignore + {state} + ); +}; + +export const getColumns = ({ handleUnlinkDetectorAction }) => + [ + { + field: 'name', + name: 'Detector', + sortable: true, + truncateText: true, + width: '30%', + align: 'left', + render: (name: string, detector: Detector) => ( + + {name} + + ), + }, + { + field: 'curState', + name: 'Real-time state', + sortable: true, + align: 'left', + width: '30%', + truncateText: true, + render: renderState, + }, + { + field: 'totalAnomalies', + name: 'Anomalies/24hr', + sortable: true, + dataType: 'number', + align: 'left', + truncateText: true, + width: '30%', + }, + { + name: 'Actions', + align: 'left', + truncateText: true, + width: '10%', + actions: [ + { + type: 'icon', + name: 'Remove association', + description: 'Remove association', + icon: 'unlink', + onClick: handleUnlinkDetectorAction, + 'data-test-subj': 'unlinkButton', + }, + ], + }, + ] as EuiBasicTableColumn[]; + +export const search = { + box: { + incremental: true, + schema: true, + }, +}; diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx new file mode 100644 index 00000000..106d0dff --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx @@ -0,0 +1,1006 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, Fragment } from 'react'; +import { + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiTitle, + EuiButton, + EuiFormFieldset, + EuiCheckableCard, + EuiSpacer, + EuiIcon, + EuiText, + EuiSwitch, + EuiFormRow, + EuiFieldText, + EuiCheckbox, + EuiFlexItem, + EuiFlexGroup, + EuiFieldNumber, + EuiCallOut, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; +import './styles.scss'; +import { + createAugmentVisSavedObject, + fetchVisEmbeddable, + ISavedAugmentVis, + ISavedPluginResource, + SavedAugmentVisLoader, + VisLayerExpressionFn, + VisLayerTypes, +} from '../../../../../../src/plugins/vis_augmenter/public'; +import { useDispatch } from 'react-redux'; +import { isEmpty, get } from 'lodash'; +import { + Field, + FieldArray, + FieldArrayRenderProps, + FieldProps, + Formik, +} from 'formik'; +import { + createDetector, + getDetectorCount, + matchDetector, + startDetector, +} from '../../../../public/redux/reducers/ad'; +import { + EmbeddableRenderer, + ErrorEmbeddable, +} from '../../../../../../src/plugins/embeddable/public'; +import './styles.scss'; +import EnhancedAccordion from '../EnhancedAccordion'; +import MinimalAccordion from '../MinimalAccordion'; +import { DataFilterList } from '../../../../public/pages/DefineDetector/components/DataFilterList/DataFilterList'; +import { + getError, + getErrorMessage, + isInvalid, + validateDetectorName, + validateNonNegativeInteger, + validatePositiveInteger, +} from '../../../../public/utils/utils'; +import { + CUSTOM_AD_RESULT_INDEX_PREFIX, + MAX_DETECTORS, +} from '../../../../server/utils/constants'; +import { + focusOnFirstWrongFeature, + initialFeatureValue, + validateFeatures, +} from '../../../../public/pages/ConfigureModel/utils/helpers'; +import { + getIndices, + getMappings, +} from '../../../../public/redux/reducers/opensearch'; +import { formikToDetector } from '../../../../public/pages/ReviewAndCreate/utils/helpers'; +import { FormattedFormRow } from '../../../../public/components/FormattedFormRow/FormattedFormRow'; +import { FeatureAccordion } from '../../../../public/pages/ConfigureModel/components/FeatureAccordion'; +import { + AD_DOCS_LINK, + AD_HIGH_CARDINALITY_LINK, + DEFAULT_SHINGLE_SIZE, + MAX_FEATURE_NUM, +} from '../../../../public/utils/constants'; +import { + getEmbeddable, + getNotifications, + getSavedFeatureAnywhereLoader, + getUISettings, + getUiActions, + getQueryService, +} from '../../../../public/services'; +import { prettifyErrorMessage } from '../../../../server/utils/helpers'; +import { + ORIGIN_PLUGIN_VIS_LAYER, + OVERLAY_ANOMALIES, + VIS_LAYER_PLUGIN_TYPE, + PLUGIN_AUGMENTATION_ENABLE_SETTING, + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING, +} from '../../../../public/expressions/constants'; +import { formikToDetectorName, visFeatureListToFormik } from './helpers'; +import { AssociateExisting } from './AssociateExisting'; +import { mountReactNode } from '../../../../../../src/core/public/utils'; +import { FLYOUT_MODES } from '../AnywhereParentFlyout/constants'; +import { DetectorListItem } from '../../../../public/models/interfaces'; +import { VisualizeEmbeddable } from '../../../../../../src/plugins/visualizations/public'; + +function AddAnomalyDetector({ + embeddable, + closeFlyout, + mode, + setMode, + selectedDetector, + setSelectedDetector, +}) { + const dispatch = useDispatch(); + const [queryText, setQueryText] = useState(''); + const [generatedEmbeddable, setGeneratedEmbeddable] = useState< + VisualizeEmbeddable | ErrorEmbeddable + >(); + + useEffect(() => { + const getInitialIndices = async () => { + await dispatch(getIndices(queryText)); + }; + getInitialIndices(); + dispatch(getMappings(embeddable.vis.data.aggs.indexPattern.title)); + + const createEmbeddable = async () => { + const visEmbeddable = await fetchVisEmbeddable( + embeddable.vis.id, + getEmbeddable(), + getQueryService() + ); + setGeneratedEmbeddable(visEmbeddable); + }; + + createEmbeddable(); + }, []); + const [isShowVis, setIsShowVis] = useState(false); + const [accordionsOpen, setAccordionsOpen] = useState({ modelFeatures: true }); + const [detectorNameFromVis, setDetectorNameFromVis] = useState( + formikToDetectorName(embeddable.vis.title) + ); + const [intervalValue, setIntervalalue] = useState(10); + const [delayValue, setDelayValue] = useState(1); + const [enabled, setEnabled] = useState(false); + const [associationLimitReached, setAssociationLimitReached] = + useState(false); + + const title = embeddable.getTitle(); + const onAccordionToggle = (key) => { + const newAccordionsOpen = { ...accordionsOpen }; + newAccordionsOpen[key] = !accordionsOpen[key]; + setAccordionsOpen(newAccordionsOpen); + }; + const onDetectorNameChange = (e, field) => { + field.onChange(e); + setDetectorNameFromVis(e.target.value); + }; + const onIntervalChange = (e, field) => { + field.onChange(e); + setIntervalalue(e.target.value); + }; + const onDelayChange = (e, field) => { + field.onChange(e); + setDelayValue(e.target.value); + }; + const aggList = embeddable.vis.data.aggs.aggs.filter( + (feature) => feature.schema == 'metric' + ); + const featureList = aggList.filter( + (feature, index) => + index < + (aggList.length < MAX_FEATURE_NUM ? aggList.length : MAX_FEATURE_NUM) + ); + + const notifications = getNotifications(); + const handleValidationAndSubmit = (formikProps) => { + if (formikProps.values.featureList.length !== 0) { + formikProps.setFieldTouched('featureList', true); + formikProps.validateForm().then(async (errors) => { + if (!isEmpty(errors)) { + focusOnFirstWrongFeature(errors, formikProps.setFieldTouched); + notifications.toasts.addDanger( + 'One or more input fields is invalid.' + ); + } else { + const isAugmentationEnabled = uiSettings.get( + PLUGIN_AUGMENTATION_ENABLE_SETTING + ); + if (!isAugmentationEnabled) { + notifications.toasts.addDanger( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } else { + const maxAssociatedCount = uiSettings.get( + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING + ); + await savedObjectLoader.findAll().then(async (resp) => { + if (resp !== undefined) { + const savedAugmentObjects = get(resp, 'hits', []); + // gets all the saved object for this visualization + const savedObjectsForThisVisualization = + savedAugmentObjects.filter( + (savedObj) => + get(savedObj, 'visId', '') === embeddable.vis.id + ); + if ( + maxAssociatedCount <= savedObjectsForThisVisualization.length + ) { + notifications.toasts.addDanger( + `Cannot create the detector and associate it to the visualization due to the limit of the max + amount of associated plugin resources (${maxAssociatedCount}) with + ${savedObjectsForThisVisualization.length} associated to the visualization` + ); + } else { + handleSubmit(formikProps); + } + } + }); + } + } + }); + } else { + notifications.toasts.addDanger('One or more features are required.'); + } + }; + + const uiSettings = getUISettings(); + const savedObjectLoader: SavedAugmentVisLoader = + getSavedFeatureAnywhereLoader(); + + let maxAssociatedCount = uiSettings.get( + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING + ); + + useEffect(async () => { + // Gets all augmented saved objects + await savedObjectLoader.findAll().then(async (resp) => { + if (resp !== undefined) { + const savedAugmentObjects = get(resp, 'hits', []); + // gets all the saved object for this visualization + const savedObjectsForThisVisualization = savedAugmentObjects.filter( + (savedObj) => get(savedObj, 'visId', '') === embeddable.vis.id + ); + if (maxAssociatedCount <= savedObjectsForThisVisualization.length) { + setAssociationLimitReached(true); + } else { + setAssociationLimitReached(false); + } + } + }); + }, []); + + const getEmbeddableSection = () => { + return ( + <> + +

+ Create and configure an anomaly detector to automatically detect + anomalies in your data and to view real-time results on the + visualization.{' '} + + Learn more + +

+
+ +
+ +

+ + {title} +

+
+ setIsShowVis(!isShowVis)} + /> +
+
+ + +
+ + ); + }; + + const getAugmentVisSavedObject = (detectorId: string) => { + const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: OVERLAY_ANOMALIES, + args: { + detectorId: detectorId, + }, + } as VisLayerExpressionFn; + + const pluginResource = { + type: VIS_LAYER_PLUGIN_TYPE, + id: detectorId, + } as ISavedPluginResource; + + return { + title: embeddable.vis.title, + originPlugin: ORIGIN_PLUGIN_VIS_LAYER, + pluginResource: pluginResource, + visId: embeddable.vis.id, + visLayerExpressionFn: fn, + } as ISavedAugmentVis; + }; + + // Error handeling/notification cases listed here as many things are being done sequentially + //1. if detector is created succesfully, started succesfully and associated succesfully and alerting exists -> show end message with alerting button + //2. If detector is created succesfully, started succesfully and associated succesfully and alerting doesn't exist -> show end message with OUT alerting button + //3. If detector is created succesfully, started succesfully and fails association -> show one toast with detector created, and one toast with failed association + //4. If detector is created succesfully, fails starting and fails association -> show one toast with detector created succesfully, one toast with failed association + //5. If detector is created successfully, fails starting and fails associating -> show one toast with detector created succesfully, one toast with fail starting, one toast with failed association + //6. If detector fails creating -> show one toast with detector failed creating + const handleSubmit = async (formikProps) => { + formikProps.setSubmitting(true); + try { + const detectorToCreate = formikToDetector(formikProps.values); + await dispatch(createDetector(detectorToCreate)) + .then(async (response) => { + dispatch(startDetector(response.response.id)) + .then((startDetectorResponse) => {}) + .catch((err: any) => { + notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem starting the real-time detector' + ) + ) + ); + }); + + const detectorId = response.response.id; + const augmentVisSavedObjectToCreate: ISavedAugmentVis = + getAugmentVisSavedObject(detectorId); + + await createAugmentVisSavedObject( + augmentVisSavedObjectToCreate, + savedObjectLoader, + uiSettings + ) + .then((savedObject: any) => { + savedObject + .save({}) + .then((response: any) => { + const shingleSize = get( + formikProps.values, + 'shingleSize', + DEFAULT_SHINGLE_SIZE + ); + const detectorId = get(savedObject, 'pluginResource.id', ''); + notifications.toasts.addSuccess({ + title: `The ${formikProps.values.name} is associated with the ${title} visualization`, + text: mountReactNode( + getEverythingSuccessfulButton(detectorId, shingleSize) + ), + className: 'createdAndAssociatedSuccessToast', + }); + closeFlyout(); + }) + .catch((error) => { + console.error( + `Error associating selected detector in save process: ${error}` + ); + notifications.toasts.addDanger( + prettifyErrorMessage( + `Error associating selected detector in save process: ${error}` + ) + ); + notifications.toasts.addSuccess( + `Detector created: ${formikProps.values.name}` + ); + }); + }) + .catch((error) => { + console.error( + `Error associating selected detector in create process: ${error}` + ); + notifications.toasts.addDanger( + prettifyErrorMessage( + `Error associating selected detector in create process: ${error}` + ) + ); + notifications.toasts.addSuccess( + `Detector created: ${formikProps.values.name}` + ); + }); + }) + .catch((err: any) => { + dispatch(getDetectorCount()).then((response: any) => { + const totalDetectors = get(response, 'response.count', 0); + if (totalDetectors === MAX_DETECTORS) { + notifications.toasts.addDanger( + 'Cannot create detector - limit of ' + + MAX_DETECTORS + + ' detectors reached' + ); + } else { + notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem creating the detector' + ) + ) + ); + } + }); + }); + closeFlyout(); + } catch (e) { + } finally { + formikProps.setSubmitting(false); + } + }; + + const getEverythingSuccessfulButton = (detectorId, shingleSize) => { + return ( + +

+ Attempting to initialize the detector with historical data. This + initializing process takes approximately 1 minute if you have data in + each of the last {32 + shingleSize} consecutive intervals. +

+ {alertingExists() ? ( + + +

Set up alerts to be notified of any anomalies.

+
+ +
+ openAlerting(detectorId)}> + Set up alerts + +
+
+
+ ) : null} +
+ ); + }; + + const alertingExists = () => { + try { + const uiActionService = getUiActions(); + uiActionService.getTrigger('ALERTING_TRIGGER_AD_ID'); + return true; + } catch (e) { + console.error('No alerting trigger exists', e); + return false; + } + }; + + const openAlerting = (detectorId: string) => { + const uiActionService = getUiActions(); + uiActionService + .getTrigger('ALERTING_TRIGGER_AD_ID') + .exec({ embeddable, detectorId }); + }; + + const handleAssociate = async (detector: DetectorListItem) => { + const augmentVisSavedObjectToCreate: ISavedAugmentVis = + getAugmentVisSavedObject(detector.id); + + createAugmentVisSavedObject( + augmentVisSavedObjectToCreate, + savedObjectLoader, + uiSettings + ) + .then((savedObject: any) => { + savedObject + .save({}) + .then((response: any) => { + notifications.toasts.addSuccess({ + title: `The ${detector.name} is associated with the ${title} visualization`, + text: "The detector's anomalies do not appear on the visualization. Refresh your dashboard to update the visualization", + }); + closeFlyout(); + }) + .catch((error) => { + notifications.toasts.addDanger(prettifyErrorMessage(error)); + }); + }) + .catch((error) => { + notifications.toasts.addDanger(prettifyErrorMessage(error)); + }); + }; + + const validateVisDetectorName = async (detectorName: string) => { + if (isEmpty(detectorName)) { + return 'Detector name cannot be empty'; + } else { + const error = validateDetectorName(detectorName); + if (error) { + return error; + } + const resp = await dispatch(matchDetector(detectorName)); + const match = get(resp, 'response.match', false); + if (!match) { + return undefined; + } + //If more than one detectors found, duplicate exists. + if (match) { + return 'Duplicate detector name'; + } + } + }; + + const initialDetectorValue = { + name: detectorNameFromVis, + index: [{ label: embeddable.vis.data.aggs.indexPattern.title }], + timeField: embeddable.vis.data.indexPattern.timeFieldName, + interval: intervalValue, + windowDelay: delayValue, + shingleSize: 8, + filterQuery: { match_all: {} }, + description: 'Created based on ' + embeddable.vis.title, + resultIndex: undefined, + filters: [], + featureList: visFeatureListToFormik( + featureList, + embeddable.vis.params.seriesParams + ), + categoryFieldEnabled: false, + realTime: true, + historical: false, + }; + + return ( +
+ + {(formikProps) => ( + <> + + +

Add anomaly detector

+
+
+ + {associationLimitReached ? ( +
+ + Adding more objects may affect cluster performance and + prevent dashboards from rendering properly. Remove + associations before adding new ones. + + {getEmbeddableSection()} +
+ ) : ( +
+ + + Options to create a new detector or associate an + existing detector + + + ), + }} + className="add-anomaly-detector__modes" + > + {[ + { + id: 'add-anomaly-detector__create', + label: 'Create new detector', + value: 'create', + }, + { + id: 'add-anomaly-detector__existing', + label: 'Associate existing detector', + value: 'existing', + }, + ].map((option) => ( + setMode(option.value), + }} + /> + ))} + + + {mode === FLYOUT_MODES.existing && ( + + )} + {mode === FLYOUT_MODES.create && ( +
+ {getEmbeddableSection()} + + +

Detector details

+
+ + + onAccordionToggle('detectorDetails')} + subTitle={ + +

+ Detector interval: {intervalValue} minute(s); + Window delay: {delayValue} minute(s) +

+
+ } + > + + {({ field, form }: FieldProps) => ( + + onDetectorNameChange(e, field)} + /> + + )} + + + + + {({ field, form }: FieldProps) => ( + + + + + + + onIntervalChange(e, field) + } + /> + + + +

minute(s)

+
+
+
+
+
+
+ )} +
+ + + + {({ field, form }: FieldProps) => ( + + + + onDelayChange(e, field)} + /> + + + +

minute(s)

+
+
+
+
+ )} +
+
+ + + + + onAccordionToggle('advancedConfiguration') + } + initialIsOpen={false} + > + + + + +

+ Source:{' '} + {embeddable.vis.data.aggs.indexPattern.title} +

+
+ + +
+ + + + + {({ field, form }: FieldProps) => ( + + + + + + + +

intervals

+
+
+
+
+ )} +
+
+ + + + {({ field, form }: FieldProps) => ( + + + { + if (enabled) { + form.setFieldValue('resultIndex', ''); + } + setEnabled(!enabled); + }} + /> + + + {enabled ? ( + + + + ) : null} + + {enabled ? ( + + + + + + ) : null} + + )} + + + + + +

+ The dashboard does not support high-cardinality + detectors.  + + Learn more + +

+
+
+
+ + + +

Model Features

+
+ + + onAccordionToggle('modelFeatures')} + > + + {({ + push, + remove, + form: { values }, + }: FieldArrayRenderProps) => { + return ( + + {values.featureList.map( + (feature: any, index: number) => ( + { + remove(index); + }} + index={index} + feature={feature} + handleChange={formikProps.handleChange} + displayMode="flyout" + /> + ) + )} + + + + = + MAX_FEATURE_NUM + } + onClick={() => { + push(initialFeatureValue()); + }} + > + Add another feature + + + + +

+ You can add up to{' '} + {Math.max( + MAX_FEATURE_NUM - + values.featureList.length, + 0 + )}{' '} + more features. +

+
+
+ ); + }} +
+
+ +
+ )} +
+ )} +
+ + + + Cancel + + + {mode === FLYOUT_MODES.existing ? ( + handleAssociate(selectedDetector)} + > + Associate detector + + ) : ( + { + handleValidationAndSubmit(formikProps); + }} + > + Create detector + + )} + + + + + )} +
+
+ ); +} + +export default AddAnomalyDetector; diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx new file mode 100644 index 00000000..cad7a718 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx @@ -0,0 +1,281 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + EuiTitle, + EuiSpacer, + EuiIcon, + EuiText, + EuiComboBox, + EuiLoadingSpinner, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiHorizontalRule, +} from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { get } from 'lodash'; +import { CoreServicesContext } from '../../../../../components/CoreServices/CoreServices'; +import { CoreStart } from '../../../../../../../../src/core/public'; +import { AppState } from '../../../../../redux/reducers'; +import { DetectorListItem } from '../../../../../models/interfaces'; +import { + GET_ALL_DETECTORS_QUERY_PARAMS, + SINGLE_DETECTOR_NOT_FOUND_MSG, +} from '../../../../../pages/utils/constants'; +import { + NO_PERMISSIONS_KEY_WORD, + prettifyErrorMessage, +} from '../../../../../../server/utils/helpers'; +import { getDetectorList } from '../../../../../redux/reducers/ad'; +import { + getSavedFeatureAnywhereLoader, + getUISettings, +} from '../../../../../services'; +import { + ISavedAugmentVis, + SavedAugmentVisLoader, + getAugmentVisSavedObjs, +} from '../../../../../../../../src/plugins/vis_augmenter/public'; +import { stateToColorMap } from '../../../../../pages/utils/constants'; +import { + BASE_DOCS_LINK, + PLUGIN_NAME, +} from '../../../../../../public/utils/constants'; +import { renderTime } from '../../../../../../public/pages/DetectorsList/utils/tableUtils'; + +interface AssociateExistingProps { + embeddableVisId: string; + selectedDetector: DetectorListItem | undefined; + setSelectedDetector(detector: DetectorListItem | undefined): void; +} + +export function AssociateExisting( + associateExistingProps: AssociateExistingProps +) { + const core = React.useContext(CoreServicesContext) as CoreStart; + const dispatch = useDispatch(); + const allDetectors = useSelector((state: AppState) => state.ad.detectorList); + const isRequestingFromES = useSelector( + (state: AppState) => state.ad.requesting + ); + const uiSettings = getUISettings(); + const [isLoadingFinalDetectors, setIsLoadingFinalDetectors] = + useState(true); + const isLoading = isRequestingFromES || isLoadingFinalDetectors; + const errorGettingDetectors = useSelector( + (state: AppState) => state.ad.errorMessage + ); + const [ + existingDetectorsAvailableToAssociate, + setExistingDetectorsAvailableToAssociate, + ] = useState([] as DetectorListItem[]); + + // Establish savedObjectLoader for all operations on vis augmented saved objects + const savedObjectLoader: SavedAugmentVisLoader = + getSavedFeatureAnywhereLoader(); + + useEffect(() => { + if ( + errorGettingDetectors && + !errorGettingDetectors.includes(SINGLE_DETECTOR_NOT_FOUND_MSG) + ) { + console.error(errorGettingDetectors); + core.notifications.toasts.addDanger( + typeof errorGettingDetectors === 'string' && + errorGettingDetectors.includes(NO_PERMISSIONS_KEY_WORD) + ? prettifyErrorMessage(errorGettingDetectors) + : 'Unable to get all detectors' + ); + setIsLoadingFinalDetectors(false); + } + }, [errorGettingDetectors]); + + // Handle all changes in the assoicated detectors such as unlinking or new detectors associated + useEffect(() => { + // Gets all augmented saved objects for the given visualization + getAugmentVisSavedObjs( + associateExistingProps.embeddableVisId, + savedObjectLoader, + uiSettings + ).then((savedAugmentObjectsArr: any) => { + if (savedAugmentObjectsArr != undefined) { + const curDetectorsToDisplayOnList = + getExistingDetectorsAvailableToAssociate( + Object.values(allDetectors), + savedAugmentObjectsArr + ); + setExistingDetectorsAvailableToAssociate(curDetectorsToDisplayOnList); + setIsLoadingFinalDetectors(false); + } + }); + }, [allDetectors]); + + // cross checks all the detectors that exist with all the savedAugment Objects to only display ones + // that are associated to the current visualization + const getExistingDetectorsAvailableToAssociate = ( + detectors: DetectorListItem[], + savedAugmentForThisVisualization: ISavedAugmentVis[] + ) => { + // Map all detector IDs for all the found augmented vis objects + const savedAugmentDetectorsSet = new Set( + savedAugmentForThisVisualization.map((savedObject) => + get(savedObject, 'pluginResource.id', '') + ) + ); + + // detectors here is all detectors + // for each detector in all detectors return that detector if that detector ID isnt in the set + // filter out any detectors that aren't on the set of detectors IDs from the augmented vis objects. + const detectorsToDisplay = detectors.filter((detector) => { + if ( + !savedAugmentDetectorsSet.has(detector.id) && + detector.detectorType === 'SINGLE_ENTITY' + ) { + return detector; + } + }); + return detectorsToDisplay; + }; + + useEffect(() => { + getDetectors(); + }, []); + + const getDetectors = async () => { + dispatch(getDetectorList(GET_ALL_DETECTORS_QUERY_PARAMS)); + }; + + const selectedOptions = useMemo(() => { + if ( + !existingDetectorsAvailableToAssociate || + !associateExistingProps.selectedDetector + ) { + return []; + } + + const detector = (existingDetectorsAvailableToAssociate || []).find( + (detector) => + detector.id === get(associateExistingProps.selectedDetector, 'id', '') + ); + return detector ? [{ label: detector.name }] : []; + }, [ + associateExistingProps.selectedDetector, + existingDetectorsAvailableToAssociate, + ]); + + const detector = associateExistingProps.selectedDetector; + + const options = useMemo(() => { + if (!existingDetectorsAvailableToAssociate) { + return []; + } + + return existingDetectorsAvailableToAssociate.map((detector) => ({ + label: detector.name, + })); + }, [existingDetectorsAvailableToAssociate]); + + return ( +
+ +

+ View existing anomaly detectors across your system and add the + detector(s) to a dashboard and visualization.{' '} + + Learn more + +

+
+ + +

Select detector to associate

+
+ + + Eligible detectors don't include high-cardinality detectors. + + {existingDetectorsAvailableToAssociate ? ( + { + let detector = undefined as DetectorListItem | undefined; + + if (selectedOptions && selectedOptions.length) { + const match = existingDetectorsAvailableToAssociate.find( + (detector) => detector.name === selectedOptions[0].label + ); + detector = match; + } + associateExistingProps.setSelectedDetector(detector); + }} + aria-label="Select an anomaly detector to associate" + isClearable + singleSelection={{ asPlainText: true }} + placeholder="Search for an anomaly detector" + /> + ) : ( + + )} + + {detector && ( + <> + + + +

{detector.name}

+
+ + + Running since {renderTime(detector.enabledTime)} + +
+ + + View detector page + + +
+ +
    + {[ + ['Indices', (detector) => detector.indices], + [ + 'Anomalies last 24 hours', + (detector) => detector.totalAnomalies, + ], + [ + 'Last real-time occurrence', + (detector) => renderTime(detector.lastActiveAnomaly), + ], + ].map(([label, getValue]) => ( +
  • + + {label}: {getValue(detector)} + +
  • + ))} +
+ + )} +
+ ); +} + +export default AssociateExisting; diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts new file mode 100644 index 00000000..90aa3ae3 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { AssociateExisting } from './containers/AssociateExisting'; diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx new file mode 100644 index 00000000..685571e9 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FEATURE_TYPE } from '../../../../public/models/interfaces'; +import { FeaturesFormikValues } from '../../../../public/pages/ConfigureModel/models/interfaces'; +import { find, snakeCase } from 'lodash'; +import { AGGREGATION_TYPES } from '../../../../public/pages/ConfigureModel/utils/constants'; + +export function visFeatureListToFormik( + featureList, + seriesParams +): FeaturesFormikValues[] { + return featureList.map((feature) => { + return { + featureId: feature.id, + featureName: getFeatureNameFromVisParams(feature.id, seriesParams), + featureEnabled: true, + featureType: FEATURE_TYPE.SIMPLE, + importance: 1, + newFeature: false, + aggregationBy: visAggregationTypeToFormik(feature), + aggregationOf: visAggregationToFormik(feature), + aggregationQuery: JSON.stringify( + visAggregationQueryToFormik(feature, seriesParams) + ), + }; + }); +} + +export function formikToDetectorName(title) { + const detectorName = + title + '_anomaly_detector_' + Math.floor(100000 + Math.random() * 900000); + const formattedName = detectorName.replace(/[^a-zA-Z0-9\-_]/g, '_'); + return formattedName; +} + +const getFeatureNameFromVisParams = (id, seriesParams) => { + const name = find(seriesParams, function (param) { + if (param.data.id === id) { + return true; + } + }); + + const formattedFeatureName = name.data.label.replace(/[^a-zA-Z0-9-_]/g, '_'); + return formattedFeatureName; +}; + +function visAggregationToFormik(value) { + if (Object.values(value.params).length !== 0) { + return [ + { + label: value.params?.field?.name, + type: value.type, + }, + ]; + } + // for count type of vis, there's no field name in the embeddable-vis schema + return []; +} + +function visAggregationQueryToFormik(value, seriesParams) { + if (Object.values(value.params).length !== 0) { + return { + [snakeCase(getFeatureNameFromVisParams(value.id, seriesParams))]: { + [visAggregationTypeToFormik(value)]: { + field: value.params?.field?.name, + }, + }, + }; + } + // for count type of vis, there's no field name in the embeddable-vis schema + // return '' as the custom expression query + return ''; +} + +function visAggregationTypeToFormik(feature) { + const aggType = feature.__type.name; + if (AGGREGATION_TYPES.some((type) => type.value === aggType)) { + return aggType; + } + if (aggType === 'count') { + return 'value_count'; + } + return 'sum'; +} diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx new file mode 100644 index 00000000..cacc501e --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import AddAnomalyDetector from './AddAnomalyDetector'; + +export default AddAnomalyDetector; diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss new file mode 100644 index 00000000..e16e3895 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.add-anomaly-detector { + height: 100%; + display: flex; + flex-direction: column; + + .euiFlyoutBody__overflowContent { + height: 100%; + padding-bottom: 0; + } + + .euiFlexItem.add-anomaly-detector__scroll { + overflow-y: auto; + } + + &__flex-group { + height: 100%; + } + + &__modes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } +} + +.create-new { + &__vis { + height: 400px; + + &--hidden { + display: none; + } + } + + &__title-and-toggle { + display: flex; + justify-content: space-between; + } + + &__title-icon { + margin-right: 10px; + vertical-align: middle; + } + + .visualization { + padding: 0; + } +} + +.featureButton { + width: 100%; + height: 100%; + min-height: 40px; +} + +.euiGlobalToastList { + width: 650px; +} + +.createdAndAssociatedSuccessToast { + width: 550px; + position: relative; + right: 15px; +} diff --git a/public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx new file mode 100644 index 00000000..3ee81e65 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +const DocumentationTitle = () => ( + + + + {i18n.translate( + 'dashboard.actions.adMenuItem.documentation.displayName', + { + defaultMessage: 'Documentation', + } + )} + + + + + + +); + +export default DocumentationTitle; diff --git a/public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx new file mode 100644 index 00000000..e9f1bd89 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import DocumentationTitle from './containers/DocumentationTitle'; + +export default DocumentationTitle; diff --git a/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx new file mode 100644 index 00000000..b129bc20 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx @@ -0,0 +1,88 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiTitle, + EuiSpacer, + EuiButtonIcon, + EuiButtonEmpty, + EuiAccordion, + EuiPanel, +} from '@elastic/eui'; +import './styles.scss'; + +const EnhancedAccordion = ({ + id, + title, + subTitle, + isOpen, + onToggle, + children, + isButton, + iconType, + extraAction, + initialIsOpen, +}) => ( +
+
+ +
+
+ {!isButton && ( + {extraAction}
+ } + forceState={isOpen ? 'open' : 'closed'} + onToggle={onToggle} + initialIsOpen={initialIsOpen} + buttonContent={ +
+ +

{title}

+
+ + {subTitle && ( + <> + + {subTitle} + + )} +
+ } + > + + {children} + + + )} + {isButton && ( + + )} +
+
+); + +export default EnhancedAccordion; diff --git a/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx new file mode 100644 index 00000000..0b994f5f --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import EnhancedAccordion from './EnhancedAccordion'; + +export default EnhancedAccordion; diff --git a/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss new file mode 100644 index 00000000..4615733d --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.enhanced-accordion { + &__arrow { + transition: rotate 0.3s; + rotate: 0deg; + + &--open { + rotate: 90deg; + } + + &--hidden { + visibility: hidden; + } + } + + &__title { + padding: 12px 16px; + } + + &__extra { + padding-right: 16px; + } + + &__button { + width: 100%; + height: 100%; + min-height: 50px; + } +} diff --git a/public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx new file mode 100644 index 00000000..ec290cd2 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiHorizontalRule, + EuiTitle, + EuiAccordion, + EuiSpacer, + EuiPanel, + EuiTextColor, + EuiText, +} from '@elastic/eui'; +import './styles.scss'; + +function MinimalAccordion({ + id, + title, + subTitle, + children, + isUsingDivider, + extraAction, +}) { + return ( +
+ {isUsingDivider && ( + <> + + + + )} + + +
{title}
+
+ {subTitle && ( + + {subTitle} + + )} + + } + extraAction={ +
{extraAction}
+ } + > + + {children} + +
+
+ ); +} + +export default MinimalAccordion; diff --git a/public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx new file mode 100644 index 00000000..7f222f69 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import MinimalAccordion from './MinimalAccordion'; + +export default MinimalAccordion; diff --git a/public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss new file mode 100644 index 00000000..3b64d5ee --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.minimal-accordion { + .euiAccordion__button { + align-items: flex-start; + + &:hover, + &:focus { + text-decoration: none; + + .minimal-accordion__title { + text-decoration: underline; + } + } + } + + &__title { + margin-top: -5px; + font-weight: 400; + } + + &__panel { + padding-left: 28px; + padding-bottom: 0; + } +} diff --git a/public/expressions/__tests__/overlay_anomalies.test.ts b/public/expressions/__tests__/overlay_anomalies.test.ts new file mode 100644 index 00000000..c503c601 --- /dev/null +++ b/public/expressions/__tests__/overlay_anomalies.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { setClient } from '../../services'; +import { httpClientMock } from '../../../test/mocks'; +import { + convertAnomaliesToPointInTimeEventsVisLayer, + getAnomalies, + getVisLayerError, + getDetectorResponse, +} from '../helpers'; +import { + ANOMALY_RESULT_SUMMARY, + ANOMALY_RESULT_SUMMARY_DETECTOR_ID, + NO_ANOMALIES_RESULT_RESPONSE, + PARSED_ANOMALIES, + SELECTED_DETECTORS, +} from '../../pages/utils/__tests__/constants'; +import { + DETECTOR_HAS_BEEN_DELETED, + PLUGIN_EVENT_TYPE, + START_OR_END_TIME_INVALID_ERROR, + VIS_LAYER_PLUGIN_TYPE, +} from '../constants'; +import { PLUGIN_NAME } from '../../utils/constants'; +import { VisLayerErrorTypes } from '../../../../../src/plugins/vis_augmenter/public'; +import { DOES_NOT_HAVE_PERMISSIONS_KEY_WORD } from '../../../server/utils/helpers'; + +describe('overlay_anomalies spec', () => { + setClient(httpClientMock); + + const ADPluginResource = { + type: VIS_LAYER_PLUGIN_TYPE, + id: ANOMALY_RESULT_SUMMARY_DETECTOR_ID, + name: 'test-1', + urlPath: `${PLUGIN_NAME}#/detectors/${ANOMALY_RESULT_SUMMARY_DETECTOR_ID}/results`, //details page for detector in AD plugin + }; + + describe('getAnomalies()', () => { + test('One anomaly', async () => { + httpClientMock.post = jest.fn().mockResolvedValue(ANOMALY_RESULT_SUMMARY); + const receivedAnomalies = await getAnomalies( + ANOMALY_RESULT_SUMMARY_DETECTOR_ID, + 1589258564789, + 1589258684789, + '' + ); + expect(receivedAnomalies).toStrictEqual(PARSED_ANOMALIES); + }); + test('No Anomalies', async () => { + httpClientMock.post = jest + .fn() + .mockResolvedValue(NO_ANOMALIES_RESULT_RESPONSE); + const receivedAnomalies = await getAnomalies( + ANOMALY_RESULT_SUMMARY_DETECTOR_ID, + 1589258564789, + 1589258684789, + '' + ); + expect(receivedAnomalies).toStrictEqual([]); + }); + test('Failed response', async () => { + httpClientMock.post = jest.fn().mockResolvedValue({ ok: false }); + const receivedAnomalies = await getAnomalies( + ANOMALY_RESULT_SUMMARY_DETECTOR_ID, + 1589258564789, + 1589258684789, + '' + ); + expect(receivedAnomalies).toStrictEqual([]); + }); + }); + describe('getDetectorResponse()', () => { + test('get detector', async () => { + httpClientMock.get = jest + .fn() + .mockResolvedValue({ ok: true, response: SELECTED_DETECTORS[0] }); + const receivedAnomalies = await getDetectorResponse( + 'gtU2l4ABuV34PY9ITTdm' + ); + expect(receivedAnomalies).toStrictEqual({ + ok: true, + response: SELECTED_DETECTORS[0], + }); + }); + }); + describe('convertAnomaliesToPointInTimeEventsVisLayer()', () => { + test('convert anomalies to PointInTimeEventsVisLayer', async () => { + const expectedTimeStamp = + PARSED_ANOMALIES[0].startTime + + (PARSED_ANOMALIES[0].endTime - PARSED_ANOMALIES[0].startTime) / 2; + const expectedPointInTimeEventsVisLayer = { + events: [ + { + metadata: {}, + timestamp: expectedTimeStamp, + }, + ], + originPlugin: 'anomalyDetectionDashboards', + pluginResource: { + id: ANOMALY_RESULT_SUMMARY_DETECTOR_ID, + name: 'test-1', + type: 'Anomaly Detectors', + urlPath: `anomaly-detection-dashboards#/detectors/${ANOMALY_RESULT_SUMMARY_DETECTOR_ID}/results`, + }, + pluginEventType: PLUGIN_EVENT_TYPE, + type: 'PointInTimeEvents', + }; + const pointInTimeEventsVisLayer = + await convertAnomaliesToPointInTimeEventsVisLayer( + PARSED_ANOMALIES, + ADPluginResource + ); + expect(pointInTimeEventsVisLayer).toStrictEqual( + expectedPointInTimeEventsVisLayer + ); + }); + }); + describe('getErrorLayerVisLayer()', () => { + test('get resource deleted ErrorVisLayer', async () => { + const error = new Error( + 'Anomaly Detector - ' + DETECTOR_HAS_BEEN_DELETED + ); + const expectedVisLayerError = { + type: VisLayerErrorTypes.RESOURCE_DELETED, + message: error.message, + }; + const visLayerError = await getVisLayerError(error); + expect(visLayerError).toStrictEqual(expectedVisLayerError); + }); + test('get no permission ErrorVisLayer', async () => { + const error = new Error( + 'Anomaly Detector - ' + DOES_NOT_HAVE_PERMISSIONS_KEY_WORD + ); + const expectedVisLayerError = { + type: VisLayerErrorTypes.PERMISSIONS_FAILURE, + message: error.message, + }; + const visLayerError = await getVisLayerError(error); + expect(visLayerError).toStrictEqual(expectedVisLayerError); + }); + test('get fetch issue ErrorVisLayer', async () => { + const error = new Error(START_OR_END_TIME_INVALID_ERROR); + const expectedVisLayerError = { + type: VisLayerErrorTypes.FETCH_FAILURE, + message: error.message, + }; + const visLayerError = await getVisLayerError(error); + expect(visLayerError).toStrictEqual(expectedVisLayerError); + }); + }); +}); diff --git a/public/expressions/constants.ts b/public/expressions/constants.ts new file mode 100644 index 00000000..71d696bc --- /dev/null +++ b/public/expressions/constants.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ORIGIN_PLUGIN_VIS_LAYER = 'anomalyDetectionDashboards'; + +// Defines the header used when categorizing and grouping the VisLayers on the view event flyout in OSD. +export const VIS_LAYER_PLUGIN_TYPE = 'Anomaly Detectors'; + +export const TYPE_OF_EXPR_VIS_LAYERS = 'vis_layers'; + +export const OVERLAY_ANOMALIES = 'overlay_anomalies'; + +export const PLUGIN_EVENT_TYPE = 'Anomalies'; + +export const PLUGIN_AUGMENTATION_ENABLE_SETTING = + 'visualization:enablePluginAugmentation'; + +export const PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING = + 'visualization:enablePluginAugmentation.maxPluginObjects'; + +export const DETECTOR_HAS_BEEN_DELETED = 'detector has been deleted'; + +export const START_OR_END_TIME_INVALID_ERROR = 'start or end time invalid'; diff --git a/public/expressions/helpers.ts b/public/expressions/helpers.ts new file mode 100644 index 00000000..298e14ba --- /dev/null +++ b/public/expressions/helpers.ts @@ -0,0 +1,139 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getAnomalySummaryQuery, + parsePureAnomalies, +} from '../pages/utils/anomalyResultUtils'; +import { AD_NODE_API } from '../../utils/constants'; +import { AnomalyData } from '../models/interfaces'; +import { getClient } from '../services'; +import { + PluginResource, + PointInTimeEventsVisLayer, + VisLayerError, + VisLayerErrorTypes, + VisLayerTypes, +} from '../../../../src/plugins/vis_augmenter/public'; +import { + DETECTOR_HAS_BEEN_DELETED, + ORIGIN_PLUGIN_VIS_LAYER, + PLUGIN_EVENT_TYPE, +} from './constants'; +import { + DOES_NOT_HAVE_PERMISSIONS_KEY_WORD, + NO_PERMISSIONS_KEY_WORD, +} from '../../server/utils/helpers'; +import { get } from 'lodash'; + +// This gets all the needed anomalies for the given detector ID and time range +export const getAnomalies = async ( + detectorId: string, + startTime: number, + endTime: number, + resultIndex: string +): Promise => { + const anomalySummaryQuery = getAnomalySummaryQuery( + startTime, + endTime, + detectorId, + undefined, + false + ); + let anomalySummaryResponse; + if (resultIndex === '') { + anomalySummaryResponse = await getClient().post( + `..${AD_NODE_API.DETECTOR}/results/_search`, + { + body: JSON.stringify(anomalySummaryQuery), + } + ); + } else { + anomalySummaryResponse = await getClient().post( + `..${AD_NODE_API.DETECTOR}/results/_search/${resultIndex}/true`, + { + body: JSON.stringify(anomalySummaryQuery), + } + ); + } + + return parsePureAnomalies(anomalySummaryResponse); +}; + +export const getDetectorResponse = async (detectorId: string) => { + const resp = await getClient().get(`..${AD_NODE_API.DETECTOR}/${detectorId}`); + return resp; +}; + +// This takes anomalies and returns them as vis layer of type PointInTimeEvents +export const convertAnomaliesToPointInTimeEventsVisLayer = ( + anomalies: AnomalyData[], + ADPluginResource: PluginResource +): PointInTimeEventsVisLayer => { + const events = anomalies.map((anomaly: AnomalyData) => { + return { + timestamp: anomaly.startTime, + metadata: {}, + }; + }); + return { + originPlugin: ORIGIN_PLUGIN_VIS_LAYER, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: ADPluginResource, + events: events, + pluginEventType: PLUGIN_EVENT_TYPE, + } as PointInTimeEventsVisLayer; +}; + +const checkIfPermissionErrors = (error): boolean => { + return typeof error === 'string' + ? error.includes(NO_PERMISSIONS_KEY_WORD) || + error.includes(DOES_NOT_HAVE_PERMISSIONS_KEY_WORD) + : get(error, 'message', '').includes(NO_PERMISSIONS_KEY_WORD) || + get(error, 'message', '').includes(DOES_NOT_HAVE_PERMISSIONS_KEY_WORD); +}; + +const checkIfDeletionErrors = (error): boolean => { + return typeof error === 'string' + ? error.includes(DETECTOR_HAS_BEEN_DELETED) + : get(error, 'message', '').includes(DETECTOR_HAS_BEEN_DELETED); +}; + +//Helps convert any possible errors into either permission, deletion or fetch related failures +export const getVisLayerError = (error): VisLayerError => { + let visLayerError: VisLayerError = {} as VisLayerError; + if (checkIfPermissionErrors(error)) { + visLayerError = { + type: VisLayerErrorTypes.PERMISSIONS_FAILURE, + message: + error === 'string' + ? error + : error instanceof Error + ? get(error, 'message', '') + : '', + }; + } else if (checkIfDeletionErrors(error)) { + visLayerError = { + type: VisLayerErrorTypes.RESOURCE_DELETED, + message: + error === 'string' + ? error + : error instanceof Error + ? get(error, 'message', '') + : '', + }; + } else { + visLayerError = { + type: VisLayerErrorTypes.FETCH_FAILURE, + message: + error === 'string' + ? error + : error instanceof Error + ? get(error, 'message', '') + : '', + }; + } + return visLayerError; +}; diff --git a/public/expressions/overlay_anomalies.ts b/public/expressions/overlay_anomalies.ts new file mode 100644 index 00000000..0df420b2 --- /dev/null +++ b/public/expressions/overlay_anomalies.ts @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { ExpressionFunctionDefinition } from '../../../../src/plugins/expressions/public'; +import { + VisLayerTypes, + VisLayers, + ExprVisLayers, +} from '../../../../src/plugins/vis_augmenter/public'; +import { + TimeRange, + calculateBounds, +} from '../../../../src/plugins/data/common'; +import { PointInTimeEventsVisLayer } from '../../../../src/plugins/vis_augmenter/public'; +import { PLUGIN_NAME } from '../utils/constants'; +import { + CANT_FIND_KEY_WORD, + DOES_NOT_HAVE_PERMISSIONS_KEY_WORD, +} from '../../server/utils/helpers'; +import { + DETECTOR_HAS_BEEN_DELETED, + OVERLAY_ANOMALIES, + PLUGIN_EVENT_TYPE, + START_OR_END_TIME_INVALID_ERROR, + TYPE_OF_EXPR_VIS_LAYERS, + VIS_LAYER_PLUGIN_TYPE, +} from './constants'; +import { + convertAnomaliesToPointInTimeEventsVisLayer, + getAnomalies, + getDetectorResponse, + getVisLayerError, +} from './helpers'; + +type Input = ExprVisLayers; +type Output = Promise; +type Name = typeof OVERLAY_ANOMALIES; + +interface Arguments { + detectorId: string; +} + +export type OverlayAnomaliesExpressionFunctionDefinition = + ExpressionFunctionDefinition; + +/* + * This function defines the Anomaly Detection expression function of type vis_layers. + * The expression-fn defined takes an argument of detectorId and an array of VisLayers as input, + * it then returns back the VisLayers array with an additional vislayer composed of anomalies. + * + * The purpose of this function is to allow us on the visualization rendering to gather additional + * overlays from an associated plugin resource such as an anomaly detector in this occasion. The VisLayers will + * now have anomaly data as one of its VisLayers. + * + * To create the new added VisLayer the function uses the detectorId and daterange from the search context + * to fetch anomalies. Next, the anomalies are mapped into events based on timestamps in order to convert them to a + * PointInTimeEventsVisLayer. + * + * If there are any errors fetching the anomalies the function will return a VisLayerError in the + * VisLayer detailing the error type. + */ + +export const overlayAnomaliesFunction = + (): OverlayAnomaliesExpressionFunctionDefinition => ({ + name: OVERLAY_ANOMALIES, + type: TYPE_OF_EXPR_VIS_LAYERS, + inputTypes: [TYPE_OF_EXPR_VIS_LAYERS], + help: i18n.translate('data.functions.overlay_anomalies.help', { + defaultMessage: 'Add an anomaly vis layer', + }), + args: { + detectorId: { + types: ['string'], + default: '""', + help: '', + }, + }, + + async fn(input, args, context): Promise { + // Parsing all of the args & input + const detectorId = get(args, 'detectorId', ''); + const timeRange = get( + context, + 'searchContext.timeRange', + '' + ) as TimeRange; + const origVisLayers = get(input, 'layers', [] as VisLayers) as VisLayers; + const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null; + const startTimeInMillis = parsedTimeRange?.min?.unix() + ? parsedTimeRange?.min?.unix() * 1000 + : undefined; + const endTimeInMillis = parsedTimeRange?.max?.unix() + ? parsedTimeRange?.max?.unix() * 1000 + : undefined; + var ADPluginResource = { + type: VIS_LAYER_PLUGIN_TYPE, + id: detectorId, + name: '', + urlPath: `${PLUGIN_NAME}#/detectors/${detectorId}/results`, //details page for detector in AD plugin + }; + try { + const detectorResponse = await getDetectorResponse(detectorId); + if (get(detectorResponse, 'error', '').includes(CANT_FIND_KEY_WORD)) { + throw new Error('Anomaly Detector - ' + DETECTOR_HAS_BEEN_DELETED); + } else if ( + get(detectorResponse, 'error', '').includes( + DOES_NOT_HAVE_PERMISSIONS_KEY_WORD + ) + ) { + throw new Error(get(detectorResponse, 'error', '')); + } + const detectorName = get(detectorResponse.response, 'name', ''); + const resultIndex = get(detectorResponse.response, 'resultIndex', ''); + if (detectorName === '') { + throw new Error('Anomaly Detector - Unable to get detector'); + } + ADPluginResource.name = detectorName; + + if (startTimeInMillis === undefined || endTimeInMillis === undefined) { + throw new RangeError(START_OR_END_TIME_INVALID_ERROR); + } + const anomalies = await getAnomalies( + detectorId, + startTimeInMillis, + endTimeInMillis, + resultIndex + ); + const anomalyLayer = convertAnomaliesToPointInTimeEventsVisLayer( + anomalies, + ADPluginResource + ); + return { + type: TYPE_OF_EXPR_VIS_LAYERS, + layers: origVisLayers + ? origVisLayers.concat(anomalyLayer) + : ([anomalyLayer] as VisLayers), + }; + } catch (error) { + console.error('Anomaly Detector - Unable to get anomalies: ', error); + const visLayerError = getVisLayerError(error); + const anomalyErrorLayer = { + type: VisLayerTypes.PointInTimeEvents, + originPlugin: PLUGIN_NAME, + pluginResource: ADPluginResource, + events: [], + error: visLayerError, + pluginEventType: PLUGIN_EVENT_TYPE, + } as PointInTimeEventsVisLayer; + return { + type: TYPE_OF_EXPR_VIS_LAYERS, + layers: origVisLayers + ? origVisLayers.concat(anomalyErrorLayer) + : ([anomalyErrorLayer] as VisLayers), + }; + } + }, + }); diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index f8fbc248..eff5ead5 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -217,6 +217,7 @@ export type DetectorListItem = { lastActiveAnomaly: number; lastUpdateTime: number; enabledTime?: number; + detectorType?: string; }; export type EntityData = { diff --git a/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx b/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx index 17efc289..a819ed8f 100644 --- a/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx +++ b/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx @@ -20,7 +20,9 @@ import { EuiButton, EuiFieldText, EuiCheckbox, + EuiButtonIcon, } from '@elastic/eui'; +import './styles.scss'; import { Field, FieldProps } from 'formik'; import { required, @@ -40,6 +42,7 @@ interface FeatureAccordionProps { index: number; feature: any; handleChange(event: React.ChangeEvent): void; + displayMode?: string; } export const FeatureAccordion = (props: FeatureAccordionProps) => { @@ -78,6 +81,18 @@ export const FeatureAccordion = (props: FeatureAccordionProps) => { }; const featureButtonContent = (feature: any, index: number) => { + if (props.displayMode === 'flyout') { + return ( +
+ +
+ {feature.featureName ? feature.featureName : 'Add feature'} +
+
+ {showSubtitle ? showFeatureDescription(feature) : null} +
+ ); + } return (
@@ -94,11 +109,25 @@ export const FeatureAccordion = (props: FeatureAccordionProps) => { ); }; - const deleteAction = (onClick: any) => ( - - Delete - - ); + const deleteAction = (onClick: any) => { + if (props.displayMode === 'flyout') { + return ( + + ); + } else { + return ( + + Delete + + ); + } + }; return ( { buttonClassName={ props.index === 0 ? 'euiAccordionForm__noTopPaddingButton' - : 'euiAccordionForm__button' + : 'euiFormAccordion_button' } className="euiAccordion__noTopBorder" paddingSize="l" diff --git a/public/pages/ConfigureModel/components/FeatureAccordion/styles.scss b/public/pages/ConfigureModel/components/FeatureAccordion/styles.scss new file mode 100644 index 00000000..5d819b8a --- /dev/null +++ b/public/pages/ConfigureModel/components/FeatureAccordion/styles.scss @@ -0,0 +1,3 @@ +.euiFormAccordion_button { + padding: 20px 16px 0 0; +} diff --git a/public/pages/Dashboard/Components/__tests__/AnomaliesLiveCharts.test.tsx b/public/pages/Dashboard/Components/__tests__/AnomaliesLiveCharts.test.tsx index 5c497688..23f65912 100644 --- a/public/pages/Dashboard/Components/__tests__/AnomaliesLiveCharts.test.tsx +++ b/public/pages/Dashboard/Components/__tests__/AnomaliesLiveCharts.test.tsx @@ -6,7 +6,7 @@ import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { AnomaliesLiveChart } from '../AnomaliesLiveChart'; -import { selectedDetectors } from '../../../../pages/utils/__tests__/constants'; +import { SELECTED_DETECTORS } from '../../../../pages/utils/__tests__/constants'; import { Provider } from 'react-redux'; import { coreServicesMock } from '../../../../../test/mocks'; import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices'; @@ -45,7 +45,7 @@ describe(' spec', () => { const { container, getByTestId, getAllByText, getByText } = render( - + ); diff --git a/public/pages/Dashboard/utils/__tests__/utils.test.tsx b/public/pages/Dashboard/utils/__tests__/utils.test.tsx index 7694dea8..0f805217 100644 --- a/public/pages/Dashboard/utils/__tests__/utils.test.tsx +++ b/public/pages/Dashboard/utils/__tests__/utils.test.tsx @@ -8,16 +8,10 @@ import { getLatestAnomalyResultsByTimeRange, getLatestAnomalyResultsForDetectorsByTimeRange, } from '../utils'; -import { httpClientMock, coreServicesMock } from '../../../../../test/mocks'; import { - Detector, - FeatureAttributes, - DetectorListItem, -} from '../../../../models/interfaces'; -import { - selectedDetectors, - anomalyResultQuery, - anomalyResultQueryPerDetector, + SELECTED_DETECTORS, + ANOMALY_RESULT_QUERY, + ANOMALY_RESULT_QUERY_PER_DETECTOR, } from '../../../../pages/utils/__tests__/constants'; const anomalyResult = { detector_id: 'gtU2l4ABuV34PY9ITTdm', @@ -114,14 +108,14 @@ describe('get latest anomaly result by time range', () => { 'opensearch-ad-plugin-result-*', false ); - expect(response[0]).toStrictEqual(anomalyResultQuery); + expect(response[0]).toStrictEqual(ANOMALY_RESULT_QUERY); }, 10000); }); describe('get latest anomaly result for detectors', () => { test('get latest by detectors and time range ', async () => { const response = await getLatestAnomalyResultsForDetectorsByTimeRange( jest.fn(), - selectedDetectors, + SELECTED_DETECTORS, '30m', jest.fn().mockResolvedValue(searchResponseGetLatestAnomalyResults), -1, @@ -131,6 +125,6 @@ describe('get latest anomaly result for detectors', () => { 'opensearch-ad-plugin-result-*', false ); - expect(response[0]).toStrictEqual(anomalyResultQueryPerDetector); + expect(response[0]).toStrictEqual(ANOMALY_RESULT_QUERY_PER_DETECTOR); }, 10000); }); diff --git a/public/pages/DetectorConfig/containers/Features.tsx b/public/pages/DetectorConfig/containers/Features.tsx index d5b8e455..9cdb609d 100644 --- a/public/pages/DetectorConfig/containers/Features.tsx +++ b/public/pages/DetectorConfig/containers/Features.tsx @@ -234,6 +234,7 @@ export const Features = (props: FeaturesProps) => { titleSize="s" > -{ - constructor(private readonly initializerContext: PluginInitializerContext) { - // can retrieve config from initializerContext +declare module '../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_AD]: {}; } +} + +//TODO: there is currently no savedAugmentVisLoader in VisAugmentSetup interface, this needs to be fixed +export interface AnomalyDetectionSetupDeps { + embeddable: EmbeddableSetup; + notifications: NotificationsSetup; + visAugmenter: VisAugmenterSetup; + //uiActions: UiActionsSetup; +} + +export interface AnomalyDetectionStartDeps { + embeddable: EmbeddableStart; + notifications: NotificationsStart; + visAugmenter: VisAugmenterStart; + uiActions: UiActionsStart; + data: DataPublicPluginStart; +} - public setup( - core: CoreSetup - ): AnomalyDetectionOpenSearchDashboardsPluginSetup { +export class AnomalyDetectionOpenSearchDashboardsPlugin + implements Plugin +{ + public setup(core: CoreSetup, plugins: any) { core.application.register({ - id: 'anomaly-detection-dashboards', + id: PLUGIN_NAME, title: 'Anomaly Detection', category: { id: 'opensearch', @@ -46,16 +81,41 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin order: 5000, mount: async (params: AppMountParameters) => { const { renderApp } = await import('./anomaly_detection_app'); - const [coreStart, depsStart] = await core.getStartServices(); + const [coreStart] = await core.getStartServices(); return renderApp(coreStart, params); }, }); + + setUISettings(core.uiSettings); + + // Set the HTTP client so it can be pulled into expression fns to make + // direct server-side calls + setClient(core.http); + + // Create context menu actions + const actions = getActions(); + + // Add actions to uiActions + actions.forEach((action) => { + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); + }); + + // registers the expression function used to render anomalies on an Augmented Visualization + plugins.expressions.registerFunction(overlayAnomaliesFunction); return {}; } public start( - core: CoreStart + core: CoreStart, + { embeddable, visAugmenter, uiActions, data }: AnomalyDetectionStartDeps ): AnomalyDetectionOpenSearchDashboardsPluginStart { + setUISettings(core.uiSettings); + setEmbeddable(embeddable); + setOverlays(core.overlays); + setSavedFeatureAnywhereLoader(visAugmenter.savedAugmentVisLoader); + setNotifications(core.notifications); + setUiActions(uiActions); + setQueryService(data.query); return {}; } } diff --git a/public/services.ts b/public/services.ts new file mode 100644 index 00000000..ef899307 --- /dev/null +++ b/public/services.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CoreStart, + IUiSettingsClient, + NotificationsStart, + OverlayStart, +} from '../../../src/core/public'; +import { DataPublicPluginStart } from '../../../src/plugins/data/public'; +import { EmbeddableStart } from '../../../src/plugins/embeddable/public'; +import { createGetterSetter } from '../../../src/plugins/opensearch_dashboards_utils/public'; +import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; +import { SavedAugmentVisLoader } from '../../../src/plugins/vis_augmenter/public'; + +export const [getSavedFeatureAnywhereLoader, setSavedFeatureAnywhereLoader] = + createGetterSetter('savedFeatureAnywhereLoader'); + +export const [getClient, setClient] = + createGetterSetter('http'); + +export const [getEmbeddable, setEmbeddable] = + createGetterSetter('Embeddable'); + +export const [getOverlays, setOverlays] = + createGetterSetter('Overlays'); + +export const [getNotifications, setNotifications] = + createGetterSetter('Notifications'); + +export const [getUiActions, setUiActions] = + createGetterSetter('UIActions'); + +export const [getUISettings, setUISettings] = + createGetterSetter('UISettings'); + +export const [getQueryService, setQueryService] = + createGetterSetter('Query'); + +// This is primarily used for mocking this module and each of its fns in tests. +export default { + getSavedFeatureAnywhereLoader, + getUISettings, + getUiActions, + getEmbeddable, + getNotifications, + getOverlays, + setUISettings, + setQueryService, +}; diff --git a/public/utils/constants.ts b/public/utils/constants.ts index 23354742..6f244704 100644 --- a/public/utils/constants.ts +++ b/public/utils/constants.ts @@ -53,6 +53,15 @@ export const ANOMALY_RESULT_INDEX = '.opendistro-anomaly-results'; export const BASE_DOCS_LINK = 'https://opensearch.org/docs/monitoring-plugins'; +export const AD_DOCS_LINK = + 'https://opensearch.org/docs/latest/observing-your-data/ad/index/'; + +export const AD_HIGH_CARDINALITY_LINK = + 'https://opensearch.org/docs/latest/observing-your-data/ad/index/#optional-set-category-fields-for-high-cardinality'; + +export const AD_FEATURE_ANYWHERE_LINK = + 'https://opensearch.org/docs/latest/observing-your-data/ad/dashboards-anomaly-detection/'; + export const MAX_DETECTORS = 1000; export const MAX_ANOMALIES = 10000; @@ -87,3 +96,5 @@ export enum MISSING_FEATURE_DATA_SEVERITY { } export const SPACE_STR = ' '; + +export const ANOMALY_DETECTION_ICON = 'anomalyDetection'; diff --git a/public/utils/contextMenu/getActions.tsx b/public/utils/contextMenu/getActions.tsx new file mode 100644 index 00000000..f58a7a9e --- /dev/null +++ b/public/utils/contextMenu/getActions.tsx @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiIconType } from '@elastic/eui'; +import { toMountPoint } from '../../../../../src/plugins/opensearch_dashboards_react/public'; +import { Action } from '../../../../../src/plugins/ui_actions/public'; +import { createADAction } from '../../action/ad_dashboard_action'; +import AnywhereParentFlyout from '../../components/FeatureAnywhereContextMenu/AnywhereParentFlyout'; +import { Provider } from 'react-redux'; +import configureStore from '../../redux/configureStore'; +import DocumentationTitle from '../../components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle'; +import { AD_FEATURE_ANYWHERE_LINK, ANOMALY_DETECTION_ICON } from '../constants'; +import { getClient, getOverlays } from '../../../public/services'; +import { FLYOUT_MODES } from '../../../public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants'; + +// This is used to create all actions in the same context menu +const grouping: Action['grouping'] = [ + { + id: 'ad-dashboard-context-menu', + getDisplayName: () => 'Anomaly Detection', + getIconType: () => ANOMALY_DETECTION_ICON, + category: 'vis_augmenter', + order: 20, + }, +]; + +export const getActions = () => { + const getOnClick = + (startingFlyout) => + async ({ embeddable }) => { + const overlayService = getOverlays(); + const openFlyout = overlayService.openFlyout; + const store = configureStore(getClient()); + const overlay = openFlyout( + toMountPoint( + + overlay.close()} + /> + + ), + { size: 'm', className: 'context-menu__flyout' } + ); + }; + + return [ + { + grouping, + id: 'createAnomalyDetector', + title: i18n.translate( + 'dashboard.actions.adMenuItem.createAnomalyDetector.displayName', + { + defaultMessage: 'Add anomaly detector', + } + ), + icon: 'plusInCircle' as EuiIconType, + order: 100, + onClick: getOnClick(FLYOUT_MODES.create), + }, + { + grouping, + id: 'associatedAnomalyDetector', + title: i18n.translate( + 'dashboard.actions.adMenuItem.associatedAnomalyDetector.displayName', + { + defaultMessage: 'Associated detectors', + } + ), + icon: 'kqlSelector' as EuiIconType, + order: 99, + onClick: getOnClick(FLYOUT_MODES.associated), + }, + { + id: 'documentationAnomalyDetector', + title: , + icon: 'documentation' as EuiIconType, + order: 98, + onClick: () => { + window.open(AD_FEATURE_ANYWHERE_LINK, '_blank'); + }, + }, + ].map((options) => createADAction({ ...options, grouping })); +}; diff --git a/server/utils/helpers.ts b/server/utils/helpers.ts index 035d2c74..15c80b3e 100644 --- a/server/utils/helpers.ts +++ b/server/utils/helpers.ts @@ -66,6 +66,10 @@ const PERMISSIONS_ERROR_PATTERN = export const NO_PERMISSIONS_KEY_WORD = 'no permissions'; +export const DOES_NOT_HAVE_PERMISSIONS_KEY_WORD = 'does not have permissions'; + +export const CANT_FIND_KEY_WORD = "Can't find"; + export const prettifyErrorMessage = (rawErrorMessage: string) => { if (isEmpty(rawErrorMessage) || rawErrorMessage === 'undefined') { return 'Unknown error is returned.'; diff --git a/test/mocks/transformMock.ts b/test/mocks/transformMock.ts new file mode 100644 index 00000000..ac888d71 --- /dev/null +++ b/test/mocks/transformMock.ts @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The transform configuration in Jest allows you to + * specify custom transformation logic for specific file types during testing. + */ +module.exports = { + /** + * This function is responsible for transforming the file. + * @returns the string module.exports = {};, which is an empty CommonJS module. + */ + process() { + return { + code: `module.exports = {};`, + }; + }, + /** + * The cache key helps Jest determine if a file needs to be retransformed or if it can use the cached transformation result. + * @returns a unique string that serves as a cache key for the transformation. + */ + getCacheKey() { + return 'svgTransform'; + }, +}; \ No newline at end of file