diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index f13818e5..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'] - -settings: - import/resolver: - '@elastic/eslint-import-resolver-kibana': - rootPackageName: 'anomaly-detection-dashboards' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cd025f06..cd739aac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ -# This should match the owning team set up in https://github.com/orgs/opensearch-project/teams -* @opensearch-project/anomaly-detection \ No newline at end of file +* @ohltyler @kaituo @jackiehanyang @amitgalitz @sean-zheng-amazon @dbwiddis @owaiskazi19 @joshpalis @sudiptoguha \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 29eddb95..fbe1239e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -11,6 +11,7 @@ A clear and concise description of the bug. **How can one reproduce the bug?** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -20,9 +21,10 @@ Steps to reproduce the behavior: A clear and concise description of what you expected to happen. **What is your host/environment?** - - OS: [e.g. iOS] - - Version [e.g. 22] - - Plugins + +- OS: [e.g. iOS] +- Version [e.g. 22] +- Plugins **Do you have any screenshots?** If applicable, add screenshots to help explain your problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6198f338..1bbccb57 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,6 +5,7 @@ title: '[FEATURE]' labels: 'enhancement, untriaged' assignees: '' --- + **Is your feature request related to a problem?** A clear and concise description of what the problem is, e.g. _I'm always frustrated when [...]_ @@ -15,4 +16,4 @@ A clear and concise description of what you want to happen. A clear and concise description of any alternative solutions or features you've considered. **Do you have any additional context?** -Add any other context or screenshots about the feature request here. \ No newline at end of file +Add any other context or screenshots about the feature request here. diff --git a/.github/configurations/docker-compose.yml b/.github/configurations/docker-compose.yml index a538ffcd..a971af14 100644 --- a/.github/configurations/docker-compose.yml +++ b/.github/configurations/docker-compose.yml @@ -7,7 +7,7 @@ services: - cluster.name=opensearch-cluster - node.name=opensearch-node1 - discovery.seed_hosts=opensearch-node1 - - cluster.initial_master_nodes=opensearch-node1 + - cluster.initial_cluster_manager_nodes=opensearch-node1 - bootstrap.memory_lock=true # along with the memlock settings below, disables swapping - 'ES_JAVA_OPTS=-Xms512m -Xmx512m' # minimum and maximum Java heap size, recommend setting both to 50% of system RAM ulimits: diff --git a/.github/workflows/add-untriaged.yml b/.github/workflows/add-untriaged.yml new file mode 100644 index 00000000..9dcc7020 --- /dev/null +++ b/.github/workflows/add-untriaged.yml @@ -0,0 +1,19 @@ +name: Apply 'untriaged' label during issue lifecycle + +on: + issues: + types: [opened, reopened, transferred] + +jobs: + apply-label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v6 + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['untriaged'] + }) diff --git a/.github/workflows/build-and-test-workflow.yml b/.github/workflows/build-and-test-workflow.yml new file mode 100644 index 00000000..2c2a2645 --- /dev/null +++ b/.github/workflows/build-and-test-workflow.yml @@ -0,0 +1,107 @@ +name: Build and test workflow +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" +env: + OPENSEARCH_DASHBOARDS_VERSION: '2.x' + +jobs: + Get-CI-Image-Tag: + uses: opensearch-project/opensearch-build/.github/workflows/get-ci-image-tag.yml@main + with: + product: opensearch-dashboards + + build-and-test-linux: + needs: Get-CI-Image-Tag + name: Build the plugin and run unit tests + runs-on: ubuntu-latest + container: + # using the same image which is used by opensearch-build team to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: --user root + + steps: + - name: Checkout OpenSearch Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + - name: Checkout Anomaly Detection OpenSearch Dashboards plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + - name: Bootstrap / build / unit test the plugin + run: | + chown -R 1000:1000 `pwd` + cd ./OpenSearch-Dashboards/ + su `id -un 1000` -c "source $NVM_DIR/nvm.sh && nvm use && node -v && yarn -v && + cd ./plugins/anomaly-detection-dashboards-plugin && + whoami && yarn osd bootstrap --single-version=loose && yarn build && yarn run test:jest --coverage" + - name: Uploads coverage + uses: codecov/codecov-action@v1 + + # TODO: once github actions supports windows and macos docker containers, we can + # merge these in to the above step's matrix. + build-and-test-windows-macos: + name: Build the plugin and run unit tests + strategy: + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + # Enable longer filenames for windows + - name: Enable longer filenames + if: ${{ matrix.os == 'windows-latest' }} + run: git config --system core.longpaths true + - name: Checkout OpenSearch Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version-file: './OpenSearch-Dashboards/.nvmrc' + registry-url: 'https://registry.npmjs.org' + - name: Install Yarn + # Need to use bash to avoid having a windows/linux specific step + shell: bash + run: | + YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn") + echo "Installing yarn@$YARN_VERSION" + npm i -g yarn@$YARN_VERSION + - run: node -v + - run: yarn -v + - name: Checkout Anomaly Detection OpenSearch Dashboards plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + - name: Bootstrap the plugin + run: | + cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + yarn osd bootstrap --single-version=loose + - name: Set npm to use bash for shell + if: ${{ matrix.os == 'windows-latest' }} + run: | + # Sets Windows to use bash for npm shell so the script (e.g., environment variable resolution in package.json postbuild script) + # commands work as intended + npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" + - name: Build the plugin + run: | + cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + yarn build + - name: Run unit tests + run: | + cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + yarn run test:jest --coverage + - name: Uploads coverage + uses: codecov/codecov-action@v1 + diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml deleted file mode 100644 index d4c9ff2c..00000000 --- a/.github/workflows/dco.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Developer Certificate of Origin Check - -on: [pull_request] - -jobs: - check: - runs-on: ubuntu-latest - - steps: - - name: Get PR Commits - id: 'get-pr-commits' - uses: tim-actions/get-pr-commits@v1.1.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: DCO Check - uses: tim-actions/dco@v1.1.0 - with: - commits: ${{ steps.get-pr-commits.outputs.commits }} \ No newline at end of file diff --git a/.github/workflows/remote-integ-tests-workflow.yml b/.github/workflows/remote-integ-tests-workflow.yml index b5e29d8f..70ecf9b5 100644 --- a/.github/workflows/remote-integ-tests-workflow.yml +++ b/.github/workflows/remote-integ-tests-workflow.yml @@ -1,94 +1,167 @@ -# Running AD integ tests stored in https://github.com/opensearch-project/opensearch-dashboards-functional-test -# In the future we should pull dependencies from bundled build snapshots. Because that is not available -# yet we build the cluster from source (besides core Opensearch, which is a pulled min artifact). -name: Remote integ tests workflow -on: - push: - branches: - - main - pull_request: - branches: - - main +name: FTR E2E AD Workbench Test + +on: [pull_request, push] + env: - OPENSEARCH_DASHBOARDS_VERSION: 'main' - OPENSEARCH_VERSION: '2.0.0-rc1-SNAPSHOT' + CI: 1 + # avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + OPENSEARCH_DASHBOARDS_VERSION: '2.x' + jobs: - test-without-security: - name: Run integ tests without security + tests: + name: Run FTR E2E AD Workbench Tests strategy: + fail-fast: false matrix: - os: [ubuntu-latest] - java: [11] + os: [ ubuntu-latest ] + jdk: [ 11 ] runs-on: ${{ matrix.os }} + steps: + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: ${{ matrix.jdk }} + - name: Checkout Anomaly-Detection uses: actions/checkout@v2 with: path: anomaly-detection repository: opensearch-project/anomaly-detection - ref: 'main' - - name: Run Opensearch with plugin + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + + - name: Run OpenSearch with plugin run: | cd anomaly-detection - ./gradlew run -Dopensearch.version=${{ env.OPENSEARCH_VERSION }} & - timeout 300 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' localhost:9200)" != "200" ]]; do sleep 5; done' + ./gradlew run & + timeout 300 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' localhost:9200)" != "200" ]]; do sleep 5; done' + shell: bash + + - name: Check OpenSearch Running on Linux + if: ${{ runner.os != 'Windows'}} + run: curl http://localhost:9200/ + shell: bash + - name: Checkout OpenSearch Dashboards uses: actions/checkout@v2 with: + path: OpenSearch-Dashboards repository: opensearch-project/OpenSearch-Dashboards ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} - path: OpenSearch-Dashboards - - name: Checkout Anomaly Detection OpenSearch Dashboards plugin + fetch-depth: 0 + filter: | + cypress + test + + - name: Checkout AD in OpenSearch Dashboards Plugins Dir uses: actions/checkout@v2 with: - path: OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin - - name: Get node and yarn versions - id: versions_step + path: OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + + - id: tool-versions run: | - echo "::set-output name=node_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.node).match(/[.0-9]+/)[0]")" - echo "::set-output name=yarn_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.yarn).match(/[.0-9]+/)[0]")" - - name: Setup node - uses: actions/setup-node@v1 + echo "node_version=$(cat .node-version)" >> $GITHUB_OUTPUT + echo "yarn_version=$(jq -r '.engines.yarn' package.json)" >> $GITHUB_OUTPUT + working-directory: OpenSearch-Dashboards + shell: bash + + - uses: actions/setup-node@v1 with: - node-version: ${{ steps.versions_step.outputs.node_version }} + node-version: ${{ steps.tool-versions.outputs.node_version }} registry-url: 'https://registry.npmjs.org' - - name: Install correct yarn version for OpenSearch Dashboards + - name: Setup Opensearch Dashboards run: | npm uninstall -g yarn - echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" - npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }} - - name: Bootstrap the plugin + echo "Installing yarn ${{ steps.tool-versions.outputs.yarn_version }}" + npm i -g yarn@${{ steps.tool-versions.outputs.yarn_version }} + yarn cache clean + yarn add sha.js + working-directory: OpenSearch-Dashboards + shell: bash + + - name: Boodstrap Opensearch Dashboards + run: | + yarn osd bootstrap --single-version=loose + working-directory: OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin + + - name: Run Opensearch Dashboards with AD Installed + run: | + nohup yarn start --no-base-path --no-watch --server.host="0.0.0.0" | tee dashboard.log & + working-directory: OpenSearch-Dashboards + + - name : Check If OpenSearch Dashboards Is Ready + if: ${{ runner.os == 'Linux' }} run: | - cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin - yarn osd bootstrap - - name: Run OpenSearch Dashboards server + if timeout 600 grep -q "bundles compiled successfully after" <(tail -n0 -f dashboard.log); then + echo "OpenSearch Dashboards compiled successfully." + else + echo "Timeout for 600 seconds reached. OpenSearch Dashboards did not finish compiling." + exit 1 + fi + working-directory: OpenSearch-Dashboards + + - name: Show OpenSearch Dashboards Logs + if: always() + run: cat dashboard.log + working-directory: OpenSearch-Dashboards + + - name: Health check run: | - cd OpenSearch-Dashboards - yarn start --no-base-path --no-watch & - sleep 300 - - name: Checkout opensearch-dashboards-functional-test + timeout 600 bash -c 'while [[ "$(curl -k http://localhost:5601/api/status | jq -r '.status.overall.state')" != "green" ]]; do sleep 5; done' + shell: bash + + - name: Check OpenSearch Dashboards Running on Linux + if: ${{ runner.os != 'Windows'}} + run: curl http://localhost:5601/api/status + shell: bash + + - name: Checkout Dashboards Functional Test Repo uses: actions/checkout@v2 with: path: opensearch-dashboards-functional-test repository: opensearch-project/opensearch-dashboards-functional-test - ref: 'main' # TODO: change to a branch when the branching strategy in that repo has been established + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + fetch-depth: 0 + + - name: Install Cypress + run: | + npm install cypress --save-dev + shell: bash + working-directory: opensearch-dashboards-functional-test + - name: Get Cypress version id: cypress_version run: | - echo "::set-output name=cypress_version::$(cat ./opensearch-dashboards-functional-test/package.json | jq '.devDependencies.cypress' | tr -d '"')" - - name: Cache Cypress - id: cache-cypress - uses: actions/cache@v1 + echo "::set-output name=cypress_version::$(cat ./package.json | jq '.dependencies.cypress' | tr -d '"')" + working-directory: opensearch-dashboards-functional-test + + - name: Finding spec files and store to output + id: finding-files + run: | + { + echo 'FILELIST<> "$GITHUB_ENV" + working-directory: opensearch-dashboards-functional-test + + - name: Run spec files from output + run: | + env CYPRESS_NO_COMMAND_LOG=1 yarn cypress:run-without-security --browser chromium --spec 'cypress/integration/plugins/anomaly-detection-dashboards-plugin/*' + working-directory: opensearch-dashboards-functional-test + + - name: Capture failure screenshots + uses: actions/upload-artifact@v1 + if: failure() with: - path: ~/.cache/Cypress - key: cypress-cache-v2-${{ runner.os }}-${{ hashFiles('**/package.json') }} - env: - CYPRESS_INSTALL_BINARY: ${{ steps.cypress_version.outputs.cypress_version }} - - run: npx cypress cache list - - run: npx cypress cache path - - name: Run AD cypress tests - uses: cypress-io/github-action@v2 + name: cypress-screenshots-${{ matrix.os }} + path: opensearch-dashboards-functional-test/cypress/screenshots + + - name: Capture failure test video + uses: actions/upload-artifact@v1 + if: failure() with: - working-directory: opensearch-dashboards-functional-test - command: yarn run cypress run --env SECURITY_ENABLED=false --spec cypress/integration/plugins/anomaly-detection-dashboards-plugin/*.js + name: cypress-videos-${{ matrix.os }} + path: opensearch-dashboards-functional-test/cypress/videos diff --git a/.github/workflows/unit-tests-workflow.yml b/.github/workflows/unit-tests-workflow.yml deleted file mode 100644 index e72e3e88..00000000 --- a/.github/workflows/unit-tests-workflow.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Unit tests workflow -on: - push: - branches: - - main - pull_request: - branches: - - main -env: - OPENSEARCH_DASHBOARDS_VERSION: 'main' -jobs: - tests: - name: Run unit tests - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout OpenSearch Dashboards - uses: actions/checkout@v2 - with: - repository: opensearch-project/OpenSearch-Dashboards - ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} - path: OpenSearch-Dashboards - - name: Get node and yarn versions - id: versions_step - run: | - echo "::set-output name=node_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.node).match(/[.0-9]+/)[0]")" - echo "::set-output name=yarn_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.yarn).match(/[.0-9]+/)[0]")" - - name: Setup node - uses: actions/setup-node@v1 - with: - node-version: ${{ steps.versions_step.outputs.node_version }} - registry-url: 'https://registry.npmjs.org' - - name: Install correct yarn version for OpenSearch Dashboards - run: | - npm uninstall -g yarn - echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" - npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }} - - name: Checkout Anomaly Detection OpenSearch Dashboards plugin - uses: actions/checkout@v2 - with: - path: OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin - - name: Bootstrap the plugin - run: | - cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin - yarn osd bootstrap - - name: Run tests - run: | - cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin - yarn run test:jest --coverage - - name: Uploads coverage - uses: codecov/codecov-action@v1 diff --git a/.github/workflows/verify-binary-installation.yml b/.github/workflows/verify-binary-installation.yml new file mode 100644 index 00000000..531e53f9 --- /dev/null +++ b/.github/workflows/verify-binary-installation.yml @@ -0,0 +1,54 @@ +name: 'Install Dashboards with Plugin via Binary' + +on: [push, pull_request] +env: + OPENSEARCH_VERSION: '2.14.0' + CI: 1 + # avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + +jobs: + verify-binary-installation: + name: Run binary installation + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # TODO: add windows support when OSD core is stable on windows + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Branch + uses: actions/checkout@v3 + + - name: Set env + run: | + opensearch_version=$(node -p "require('./opensearch_dashboards.json').opensearchDashboardsVersion") + plugin_version=$(node -p "require('./opensearch_dashboards.json').version") + echo "OPENSEARCH_VERSION=$opensearch_version" >> $GITHUB_ENV + echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV + shell: bash + + - name: Run Opensearch + uses: derek-ho/start-opensearch@v2 + with: + opensearch-version: ${{ env.OPENSEARCH_VERSION }} + security-enabled: false + + - name: Run Dashboard + id: setup-dashboards + uses: derek-ho/setup-opensearch-dashboards@v1 + with: + plugin_name: anomaly-detection-dashboards-plugin + built_plugin_name: anomaly-detection-dashboards + install_zip: true + + - name: Start the binary + run: | + nohup ./bin/opensearch-dashboards & + working-directory: ${{ steps.setup-dashboards.outputs.dashboards-binary-directory }} + shell: bash + + - name: Health check + run: | + timeout 300 bash -c 'while [[ "$(curl http://localhost:5601/api/status | jq -r '.status.overall.state')" != "green" ]]; do sleep 5; done' + shell: bash diff --git a/MAINTAINERS.md b/MAINTAINERS.md index ed2946d4..46a5364b 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,13 +1,26 @@ -# OpenSearch Anomaly Detection Dashboards Maintainers +## Overview -## Maintainers +This document contains a list of maintainers in this repo. See [opensearch-project/.github/RESPONSIBILITIES.md](https://github.com/opensearch-project/.github/blob/main/RESPONSIBILITIES.md#maintainer-responsibilities) that explains what the role of maintainer means, what maintainers do in this and other repos, and how they should be doing it. If you're interested in contributing, and becoming a maintainer, see [CONTRIBUTING](CONTRIBUTING.md). -| Maintainer | GitHub ID | Affiliation | -| ----------------------- | ------------------------------------------------------- | ----------- | -| Tyler Ohlsen | [ohltyler](https://github.com/ohltyler) | Amazon | +## Current Maintainers + +| Maintainer | GitHub ID | Affiliation | +| ----------------------- | -------------------------------------------------------- | ----------- | +| Tyler Ohlsen | [ohltyler](https://github.com/ohltyler) | Amazon | +| Kaituo Li | [kaituo](https://github.com/kaituo) | Amazon | +| Jackie Han | [jackiehanyang](https://github.com/jackiehanyang) | Amazon | +| Amit Galitzky | [amitgalitz](https://github.com/amitgalitz) | Amazon | +| Sean Zheng | [sean-zheng-amazon](https://github.com/sean-zheng-amazon)| Amazon | +| Dan Widdis | [dbwiddis](https://github.com/dbwiddis) | Amazon | +| Owais Kazi | [owaiskazi19](https://github.com/owaiskazi19) | Amazon | +| Josh Palis | [joshpalis](https://github.com/joshpalis) | Amazon | +| Sudipto Guha | [sudiptoguha](https://github.com/sudiptoguha) | Amazon | + +## Emeritus Maintainers + +| Maintainer | GitHub ID | Affiliation | +| ----------------- | ------------------------------------------------------- | ----------- | | Yaliang | [ylwu-amzn](https://github.com/ylwu-amzn) | Amazon | | Yizhe Liu | [yizheliu-amazon](https://github.com/yizheliu-amazon) | Amazon | | Vijayan Balasubramanian | [VijayanB](https://github.com/VijayanB) | Amazon | -| Sarat Vemulapalli | [saratvemulapalli](https://github.com/saratvemulapalli) | Amazon | - -[This document](https://github.com/opensearch-project/.github/blob/main/MAINTAINERS.md) explains what maintainers do in this repo, and how they should be doing it. If you're interested in contributing, see [CONTRIBUTING](CONTRIBUTING.md). +| Sarat Vemulapalli | [saratvemulapalli](https://github.com/saratvemulapalli) | Amazon | \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 0bd5b917..a3c59564 100644 --- a/babel.config.js +++ b/babel.config.js @@ -16,8 +16,8 @@ module.exports = { require('@babel/preset-typescript'), ], plugins: [ - require('@babel/plugin-proposal-class-properties'), - require('@babel/plugin-proposal-object-rest-spread'), + require('@babel/plugin-transform-class-properties'), + require('@babel/plugin-transform-object-rest-spread'), ['@babel/plugin-transform-modules-commonjs', { allowTopLevelThis: true }], [require('@babel/plugin-transform-runtime'), { regenerator: true }], ], diff --git a/global-setup.js b/global-setup.js new file mode 100644 index 00000000..67ca0db6 --- /dev/null +++ b/global-setup.js @@ -0,0 +1,3 @@ +export default () => { + process.env.TZ = 'UTC'; +}; diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 8cfa51a0..aa749836 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,10 +1,32 @@ { "id": "anomalyDetectionDashboards", - "version": "2.0.0.0-rc1", - "opensearchDashboardsVersion": "2.0.0", - "configPath": ["anomaly_detection_dashboards"], - "requiredPlugins": ["navigation"], - "optionalPlugins": [], + "version": "2.17.1.0", + "opensearchDashboardsVersion": "2.17.1", + "configPath": [ + "anomaly_detection_dashboards" + ], + "optionalPlugins": [ + "dataSource", + "dataSourceManagement" + ], + "requiredPlugins": [ + "opensearchDashboardsUtils", + "expressions", + "data", + "visAugmenter", + "uiActions", + "dashboard", + "embeddable", + "opensearchDashboardsReact", + "savedObjects", + "visAugmenter", + "opensearchDashboardsUtils", + "navigation" + ], "server": true, - "ui": true -} + "ui": true, + "supportedOSDataSourceVersions": ">=2.9.0", + "requiredOSDataSourcePlugins": [ + "opensearch-anomaly-detection" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index f65456c3..e5db0e78 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,11 @@ { "name": "anomaly-detection-dashboards", - "version": "2.0.0.0-rc1", + "version": "2.17.1.0", "description": "OpenSearch Anomaly Detection Dashboards Plugin", "main": "index.js", "config": { - "plugin_version": "2.0.0.0-rc1", - "plugin_name": "anomalyDetectionDashboards", - "plugin_zip_name": "anomaly-detection-dashboards" + "id": "anomalyDetectionDashboards", + "zip_name": "anomaly-detection-dashboards" }, "scripts": { "osd": "node ../../scripts/osd", @@ -14,7 +13,8 @@ "lint": "node ../../scripts/eslint .", "plugin-helpers": "node ../../scripts/plugin_helpers", "test:jest": "../../node_modules/.bin/jest --config ./test/jest.config.js", - "build": "yarn plugin-helpers build && echo Renaming artifact to $npm_package_config_plugin_zip_name-$npm_package_config_plugin_version.zip && mv ./build/$npm_package_config_plugin_name*.zip ./build/$npm_package_config_plugin_zip_name-$npm_package_config_plugin_version.zip" + "build": "yarn plugin-helpers build", + "postbuild": "echo Renaming artifact to [$npm_package_config_zip_name-$npm_package_version.zip] && mv build/$npm_package_config_id*.zip build/$npm_package_config_zip_name-$npm_package_version.zip" }, "lint-staged": { "*.{ts,tsx,js,jsx,json,css,md}": [ @@ -23,26 +23,40 @@ ] }, "devDependencies": { - "@elastic/eslint-import-resolver-kibana": "link:../../packages/osd-eslint-import-resolver-opensearch-dashboards", "@testing-library/user-event": "^12.1.6", - "@types/react-plotly.js": "^2.2.4", - "@types/redux-mock-store": "^1.0.1", + "@types/react-plotly.js": "^2.6.0", + "@types/redux-mock-store": "^1.0.6", "babel-polyfill": "^6.26.0", "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-prefer-object-spread": "^1.2.1", + "jest-canvas-mock": "^2.5.1", "lint-staged": "^9.2.0", "moment": "^2.24.0", - "redux-mock-store": "^1.5.3", + "redux-mock-store": "^1.5.4", "start-server-and-test": "^1.11.7" }, "dependencies": { "babel-polyfill": "^6.26.0", "brace": "0.11.1", "formik": "^2.2.5", - "plotly.js-dist": "^1.57.1", + "plotly.js-dist": "^2.29.1", "prettier": "^2.1.1", - "react-plotly.js": "^2.4.0", + "react-plotly.js": "^2.6.0", "react-redux": "^7.2.0", "reselect": "^4.0.0" + }, + "resolutions": { + "**/ansi-regex": "^5.0.1", + "**/glob-parent": "^6.0.0", + "**/loader-utils": "^2.0.4", + "**/terser": "^4.8.1", + "decode-uri-component": "^0.2.1", + "json5": "^2.2.3", + "@sideway/formula": "^3.0.1", + "semver": "^5.7.2", + "browserify-sign": "^4.2.2", + "axios": "^1.6.1", + "braces": "^3.0.3", + "micromatch": "^4.0.8" } -} +} \ No newline at end of file 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/anomaly_detection_app.tsx b/public/anomaly_detection_app.tsx index dfbc4591..a496d144 100644 --- a/public/anomaly_detection_app.tsx +++ b/public/anomaly_detection_app.tsx @@ -18,7 +18,7 @@ import { Provider } from 'react-redux'; import configureStore from './redux/configureStore'; import { CoreServicesContext } from './components/CoreServices/CoreServices'; -export function renderApp(coreStart: CoreStart, params: AppMountParameters) { +export function renderApp(coreStart: CoreStart, params: AppMountParameters, landingPage: string | undefined, hideInAppSideNavBar: boolean) { const http = coreStart.http; const store = configureStore(http); @@ -29,13 +29,19 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters) { } else { require('@elastic/charts/dist/theme_only_light.css'); } + ReactDOM.render( ( -
+
)} /> diff --git a/public/components/CodeModal/CodeModal.tsx b/public/components/CodeModal/CodeModal.tsx index 43c10c5b..3698102c 100644 --- a/public/components/CodeModal/CodeModal.tsx +++ b/public/components/CodeModal/CodeModal.tsx @@ -17,6 +17,7 @@ import { EuiModalHeaderTitle, EuiOverlayMask, EuiCodeBlock, + EuiText, } from '@elastic/eui'; interface CodeModalProps { @@ -33,7 +34,9 @@ export const CodeModal = (props: CodeModalProps) => {
-

{props.title}

+ +

{props.title}

+
{props.subtitle ? (

{props.subtitle}

) : ( diff --git a/public/components/CodeModal/__tests__/CodeModal.test.tsx b/public/components/CodeModal/__tests__/CodeModal.test.tsx index b8dfbcbc..6a7de3f3 100644 --- a/public/components/CodeModal/__tests__/CodeModal.test.tsx +++ b/public/components/CodeModal/__tests__/CodeModal.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { CodeModal } from '../CodeModal'; -describe('CodeMOdal spec', () => { +describe('CodeModal spec', () => { const onVisibilityChange = jest.fn(() => true); const onCloseModal = jest.fn(); diff --git a/public/components/CodeModal/__tests__/__snapshots__/CodeModal.test.tsx.snap b/public/components/CodeModal/__tests__/__snapshots__/CodeModal.test.tsx.snap index fcddd060..d9be998c 100644 --- a/public/components/CodeModal/__tests__/__snapshots__/CodeModal.test.tsx.snap +++ b/public/components/CodeModal/__tests__/__snapshots__/CodeModal.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CodeMOdal spec renders the component 1`] = ` +exports[`CodeModal spec renders the component 1`] = ` ,
World
]} + > +
Testing ContentPanel
+ + ); + getByText('testing-1'); + getByText('testing-2'); + }); + test('renders array subtitle', () => { + const { getByText } = render( + Hello
,
World
]} + > +
Testing ContentPanel
+ + ); + getByText('testing-1'); + getByText('testing-2'); + }); }); diff --git a/public/components/ContentPanel/ContentPanel.tsx b/public/components/ContentPanel/ContentPanel.tsx index 690ee45f..d3d89c04 100644 --- a/public/components/ContentPanel/ContentPanel.tsx +++ b/public/components/ContentPanel/ContentPanel.tsx @@ -49,7 +49,7 @@ const ContentPanel = (props: ContentPanelProps) => { ); return ( { margin="s" className={props.horizontalRuleClassName} /> -
- {props.children} -
+
{props.children}
) : null}
diff --git a/public/components/CreateDetectorButtons/CreateDetectorButtons.tsx b/public/components/CreateDetectorButtons/CreateDetectorButtons.tsx index 087789f4..38baf34e 100644 --- a/public/components/CreateDetectorButtons/CreateDetectorButtons.tsx +++ b/public/components/CreateDetectorButtons/CreateDetectorButtons.tsx @@ -9,31 +9,41 @@ * GitHub history for details. */ -import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSmallButton } from '@elastic/eui'; import React from 'react'; import { APP_PATH, PLUGIN_NAME } from '../../utils/constants'; +import { useLocation } from 'react-router-dom'; +import { constructHrefWithDataSourceId, getDataSourceFromURL } from '../../pages/utils/helpers'; export const CreateDetectorButtons = () => { + const location = useLocation(); + const MDSQueryParams = getDataSourceFromURL(location); + const dataSourceId = MDSQueryParams.dataSourceId; + + const createDetectorUrl = `${PLUGIN_NAME}#` + constructHrefWithDataSourceId(`${APP_PATH.CREATE_DETECTOR}`, dataSourceId, false); + + const sampleDetectorUrl = `${PLUGIN_NAME}#` + constructHrefWithDataSourceId(`${APP_PATH.OVERVIEW}`, dataSourceId, false); + return ( - Try a sample detector - + - Create detector - + ); 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..aa420a7a --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx @@ -0,0 +1,84 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiOverlayMask, + EuiSmallButton, + EuiSmallButtonEmpty, + 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..c093e59f --- /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..dda1af69 --- /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..90dc8028 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx @@ -0,0 +1,397 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useEffect, useState } from 'react'; +import { + EuiFlyoutHeader, + EuiTitle, + EuiSpacer, + EuiInMemoryTable, + EuiFlyoutBody, + EuiSmallButton, + EuiFlyout, + EuiFlexItem, + EuiFlexGroup, + EuiCallOut, + EuiText, +} 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, + getSavedObjectsClient, +} 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'; +import { getAllDetectorsQueryParamsWithDataSourceId } from '../../../../../public/pages/utils/helpers'; + +interface ConfirmModalState { + isOpen: boolean; + action: ASSOCIATED_DETECTOR_ACTION; + isListLoading: boolean; + isRequestingToClose: boolean; + affectedDetector: DetectorListItem; +} + +interface References { + id: string; + name: string; + type: string; +} + +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 indexPatternId = embeddable.vis.data.aggs != null ? embeddable.vis.data.aggs.indexPattern.id : ""; + const [dataSourceId, setDataSourceId] = useState(undefined); + + async function getDataSourceId() { + try { + const indexPattern = await getSavedObjectsClient().get('index-pattern', indexPatternId); + const refs = indexPattern.references as References[]; + const foundDataSourceId = refs.find(ref => ref.type === 'data-source')?.id; + setDataSourceId(foundDataSourceId); + } catch (error) { + console.error("Error fetching index pattern:", error); + } + } + + 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(() => { + async function fetchData() { + await getDataSourceId(); + getDetectors(); + } + fetchData(); + }, [dataSourceId]); + + // 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(id => 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( + getAllDetectorsQueryParamsWithDataSourceId(dataSourceId) + ) + ); + }; + + 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..58c280ff --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx @@ -0,0 +1,1049 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, Fragment } from 'react'; +import { + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiTitle, + EuiSmallButton, + EuiFormFieldset, + EuiCheckableCard, + EuiSpacer, + EuiIcon, + EuiText, + EuiCompressedSwitch, + EuiCompressedFormRow, + EuiCompressedFieldText, + EuiCompressedCheckbox, + EuiFlexItem, + EuiFlexGroup, + EuiCompressedFieldNumber, + EuiCallOut, + EuiSmallButtonEmpty, + 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, + getSavedObjectsClient, +} 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'; + +interface References { + id: string; + name: string; + type: string; +} + +function AddAnomalyDetector({ + embeddable, + closeFlyout, + mode, + setMode, + selectedDetector, + setSelectedDetector, +}) { + const dispatch = useDispatch(); + const [queryText, setQueryText] = useState(''); + const [generatedEmbeddable, setGeneratedEmbeddable] = useState< + VisualizeEmbeddable | ErrorEmbeddable + >(); + + const indexPatternId = embeddable.vis.data.aggs.indexPattern.id; + const [dataSourceId, setDataSourceId] = useState( + undefined + ); + + async function getDataSourceId() { + try { + const indexPattern = await getSavedObjectsClient().get( + 'index-pattern', + indexPatternId + ); + const refs = indexPattern.references as References[]; + const foundDataSourceId = refs.find( + (ref) => ref.type === 'data-source' + )?.id; + setDataSourceId(foundDataSourceId); + } catch (error) { + console.error('Error fetching index pattern:', error); + } + } + + // useEffect to dispatch actions once dataSourceId fetch is complete + useEffect(() => { + async function fetchData() { + await getDataSourceId(); + + const getIndicesDispatchCall = dispatch( + getIndices(queryText, dataSourceId) + ); + const getMappingDispatchCall = dispatch( + getMappings([embeddable.vis.data.aggs.indexPattern.title], dataSourceId) + ); + await Promise.all([getIndicesDispatchCall, getMappingDispatchCall]); + } + + async function createEmbeddable() { + const visEmbeddable = await fetchVisEmbeddable( + embeddable.vis.id, + getEmbeddable(), + getQueryService() + ); + setGeneratedEmbeddable(visEmbeddable); + } + fetchData(); + createEmbeddable(); + }, [dataSourceId]); + + 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('', 100, [], { + type: 'visualization', + id: embeddable.vis.id as string, + }) + .then(async (resp) => { + if (resp !== undefined) { + const savedObjectsForThisVisualization = get( + resp, + 'hits', + [] + ); + 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('', 100, [], { + type: 'visualization', + id: embeddable.vis.id as string, + }) + .then(async (resp) => { + if (resp !== undefined) { + const savedObjectsForThisVisualization = get(resp, 'hits', []); + 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, + dataSourceId: dataSourceId, + }, + } 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, dataSourceId)) + .then(async (response) => { + dispatch(startDetector(response.response.id, dataSourceId)) + .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(dataSourceId)).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, dataSourceId)); + 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..2caea78d --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx @@ -0,0 +1,305 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + EuiTitle, + EuiSpacer, + EuiIcon, + EuiText, + EuiCompressedComboBox, + 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, + getSavedObjectsClient, + 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'; +import { getAllDetectorsQueryParamsWithDataSourceId } from '../../../../../pages/utils/helpers'; + +interface AssociateExistingProps { + embeddableVisId: string; + selectedDetector: DetectorListItem | undefined; + setSelectedDetector(detector: DetectorListItem | undefined): void; + indexPatternId: string; +} +interface References { + id: string; + name: string; + type: string; +} + +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 [dataSourceId, setDataSourceId] = useState(undefined); + + async function getDataSourceId() { + try { + const indexPattern = await getSavedObjectsClient().get('index-pattern', associateExistingProps.indexPatternId); + const refs = indexPattern.references as References[]; + const foundDataSourceId = refs.find(ref => ref.type === 'data-source')?.id; + setDataSourceId(foundDataSourceId); + } catch (error) { + console.error("Error fetching index pattern:", error); + } + } + 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(() => { + async function fetchData() { + await getDataSourceId(); + getDetectors(); + } + fetchData(); + }, [dataSourceId]); + + const getDetectors = async () => { + dispatch(getDetectorList(getAllDetectorsQueryParamsWithDataSourceId(dataSourceId))); + }; + + 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..2ad6805a --- /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, + EuiSmallButtonIcon, + EuiSmallButtonEmpty, + 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/components/FormattedFormRow/FormattedFormRow.tsx b/public/components/FormattedFormRow/FormattedFormRow.tsx index 7a125664..043e493e 100644 --- a/public/components/FormattedFormRow/FormattedFormRow.tsx +++ b/public/components/FormattedFormRow/FormattedFormRow.tsx @@ -10,7 +10,7 @@ */ import React, { ReactElement, ReactNode } from 'react'; -import { EuiFormRow, EuiText, EuiLink, EuiIcon } from '@elastic/eui'; +import { EuiCompressedFormRow, EuiText, EuiLink, EuiIcon } from '@elastic/eui'; type FormattedFormRowProps = { title?: string; @@ -35,7 +35,7 @@ export const FormattedFormRow = (props: FormattedFormRowProps) => { {props.hintLink ? ' ' : null} {props.hintLink ? ( - Learn more + Learn more ) : null} @@ -46,7 +46,7 @@ export const FormattedFormRow = (props: FormattedFormRowProps) => { const { formattedTitle, ...euiFormRowProps } = props; return ( - {formattedTitle ? formattedTitle :

{props.title}

} @@ -57,6 +57,6 @@ export const FormattedFormRow = (props: FormattedFormRowProps) => { {...euiFormRowProps} > {props.children} -
+ ); }; diff --git a/public/expressions/__tests__/overlay_anomalies.test.ts b/public/expressions/__tests__/overlay_anomalies.test.ts new file mode 100644 index 00000000..b117c635 --- /dev/null +++ b/public/expressions/__tests__/overlay_anomalies.test.ts @@ -0,0 +1,152 @@ +/* + * 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; + 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..9c655a8b --- /dev/null +++ b/public/expressions/helpers.ts @@ -0,0 +1,143 @@ +/* + * 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, + dataSourceId: string = '', +): Promise => { + const anomalySummaryQuery = getAnomalySummaryQuery( + startTime, + endTime, + detectorId, + undefined, + false + ); + let anomalySummaryResponse; + if (resultIndex === '') { + anomalySummaryResponse = await getClient().post( + `..${AD_NODE_API.DETECTOR}/results/_search/${dataSourceId}`, + { + body: JSON.stringify(anomalySummaryQuery), + } + ); + } else { + if (!resultIndex.endsWith('*')) { + resultIndex += '*'; + } + anomalySummaryResponse = await getClient().post( + `..${AD_NODE_API.DETECTOR}/results/_search/${resultIndex}/true/${dataSourceId}`, + { + body: JSON.stringify(anomalySummaryQuery), + } + ); + } + + return parsePureAnomalies(anomalySummaryResponse); +}; + +export const getDetectorResponse = async (detectorId: string, dataSourceId: string = '') => { + const url = dataSourceId ? `..${AD_NODE_API.DETECTOR}/${detectorId}/${dataSourceId}` : `..${AD_NODE_API.DETECTOR}/${detectorId}`; + const resp = await getClient().get(url); + 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..e55efc48 --- /dev/null +++ b/public/expressions/overlay_anomalies.ts @@ -0,0 +1,169 @@ +/* + * 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; + dataSourceId: 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: '', + }, + dataSourceId: { + types: ['string'], + default: '""', + help: '', + }, + }, + + async fn(input, args, context): Promise { + // Parsing all of the args & input + const detectorId = get(args, 'detectorId', ''); + const dataSourceId = get(args, 'dataSourceId', ''); + 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, dataSourceId); + 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, + dataSourceId + ); + 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 47420b44..f5a62568 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -12,6 +12,13 @@ import { InitProgress } from '../../server/models/interfaces'; import { DATA_TYPES } from '../utils/constants'; import { DETECTOR_STATE } from '../../server/utils/constants'; +import { Duration } from 'moment'; +import moment from 'moment'; +import { MDSQueryParams } from '../../server/models/types'; +import { + ImputationOption, + Rule +} from './types'; export type FieldInfo = { label: string; @@ -78,8 +85,82 @@ export type FeatureAttributes = { aggregationQuery: { [key: string]: any }; }; +// all possible valid units accepted by the backend export enum UNITS { + NANOS = 'Nanos', + MICROS = 'Micros', + MILLIS = 'Millis', + SECONDS = 'Seconds', MINUTES = 'Minutes', + HOURS = 'Hours', + HALF_DAYS = 'HalfDays', + DAYS = 'Days', + WEEKS = 'Weeks', + MONTHS = 'Months', + YEARS = 'Years', + DECADES = 'Decades', + CENTURIES = 'Centuries', + MILLENNIA = 'Millennia', + ERAS = 'Eras', + FOREVER = 'Forever', +} + +// cannot create a method in enum, have to write function separately +export function toDuration(units: UNITS): Duration { + switch (units) { + case UNITS.NANOS: { + // Duration in moment library does not support + return moment.duration(0.000000001, 'seconds'); + } + case UNITS.MICROS: { + return moment.duration(0.000001, 'seconds'); + } + case UNITS.MILLIS: { + return moment.duration(0.001, 'seconds'); + } + case UNITS.SECONDS: { + return moment.duration(1, 'seconds'); + } + case UNITS.MINUTES: { + return moment.duration(60, 'seconds'); + } + case UNITS.HOURS: { + return moment.duration(3600, 'seconds'); + } + case UNITS.HALF_DAYS: { + return moment.duration(43200, 'seconds'); + } + case UNITS.DAYS: { + return moment.duration(86400, 'seconds'); + } + case UNITS.WEEKS: { + return moment.duration(7 * 86400, 'seconds'); + } + case UNITS.MONTHS: { + return moment.duration(31556952 / 12, 'seconds'); + } + case UNITS.YEARS: { + return moment.duration(31556952, 'seconds'); + } + case UNITS.DECADES: { + return moment.duration(31556952 * 10, 'seconds'); + } + case UNITS.CENTURIES: { + return moment.duration(31556952 * 100, 'seconds'); + } + case UNITS.MILLENNIA: { + return moment.duration(31556952 * 1000, 'seconds'); + } + case UNITS.ERAS: { + return moment.duration(31556952 * 1000000000, 'seconds'); + } + case UNITS.FOREVER: { + return moment.duration(Number.MAX_VALUE, 'seconds'); + } + default: + break; + } + throw new Error('Unexpected unit: ' + units); } export type Schedule = { @@ -110,6 +191,9 @@ export type Detector = { timeField: string; indices: string[]; resultIndex?: string; + resultIndexMinAge?: number; + resultIndexMinSize?: number; + resultIndexTtl?: number; filterQuery: { [key: string]: any }; featureAttributes: FeatureAttributes[]; windowDelay: { period: Schedule }; @@ -129,6 +213,8 @@ export type Detector = { taskState?: DETECTOR_STATE; taskProgress?: number; taskError?: string; + imputationOption?: ImputationOption; + rules?: Rule[]; }; export type DetectorListItem = { @@ -141,6 +227,7 @@ export type DetectorListItem = { lastActiveAnomaly: number; lastUpdateTime: number; enabledTime?: number; + detectorType?: string; }; export type EntityData = { @@ -158,14 +245,23 @@ export type AnomalyData = { plotTime?: number; entity?: EntityData[]; features?: { [key: string]: FeatureAggregationData }; + contributions?: { [key: string]: FeatureContributionData }; aggInterval?: string; }; export type FeatureAggregationData = { data: number; + name?: string; endTime: number; startTime: number; plotTime?: number; + attribution?: number; + expectedValue?: number; +}; + +export type FeatureContributionData = { + name: string; + attribution: number; }; export type Anomalies = { @@ -189,10 +285,10 @@ export type MonitorAlert = { triggerName: string; severity: number; state: string; - error: string; + error: string | null; startTime: number; - endTime: number; - acknowledgedTime: number; + endTime: number | null; + acknowledgedTime: number | null; }; export type AnomalySummary = { @@ -203,6 +299,7 @@ export type AnomalySummary = { minConfidence: number; maxConfidence: number; lastAnomalyOccurrence: string; + contributions?: string; }; export type DateRange = { @@ -239,3 +336,8 @@ export interface ValidationSettingResponse { message: string; validationType: string; } + +export interface MDSStates { + queryParams: MDSQueryParams; + selectedDataSourceId: string | undefined; +} diff --git a/public/models/types.ts b/public/models/types.ts index f56af8e9..866396cf 100644 --- a/public/models/types.ts +++ b/public/models/types.ts @@ -12,3 +12,108 @@ export type AggregationOption = { label: string; }; + +export type ImputationOption = { + method: ImputationMethod; + defaultFill?: Array<{ featureName: string; data: number }>; +}; + +export enum ImputationMethod { + /** + * This method replaces all missing values with 0's. It's a simple approach, but it may introduce bias if the data is not centered around zero. + */ + ZERO = 'ZERO', + /** + * This method replaces missing values with a predefined set of values. The values are the same for each input dimension, and they need to be specified by the user. + */ + FIXED_VALUES = 'FIXED_VALUES', + /** + * This method replaces missing values with the last known value in the respective input dimension. It's a commonly used method for time series data, where temporal continuity is expected. + */ + PREVIOUS = 'PREVIOUS', +} + +// Constants for field names +export const RULES_FIELD = "rules"; +export const ACTION_FIELD = "action"; +export const CONDITIONS_FIELD = "conditions"; +export const FEATURE_NAME_FIELD = "feature_name"; +export const THRESHOLD_TYPE_FIELD = "threshold_type"; +export const OPERATOR_FIELD = "operator"; +export const VALUE_FIELD = "value"; + +// Enums +export enum Action { + IGNORE_ANOMALY = "IGNORE_ANOMALY", // ignore anomaly if found +} + +export enum ThresholdType { + /** + * Specifies a threshold for ignoring anomalies where the actual value + * exceeds the expected value by a certain margin. + * + * Assume a represents the actual value and b signifies the expected value. + * IGNORE_SIMILAR_FROM_ABOVE implies the anomaly should be disregarded if a-b + * is less than or equal to ignoreSimilarFromAbove. + */ + ACTUAL_OVER_EXPECTED_MARGIN = "ACTUAL_OVER_EXPECTED_MARGIN", + + /** + * Specifies a threshold for ignoring anomalies where the actual value + * is below the expected value by a certain margin. + * + * Assume a represents the actual value and b signifies the expected value. + * Likewise, IGNORE_SIMILAR_FROM_BELOW + * implies the anomaly should be disregarded if b-a is less than or equal to + * ignoreSimilarFromBelow. + */ + EXPECTED_OVER_ACTUAL_MARGIN = "EXPECTED_OVER_ACTUAL_MARGIN", + + /** + * Specifies a threshold for ignoring anomalies based on the ratio of + * the difference to the actual value when the actual value exceeds + * the expected value. + * + * Assume a represents the actual value and b signifies the expected value. + * The variable IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO presumably implies the + * anomaly should be disregarded if the ratio of the deviation from the actual + * to the expected (a-b)/|a| is less than or equal to IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO. + */ + ACTUAL_OVER_EXPECTED_RATIO = "ACTUAL_OVER_EXPECTED_RATIO", + + /** + * Specifies a threshold for ignoring anomalies based on the ratio of + * the difference to the actual value when the actual value is below + * the expected value. + * + * Assume a represents the actual value and b signifies the expected value. + * Likewise, IGNORE_NEAR_EXPECTED_FROM_BELOW_BY_RATIO appears to indicate that the anomaly + * should be ignored if the ratio of the deviation from the expected to the actual + * (b-a)/|a| is less than or equal to ignoreNearExpectedFromBelowByRatio. + */ + EXPECTED_OVER_ACTUAL_RATIO = "EXPECTED_OVER_ACTUAL_RATIO", +} + +// Method to get the description of ThresholdType +export function getThresholdTypeDescription(thresholdType: ThresholdType): string { + return thresholdType; // In TypeScript, the enum itself holds the description. +} + +// Enums for Operators +export enum Operator { + LTE = "LTE", +} + +// Interfaces for Rule and Condition +export interface Rule { + action: Action; + conditions: Condition[]; +} + +export interface Condition { + featureName: string; + thresholdType: ThresholdType; + operator: Operator; + value: number; +} + diff --git a/public/pages/AnomalyCharts/components/AlertsButton/AlertsButton.tsx b/public/pages/AnomalyCharts/components/AlertsButton/AlertsButton.tsx index 0e741eaa..d4ee0b54 100644 --- a/public/pages/AnomalyCharts/components/AlertsButton/AlertsButton.tsx +++ b/public/pages/AnomalyCharts/components/AlertsButton/AlertsButton.tsx @@ -9,7 +9,7 @@ * GitHub history for details. */ -import { EuiButton, EuiButtonProps } from '@elastic/eui'; +import { EuiSmallButton, EuiButtonProps } from '@elastic/eui'; import React, { Fragment } from 'react'; import { getAlertingCreateMonitorLink, @@ -30,14 +30,14 @@ export const AlertsButton = (props: AlertsButtonProps) => { return ( {props.monitor ? ( - Edit alert settings - + ) : ( - { {...props} > Set up alerts - + )} ); diff --git a/public/pages/AnomalyCharts/components/AlertsButton/__tests__/__snapshots__/AlertsButton.test.tsx.snap b/public/pages/AnomalyCharts/components/AlertsButton/__tests__/__snapshots__/AlertsButton.test.tsx.snap index 1cf7cf2b..a4a862f9 100644 --- a/public/pages/AnomalyCharts/components/AlertsButton/__tests__/__snapshots__/AlertsButton.test.tsx.snap +++ b/public/pages/AnomalyCharts/components/AlertsButton/__tests__/__snapshots__/AlertsButton.test.tsx.snap @@ -3,7 +3,7 @@ exports[` spec Alerts Button renders component with monitor 1`] = `
spec Alerts Button renders component with monitor 1`] exports[` spec Alerts Button renders component without monitor 1`] = `
diff --git a/public/pages/AnomalyCharts/components/FeatureChart/FeatureChart.tsx b/public/pages/AnomalyCharts/components/FeatureChart/FeatureChart.tsx index 29773470..cb001930 100644 --- a/public/pages/AnomalyCharts/components/FeatureChart/FeatureChart.tsx +++ b/public/pages/AnomalyCharts/components/FeatureChart/FeatureChart.tsx @@ -21,7 +21,7 @@ import { LineAnnotation, AnnotationDomainType, } from '@elastic/charts'; -import { EuiText, EuiLink, EuiButton, EuiIcon } from '@elastic/eui'; +import { EuiText, EuiLink, EuiSmallButton, EuiIcon } from '@elastic/eui'; import React, { useState, Fragment } from 'react'; import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; import { useDelayedLoader } from '../../../../hooks/useDelayedLoader'; @@ -68,6 +68,7 @@ interface FeatureChartProps { rawFeatureData: FeatureAggregationData[][]; entityData: EntityData[][]; isHCDetector?: boolean; + windowDelay: Schedule; } export const FeatureChart = (props: FeatureChartProps) => { @@ -165,7 +166,7 @@ export const FeatureChart = (props: FeatureChartProps) => { subTitle={featureDescription()} actions={ props.edit ? ( - Edit feature + Edit feature ) : null } > @@ -204,6 +205,7 @@ export const FeatureChart = (props: FeatureChartProps) => { * and thus show different annotations per feature chart (currently all annotations * shown equally across all enabled feature charts for a given detector). */} + {props.feature.featureEnabled ? ( { ? flattenData(props.rawFeatureData) : flattenData(props.featureData), props.detectorInterval.interval, + props.windowDelay, getFeatureMissingAnnotationDateRange( props.dateRange, props.detectorEnabledTime ), - props.dateRange + props.dateRange, + // date range is selected by customer in UX so window delay time is not considered + false )} marker={} style={{ @@ -253,13 +258,14 @@ export const FeatureChart = (props: FeatureChartProps) => { } {featureData.map( (featureTimeSeries: FeatureAggregationData[], index) => { + const timeSeriesList: any[] = []; const seriesKey = props.isHCDetector ? `${props.featureDataSeriesName} (${convertToEntityString( props.entityData[index], ', ' )})` : props.featureDataSeriesName; - return ( + timeSeriesList.push( { data={featureTimeSeries} /> ); + if ( + featureTimeSeries.map((item: FeatureAggregationData) => { + if (item.hasOwnProperty('expectedValue')) { + timeSeriesList.push( + + ); + } + }) + ) + return timeSeriesList; } )} diff --git a/public/pages/AnomalyCharts/components/FeatureChart/NoFeaturePrompt.tsx b/public/pages/AnomalyCharts/components/FeatureChart/NoFeaturePrompt.tsx index 7765d9fc..21a5d7ce 100644 --- a/public/pages/AnomalyCharts/components/FeatureChart/NoFeaturePrompt.tsx +++ b/public/pages/AnomalyCharts/components/FeatureChart/NoFeaturePrompt.tsx @@ -16,7 +16,7 @@ import { EuiText, EuiFlexItem, EuiFlexGroup, - EuiButton, + EuiSmallButton, } from '@elastic/eui'; import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; @@ -42,12 +42,12 @@ export const NoFeaturePrompt = (props: NoFeaturePromptProps) => { } actions={[ - Add feature - , + , ]} /> diff --git a/public/pages/AnomalyCharts/containers/AnomaliesChart.tsx b/public/pages/AnomalyCharts/containers/AnomaliesChart.tsx index 901fe5f9..2a64a257 100644 --- a/public/pages/AnomalyCharts/containers/AnomaliesChart.tsx +++ b/public/pages/AnomalyCharts/containers/AnomaliesChart.tsx @@ -15,7 +15,7 @@ import { EuiFlexItem, EuiLoadingChart, EuiSpacer, - EuiSuperDatePicker, + EuiCompressedSuperDatePicker, EuiTitle, } from '@elastic/eui'; import { get, orderBy } from 'lodash'; @@ -84,6 +84,7 @@ export interface AnomaliesChartProps { entityAnomalySummaries?: EntityAnomalySummaries[]; selectedCategoryFields?: any[]; handleCategoryFieldsChange(selectedOptions: any[]): void; + openOutOfRangeCallOut?: boolean; } export const AnomaliesChart = React.memo((props: AnomaliesChartProps) => { @@ -96,6 +97,8 @@ export const AnomaliesChart = React.memo((props: AnomaliesChartProps) => { : 'now', }); + const [showOutOfRangeCallOut, setShowOutOfRangeCallOut] = useState(false); + // for each time series of results, get the anomalies, ignoring feature data let anomalyResults = [] as AnomalyData[][]; get(props, 'anomalyAndFeatureResults', []).forEach( @@ -107,6 +110,11 @@ export const AnomaliesChart = React.memo((props: AnomaliesChartProps) => { const handleDateRangeChange = (startDate: number, endDate: number) => { props.onDateRangeChange(startDate, endDate); props.onZoomRangeChange(startDate, endDate); + if (!props.isHistorical && endDate < get(props, 'detector.enabledTime')) { + setShowOutOfRangeCallOut(true); + } else { + setShowOutOfRangeCallOut(false); + } }; const showLoader = useDelayedLoader(props.isLoading); @@ -149,7 +157,7 @@ export const AnomaliesChart = React.memo((props: AnomaliesChartProps) => { }; const datePicker = () => ( - { }} isPaused={true} commonlyUsedRanges={DATE_PICKER_QUICK_OPTIONS} + updateButtonProps={{ + fill: false, + }} /> ); @@ -387,6 +398,7 @@ export const AnomaliesChart = React.memo((props: AnomaliesChartProps) => { isHCDetector={props.isHCDetector} isHistorical={props.isHistorical} onDatePickerRangeChange={handleDatePickerRangeChange} + openOutOfRangeCallOut={showOutOfRangeCallOut} /> )} diff --git a/public/pages/AnomalyCharts/containers/AnomalyDetailsChart.tsx b/public/pages/AnomalyCharts/containers/AnomalyDetailsChart.tsx index 5286e3dc..74661b67 100644 --- a/public/pages/AnomalyCharts/containers/AnomalyDetailsChart.tsx +++ b/public/pages/AnomalyCharts/containers/AnomalyDetailsChart.tsx @@ -18,6 +18,7 @@ import { niceTimeFormatter, Position, RectAnnotation, + RectAnnotationDatum, ScaleType, Settings, XYBrushArea, @@ -29,8 +30,10 @@ import { EuiLoadingChart, EuiStat, EuiButtonGroup, + EuiText, + EuiCallOut, } from '@elastic/eui'; -import { get } from 'lodash'; +import { forEach, get } from 'lodash'; import moment from 'moment'; import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -41,6 +44,7 @@ import { Monitor, MonitorAlert, AnomalyData, + Anomalies, } from '../../../models/interfaces'; import { AppState } from '../../../redux/reducers'; import { searchAlerts } from '../../../redux/reducers/alerting'; @@ -52,6 +56,8 @@ import { getHistoricalAggQuery, parseHistoricalAggregatedAnomalies, convertToEntityString, + flattenData, + generateAnomalyAnnotations, } from '../../utils/anomalyResultUtils'; import { AlertsFlyout } from '../components/AlertsFlyout/AlertsFlyout'; import { @@ -75,7 +81,10 @@ import { } from '../utils/constants'; import { HeatmapCell } from './AnomalyHeatmapChart'; import { ANOMALY_AGG, MIN_END_TIME, MAX_END_TIME } from '../../utils/constants'; -import { MAX_HISTORICAL_AGG_RESULTS } from '../../../utils/constants'; +import { + DATA_SOURCE_ID, + MAX_HISTORICAL_AGG_RESULTS, +} from '../../../utils/constants'; import { searchResults } from '../../../redux/reducers/anomalyResults'; import { DAY_IN_MILLI_SECS, @@ -83,6 +92,8 @@ import { DETECTOR_STATE, } from '../../../../server/utils/constants'; import { ENTITY_COLORS } from '../../DetectorResults/utils/constants'; +import { useLocation } from 'react-router-dom'; +import { getDataSourceFromURL } from '../../../pages/utils/helpers'; interface AnomalyDetailsChartProps { onDateRangeChange( @@ -106,11 +117,15 @@ interface AnomalyDetailsChartProps { isHistorical?: boolean; selectedHeatmapCell?: HeatmapCell; onDatePickerRangeChange?(startDate: number, endDate: number): void; + openOutOfRangeCallOut?: boolean; } export const AnomalyDetailsChart = React.memo( (props: AnomalyDetailsChartProps) => { const dispatch = useDispatch(); + const location = useLocation(); + const MDSQueryParams = getDataSourceFromURL(location); + const dataSourceId = MDSQueryParams.dataSourceId; const [showAlertsFlyout, setShowAlertsFlyout] = useState(false); const [alertAnnotations, setAlertAnnotations] = useState([]); const [isLoadingAlerts, setIsLoadingAlerts] = useState(false); @@ -155,7 +170,6 @@ export const AnomalyDetailsChart = React.memo( const taskId = get(props, 'detector.taskId'); const taskState = get(props, 'detector.taskState'); - const isRequestingAnomalyResults = useSelector( (state: AppState) => state.anomalyResults.requesting ); @@ -168,7 +182,9 @@ export const AnomalyDetailsChart = React.memo( zoomRange.endDate, taskId ); - dispatch(searchResults(anomalyDataRangeQuery, resultIndex, true)) + dispatch( + searchResults(anomalyDataRangeQuery, resultIndex, dataSourceId, true) + ) .then((response: any) => { // Only retrieve buckets that are in the anomaly results range. This is so // we don't show aggregate results for where there is no data at all @@ -187,7 +203,9 @@ export const AnomalyDetailsChart = React.memo( taskId, selectedAggId ); - dispatch(searchResults(historicalAggQuery, resultIndex, true)) + dispatch( + searchResults(historicalAggQuery, resultIndex, dataSourceId, true) + ) .then((response: any) => { const aggregatedAnomalies = parseHistoricalAggregatedAnomalies( response, @@ -223,7 +241,9 @@ export const AnomalyDetailsChart = React.memo( zoomRange.endDate, taskId ); - dispatch(searchResults(anomalyDataRangeQuery, resultIndex, true)) + dispatch( + searchResults(anomalyDataRangeQuery, resultIndex, dataSourceId, true) + ) .then((response: any) => { const dataStartDate = get( response, @@ -312,7 +332,7 @@ export const AnomalyDetailsChart = React.memo( try { setIsLoadingAlerts(true); const result = await dispatch( - searchAlerts(monitorId, startDateTime, endDateTime) + searchAlerts(monitorId, startDateTime, endDateTime, dataSourceId) ); setIsLoadingAlerts(false); setTotalAlerts(get(result, 'response.totalAlerts')); @@ -344,6 +364,72 @@ export const AnomalyDetailsChart = React.memo( zoomRange.endDate, ]); + const customAnomalyContributionTooltip = (details?: string) => { + const anomaly = details ? JSON.parse(details) : undefined; + const contributionData = get(anomaly, `contributions`, []); + + const featureData = get(anomaly, `features`, {}); + let featureAttributionList = [] as any[]; + if (Array.isArray(contributionData)) { + contributionData.map((contribution: any) => { + const featureName = get( + get(featureData, contribution.feature_id, ''), + 'name', + '' + ); + const dataString = contribution.data * 100 + '%'; + featureAttributionList.push( +
+ {featureName}: {dataString}
+
+ ); + }); + } else { + for (const [, value] of Object.entries(contributionData)) { + featureAttributionList.push( +
+ {value.name}: {value.attribution}
+
+ ); + } + } + return ( +
+ + Feature Contribution: + {anomaly ? ( +

+


+ {featureAttributionList} +

+ ) : null} +
+
+ ); + }; + + const generateContributionAnomalyAnnotations = ( + anomalies: AnomalyData[][] + ): any[][] => { + let annotations = [] as any[]; + anomalies.forEach((anomalyTimeSeries: AnomalyData[]) => { + annotations.push( + Array.isArray(anomalyTimeSeries) + ? anomalyTimeSeries + .filter((anomaly: AnomalyData) => anomaly.anomalyGrade > 0) + .map((anomaly: AnomalyData) => ({ + coordinates: { + x0: anomaly.startTime, + x1: anomaly.endTime + (anomaly.endTime - anomaly.startTime), + }, + details: `${JSON.stringify(anomaly)}`, + })) + : [] + ); + }); + return annotations; + }; + const isLoading = props.isLoading || isLoadingAlerts || isRequestingAnomalyResults; const isInitializingHistorical = taskState === DETECTOR_STATE.INIT; @@ -354,6 +440,20 @@ export const AnomalyDetailsChart = React.memo( return ( + {props.openOutOfRangeCallOut ? ( + + {`Your selected dates are not in the range from when the detector + last started streaming data + (${moment(get(props, 'detector.enabledTime')).format( + 'MM/DD/YYYY hh:mm A' + )}).`} + + ) : null} + )} + {alertAnnotations ? ( - )} - - handleSortByFieldChange(value)} diff --git a/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx b/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx index 2fce92f4..e939e64d 100644 --- a/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx +++ b/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx @@ -27,6 +27,7 @@ import { FEATURE_TYPE, FeatureAggregationData, EntityData, + UNITS, } from '../../../models/interfaces'; import { NoFeaturePrompt } from '../components/FeatureChart/NoFeaturePrompt'; import { focusOnFeatureAccordion } from '../../ConfigureModel/utils/helpers'; @@ -175,6 +176,9 @@ export const FeatureBreakDown = React.memo((props: FeatureBreakDownProps) => { detectorEnabledTime={props.detector.enabledTime} entityData={getEntityDataForChart(props.anomalyAndFeatureResults)} isHCDetector={props.isHCDetector} + windowDelay={get(props, `detector.windowDelay.period`, { + period: { interval: 0, unit: UNITS.MINUTES }, + })} /> {index + 1 === get(props, 'detector.featureAttributes', []).length ? null : ( diff --git a/public/pages/AnomalyCharts/containers/__tests__/AnomaliesChart.test.tsx b/public/pages/AnomalyCharts/containers/__tests__/AnomaliesChart.test.tsx index aebc1ea2..02f63fcd 100644 --- a/public/pages/AnomalyCharts/containers/__tests__/AnomaliesChart.test.tsx +++ b/public/pages/AnomalyCharts/containers/__tests__/AnomaliesChart.test.tsx @@ -22,6 +22,8 @@ import { FAKE_ANOMALIES_RESULT, FAKE_DATE_RANGE, } from '../../../../pages/utils/__tests__/constants'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; const DEFAULT_PROPS = { onDateRangeChange: jest.fn(), @@ -47,17 +49,32 @@ const DEFAULT_PROPS = { entityAnomalySummaries: [], } as AnomaliesChartProps; +const history = createMemoryHistory(); + const renderDataFilter = (chartProps: AnomaliesChartProps) => ({ ...render( - + + + ), }); describe(' spec', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + href: 'http://test.com', + pathname: '/', + search: '', + hash: '', + }, + writable: true + }); + }); test('renders the component for sample / preview', () => { console.error = jest.fn(); const { getByText, getAllByText } = renderDataFilter({ diff --git a/public/pages/AnomalyCharts/containers/__tests__/AnomalyDetailsChart.test.tsx b/public/pages/AnomalyCharts/containers/__tests__/AnomalyDetailsChart.test.tsx index fbf1e66d..5bed2db9 100644 --- a/public/pages/AnomalyCharts/containers/__tests__/AnomalyDetailsChart.test.tsx +++ b/public/pages/AnomalyCharts/containers/__tests__/AnomalyDetailsChart.test.tsx @@ -22,6 +22,18 @@ import { } from '../../../../pages/utils/__tests__/constants'; import { INITIAL_ANOMALY_SUMMARY } from '../../utils/constants'; import { getRandomDetector } from '../../../../redux/reducers/__tests__/utils'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; + +jest.mock('../../../../services', () => ({ + ...jest.requireActual('../../../../services'), + + getDataSourceEnabled: () => ({ + enabled: false + }) +})); + +const history = createMemoryHistory(); const renderAnomalyOccurenceChart = ( isNotSample: boolean, @@ -30,27 +42,41 @@ const renderAnomalyOccurenceChart = ( ...render( - + + + ), }); describe(' spec', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + href: 'http://test.com', + pathname: '/', + search: '', + hash: '', + }, + writable: true + }); + }); + test('renders the component in case of Sample Anomaly', () => { console.error = jest.fn(); const { getByText } = renderAnomalyOccurenceChart(false, false); diff --git a/public/pages/AnomalyCharts/containers/__tests__/AnomalyOccurrenceChart.test.tsx b/public/pages/AnomalyCharts/containers/__tests__/AnomalyOccurrenceChart.test.tsx index 802d7d6c..94b4ffd4 100644 --- a/public/pages/AnomalyCharts/containers/__tests__/AnomalyOccurrenceChart.test.tsx +++ b/public/pages/AnomalyCharts/containers/__tests__/AnomalyOccurrenceChart.test.tsx @@ -16,6 +16,8 @@ import { mockedStore } from '../../../../redux/utils/testUtils'; import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices'; import { coreServicesMock } from '../../../../../test/mocks'; import { AnomalyOccurrenceChart } from '../AnomalyOccurrenceChart'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; import { FAKE_ANOMALY_DATA, FAKE_DATE_RANGE, @@ -23,6 +25,16 @@ import { import { INITIAL_ANOMALY_SUMMARY } from '../../utils/constants'; import { getRandomDetector } from '../../../../redux/reducers/__tests__/utils'; +jest.mock('../../../../services', () => ({ + ...jest.requireActual('../../../../services'), + + getDataSourceEnabled: () => ({ + enabled: false + }) +})); + +const history = createMemoryHistory(); + const renderAnomalyOccurenceChart = ( isNotSample: boolean, isHCDetector: boolean @@ -30,28 +42,41 @@ const renderAnomalyOccurenceChart = ( ...render( - + + + ), }); describe(' spec', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + href: 'http://test.com', + pathname: '/', + search: '', + hash: '', + }, + writable: true + }); + }); test('renders the component in case of Sample Anomaly', () => { console.error = jest.fn(); const { getByText } = renderAnomalyOccurenceChart(false, false); diff --git a/public/pages/AnomalyCharts/containers/__tests__/__snapshots__/AnomalyHeatmapChart.test.tsx.snap b/public/pages/AnomalyCharts/containers/__tests__/__snapshots__/AnomalyHeatmapChart.test.tsx.snap index bad12782..fd6613bb 100644 --- a/public/pages/AnomalyCharts/containers/__tests__/__snapshots__/AnomalyHeatmapChart.test.tsx.snap +++ b/public/pages/AnomalyCharts/containers/__tests__/__snapshots__/AnomalyHeatmapChart.test.tsx.snap @@ -24,7 +24,11 @@ exports[` spec AnomalyHeatmapChart with Sample anomaly da viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" - /> + > + + @@ -85,17 +89,17 @@ exports[` spec AnomalyHeatmapChart with Sample anomaly da @@ -434,14 +458,14 @@ exports[` spec AnomalyHeatmapChart with anomaly summaries xmlns="http://www.w3.org/2000/svg" > - No anomalies found in the specified date range. + Choose a filled rectangle in the heat map for a more detailed view of anomalies within that entity.
@@ -488,17 +512,17 @@ exports[` spec AnomalyHeatmapChart with anomaly summaries