diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index cd025f06..48711d5f 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
\ No newline at end of file
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/remote-integ-tests-workflow.yml b/.github/workflows/remote-integ-tests-workflow.yml
index bf05f8ec..55991557 100644
--- a/.github/workflows/remote-integ-tests-workflow.yml
+++ b/.github/workflows/remote-integ-tests-workflow.yml
@@ -12,83 +12,124 @@ on:
env:
OPENSEARCH_DASHBOARDS_VERSION: 'main'
OPENSEARCH_VERSION: '3.0.0-SNAPSHOT'
+ OPENSEARCH_DASHBOARDS_FTREPO_VERSION: 'main'
+ ANOMALY_DETECTION_PLUGIN_VERSION: 'main'
jobs:
test-without-security:
name: Run integ tests without security
strategy:
matrix:
- os: [ubuntu-latest]
+ os: [ubuntu-latest, windows-latest]
java: [11]
+ include:
+ - os: windows-latest
+ cypress_cache_folder: ~/AppData/Local/Cypress/Cache
+ - os: ubuntu-latest
+ cypress_cache_folder: ~/.cache/Cypress
runs-on: ${{ matrix.os }}
steps:
+ - name: Set up Java 11
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '11'
+
+ - name: Enable longer filenames
+ if: ${{ matrix.os == 'windows-latest' }}
+ run: git config --system core.longpaths true
+
- 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.ANOMALY_DETECTION_PLUGIN_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'
+ shell: bash
+
- 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: 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
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
with:
- node-version: ${{ steps.versions_step.outputs.node_version }}
+ node-version-file: './OpenSearch-Dashboards/.nvmrc'
registry-url: 'https://registry.npmjs.org'
- - name: Install correct yarn version for OpenSearch Dashboards
+ - name: Install Yarn
+ # Need to use bash to avoid having a windows/linux specific step
+ shell: bash
run: |
- npm uninstall -g yarn
- echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}"
- npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }}
+ 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: Bootstrap the plugin
run: |
cd OpenSearch-Dashboards/plugins/anomaly-detection-dashboards-plugin
yarn osd bootstrap
+
- name: Run OpenSearch Dashboards server
run: |
cd OpenSearch-Dashboards
yarn start --no-base-path --no-watch &
- sleep 300
+ shell: bash
+
+ # Window is slow so wait longer
+ - name: Sleep until OSD server starts - windows
+ if: ${{ matrix.os == 'windows-latest' }}
+ run: Start-Sleep -s 400
+ shell: powershell
+
+ - name: Sleep until OSD server starts - non-windows
+ if: ${{ matrix.os != 'windows-latest' }}
+ run: sleep 300
+ shell: bash
+
- name: Checkout opensearch-dashboards-functional-test
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_FTREPO_VERSION }}
+
- 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
with:
- path: ~/.cache/Cypress
+ path: ${{ matrix.cypress_cache_folder }}
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
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
+ env:
+ CYPRESS_CACHE_FOLDER: ${{ matrix.cypress_cache_folder }}
diff --git a/.github/workflows/unit-tests-workflow.yml b/.github/workflows/unit-tests-workflow.yml
index 838d9ae2..abe1743c 100644
--- a/.github/workflows/unit-tests-workflow.yml
+++ b/.github/workflows/unit-tests-workflow.yml
@@ -26,21 +26,20 @@ jobs:
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
+ - name: Setup Node
+ uses: actions/setup-node@v3
with:
- node-version: ${{ steps.versions_step.outputs.node_version }}
+ node-version-file: './OpenSearch-Dashboards/.nvmrc'
registry-url: 'https://registry.npmjs.org'
- - name: Install correct yarn version for OpenSearch Dashboards
+ - name: Install Yarn
+ # Need to use bash to avoid having a windows/linux specific step
+ shell: bash
run: |
- npm uninstall -g yarn
- echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}"
- npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }}
+ 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:
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index ed2946d4..9b4551dd 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -1,13 +1,22 @@
-# 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 |
+
+## 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/global-setup.js b/global-setup.js
index 3c578000..67ca0db6 100644
--- a/global-setup.js
+++ b/global-setup.js
@@ -1,3 +1,3 @@
export default () => {
- process.env.TZ = 'UTC';
- }
\ No newline at end of file
+ process.env.TZ = 'UTC';
+};
diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json
index 6868b4ed..21cd2fbb 100644
--- a/opensearch_dashboards.json
+++ b/opensearch_dashboards.json
@@ -3,8 +3,19 @@
"version": "3.0.0.0",
"opensearchDashboardsVersion": "3.0.0",
"configPath": ["anomaly_detection_dashboards"],
- "requiredPlugins": ["navigation"],
- "optionalPlugins": [],
+ "requiredPlugins": [
+ "opensearchDashboardsUtils",
+ "expressions",
+ "data",
+ "visAugmenter",
+ "uiActions",
+ "dashboard",
+ "embeddable",
+ "opensearchDashboardsReact",
+ "savedObjects",
+ "visAugmenter",
+ "opensearchDashboardsUtils"
+ ],
"server": true,
"ui": true
}
diff --git a/package.json b/package.json
index d997ca9d..a6875522 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,9 @@
"**/ansi-regex": "^5.0.1",
"**/glob-parent": "^6.0.0",
"**/loader-utils": "^2.0.4",
- "**/terser": "^4.8.1"
+ "**/terser": "^4.8.1",
+ "decode-uri-component": "^0.2.1",
+ "json5": "^2.2.3",
+ "@sideway/formula": "^3.0.1"
}
}
diff --git a/public/action/ad_dashboard_action.tsx b/public/action/ad_dashboard_action.tsx
new file mode 100644
index 00000000..2cde952b
--- /dev/null
+++ b/public/action/ad_dashboard_action.tsx
@@ -0,0 +1,78 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { IEmbeddable } from '../../../../src/plugins/dashboard/public/embeddable_plugin';
+import {
+ DASHBOARD_CONTAINER_TYPE,
+ DashboardContainer,
+} from '../../../../src/plugins/dashboard/public';
+import {
+ IncompatibleActionError,
+ createAction,
+ Action,
+} from '../../../../src/plugins/ui_actions/public';
+import { isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public';
+import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
+import { VisualizeEmbeddable } from '../../../../src/plugins/visualizations/public';
+import { isEligibleForVisLayers } from '../../../../src/plugins/vis_augmenter/public';
+import { getUISettings } from '../services';
+
+export const ACTION_AD = 'ad';
+
+function isDashboard(
+ embeddable: IEmbeddable
+): embeddable is DashboardContainer {
+ return embeddable.type === DASHBOARD_CONTAINER_TYPE;
+}
+
+export interface ActionContext {
+ embeddable: IEmbeddable;
+}
+
+export interface CreateOptions {
+ grouping: Action['grouping'];
+ title: string;
+ icon: EuiIconType;
+ id: string;
+ order: number;
+ onClick: Function;
+}
+
+export const createADAction = ({
+ grouping,
+ title,
+ icon,
+ id,
+ order,
+ onClick,
+}: CreateOptions) =>
+ createAction({
+ id,
+ order,
+ getDisplayName: ({ embeddable }: ActionContext) => {
+ if (!embeddable.parent || !isDashboard(embeddable.parent)) {
+ throw new IncompatibleActionError();
+ }
+ return title;
+ },
+ getIconType: () => icon,
+ type: ACTION_AD,
+ grouping,
+ isCompatible: async ({ embeddable }: ActionContext) => {
+ const vis = (embeddable as VisualizeEmbeddable).vis;
+ return Boolean(
+ embeddable.parent &&
+ embeddable.getInput()?.viewMode === 'view' &&
+ isDashboard(embeddable.parent) &&
+ vis !== undefined &&
+ isEligibleForVisLayers(vis, getUISettings())
+ );
+ },
+ execute: async ({ embeddable }: ActionContext) => {
+ if (!isReferenceOrValueEmbeddable(embeddable)) {
+ throw new IncompatibleActionError();
+ }
+ onClick({ embeddable });
+ },
+ });
diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx
new file mode 100644
index 00000000..5ab72b2d
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import React, { useState } from 'react';
+import { get } from 'lodash';
+import AssociatedDetectors from '../AssociatedDetectors/containers/AssociatedDetectors';
+import { getEmbeddable } from '../../../../public/services';
+import AddAnomalyDetector from '../CreateAnomalyDetector';
+import { FLYOUT_MODES } from './constants';
+
+const AnywhereParentFlyout = ({ startingFlyout, ...props }) => {
+ const embeddable = getEmbeddable().getEmbeddableFactory;
+ const indices: { label: string }[] = [
+ { label: get(embeddable, 'vis.data.indexPattern.title', '') },
+ ];
+
+ const [mode, setMode] = useState(startingFlyout);
+ const [selectedDetector, setSelectedDetector] = useState(undefined);
+
+ const AnywhereFlyout = {
+ [FLYOUT_MODES.create]: AddAnomalyDetector,
+ [FLYOUT_MODES.associated]: AssociatedDetectors,
+ [FLYOUT_MODES.existing]: AddAnomalyDetector,
+ }[mode];
+
+ return (
+
+ );
+};
+
+export default AnywhereParentFlyout;
diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts
new file mode 100644
index 00000000..fa470962
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+//created: Flyout for creating a new anomaly detector from a visualization
+//associated: Flyout for listing all the associated detectors to the given visualization
+//existing: Flyout for associating existing detectors with the current visualizations
+export enum FLYOUT_MODES {
+ create = 'create',
+ associated = 'associated',
+ existing = 'existing',
+}
diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx
new file mode 100644
index 00000000..591d4b6d
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import AnywhereParentFlyout from './AnywhereParentFlyout';
+
+export default AnywhereParentFlyout;
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx
new file mode 100644
index 00000000..98d5d155
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx
@@ -0,0 +1,78 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiText,
+ EuiOverlayMask,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiModal,
+ EuiModalHeader,
+ EuiModalFooter,
+ EuiModalBody,
+ EuiModalHeaderTitle,
+} from '@elastic/eui';
+import { DetectorListItem } from '../../../../../models/interfaces';
+import { EuiSpacer } from '@elastic/eui';
+
+interface ConfirmUnlinkDetectorModalProps {
+ detector: DetectorListItem;
+ onUnlinkDetector(): void;
+ onHide(): void;
+ onConfirm(): void;
+ isListLoading: boolean;
+}
+
+export const ConfirmUnlinkDetectorModal = (
+ props: ConfirmUnlinkDetectorModalProps
+) => {
+ const [isModalLoading, setIsModalLoading] = useState(false);
+ const isLoading = isModalLoading || props.isListLoading;
+ return (
+
+
+
+ {'Remove association?'}
+
+
+
+ Removing association unlinks {props.detector.name} detector from the
+ visualization but does not delete it. The detector association can
+ be restored.
+
+
+
+
+ {isLoading ? null : (
+
+ Cancel
+
+ )}
+ {
+ setIsModalLoading(true);
+ props.onUnlinkDetector();
+ props.onConfirm();
+ }}
+ >
+ {'Remove association'}
+
+
+
+
+ );
+};
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx
new file mode 100644
index 00000000..d005e087
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx
@@ -0,0 +1,32 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
+import React from 'react';
+
+const FILTER_TEXT = 'There are no detectors matching your search';
+
+interface EmptyDetectorProps {
+ isFilterApplied: boolean;
+ embeddableTitle: string;
+}
+
+export const EmptyAssociatedDetectorMessage = (props: EmptyDetectorProps) => (
+ No anomaly detectors to display}
+ titleSize="s"
+ data-test-subj="emptyAssociatedDetectorFlyoutMessage"
+ style={{ maxWidth: '45em' }}
+ body={
+
+
+ {props.isFilterApplied
+ ? FILTER_TEXT
+ : `There are no anomaly detectors associated with ${props.embeddableTitle} visualization.`}
+
+
+ }
+ />
+);
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx
new file mode 100644
index 00000000..ed055dec
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/ConfirmUnlinkDetectorModal.test.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { findAllByTestId, render, waitFor } from '@testing-library/react';
+import { ConfirmUnlinkDetectorModal } from '../index';
+import { getRandomDetector } from '../../../../../../public/redux/reducers/__tests__/utils';
+import { DetectorListItem } from '../../../../../../public/models/interfaces';
+import userEvent from '@testing-library/user-event';
+
+describe('ConfirmUnlinkDetectorModal spec', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const testDetectors = [
+ {
+ id: 'detectorId1',
+ name: 'test-detector-1',
+ },
+ {
+ id: 'detectorId2',
+ name: 'test-detector-2',
+ },
+ ] as DetectorListItem[];
+
+ const ConfirmUnlinkDetectorModalProps = {
+ detector: testDetectors[0],
+ onHide: jest.fn(),
+ onConfirm: jest.fn(),
+ onUnlinkDetector: jest.fn(),
+ isListLoading: false,
+ };
+
+ test('renders the component correctly', () => {
+ const { container, getByText } = render(
+
+ );
+ getByText('Remove association?');
+ getByText(
+ 'Removing association unlinks test-detector-1 detector from the visualization but does not delete it. The detector association can be restored.'
+ );
+ });
+ test('should call onConfirm() when closing', async () => {
+ const { container, getByText, getByTestId } = render(
+
+ );
+ getByText('Remove association?');
+ userEvent.click(getByTestId('confirmUnlinkButton'));
+ expect(ConfirmUnlinkDetectorModalProps.onConfirm).toHaveBeenCalled();
+ });
+ test('should call onConfirm() when closing', async () => {
+ const { container, getByText, getByTestId } = render(
+
+ );
+ getByText('Remove association?');
+ userEvent.click(getByTestId('confirmUnlinkButton'));
+ expect(ConfirmUnlinkDetectorModalProps.onConfirm).toHaveBeenCalled();
+ });
+ test('should call onHide() when closing', async () => {
+ const { getByTestId } = render(
+
+ );
+ userEvent.click(getByTestId('cancelUnlinkButton'));
+ expect(ConfirmUnlinkDetectorModalProps.onHide).toHaveBeenCalled();
+ });
+});
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx
new file mode 100644
index 00000000..21b684be
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/EmptyAssociatedDetectorMessage.test.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { findAllByTestId, render, waitFor } from '@testing-library/react';
+import { EmptyAssociatedDetectorMessage } from '../index';
+import { getRandomDetector } from '../../../../../../public/redux/reducers/__tests__/utils';
+import { DetectorListItem } from '../../../../../../public/models/interfaces';
+import userEvent from '@testing-library/user-event';
+
+describe('ConfirmUnlinkDetectorModal spec', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('renders the component with filter applied', () => {
+ const { container, getByText } = render(
+
+ );
+ getByText('There are no detectors matching your search');
+ expect(container).toMatchSnapshot();
+ });
+ test('renders the component with filter applied', () => {
+ const { container, getByText } = render(
+
+ );
+ getByText(
+ 'There are no anomaly detectors associated with test-title visualization.'
+ );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap
new file mode 100644
index 00000000..15c1a6c3
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/__tests__/__snapshots__/EmptyAssociatedDetectorMessage.test.tsx.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConfirmUnlinkDetectorModal spec renders the component with filter applied 1`] = `
+
+
+
+ No anomaly detectors to display
+
+
+
+
+
+
+ There are no detectors matching your search
+
+
+
+
+
+
+`;
+
+exports[`ConfirmUnlinkDetectorModal spec renders the component with filter applied 2`] = `
+
+
+
+ No anomaly detectors to display
+
+
+
+
+
+
+ There are no anomaly detectors associated with test-title visualization.
+
+
+
+
+
+
+`;
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts
new file mode 100644
index 00000000..92d619eb
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { ConfirmUnlinkDetectorModal } from './ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal';
+export { EmptyAssociatedDetectorMessage } from './EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage';
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx
new file mode 100644
index 00000000..1cbdbc82
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/AssociatedDetectors.tsx
@@ -0,0 +1,366 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useMemo, useEffect, useState } from 'react';
+import {
+ EuiFlyoutHeader,
+ EuiTitle,
+ EuiSpacer,
+ EuiInMemoryTable,
+ EuiFlyoutBody,
+ EuiButton,
+ EuiFlyout,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiCallOut,
+} from '@elastic/eui';
+import { get, isEmpty } from 'lodash';
+import '../styles.scss';
+import { getColumns } from '../utils/helpers';
+import { useDispatch, useSelector } from 'react-redux';
+import { AppState } from '../../../../redux/reducers';
+import { DetectorListItem } from '../../../../models/interfaces';
+import {
+ getSavedFeatureAnywhereLoader,
+ getNotifications,
+ getUISettings,
+} from '../../../../services';
+import {
+ GET_ALL_DETECTORS_QUERY_PARAMS,
+ SINGLE_DETECTOR_NOT_FOUND_MSG,
+} from '../../../../pages/utils/constants';
+import { getDetectorList } from '../../../../redux/reducers/ad';
+import {
+ prettifyErrorMessage,
+ NO_PERMISSIONS_KEY_WORD,
+} from '../../../../../server/utils/helpers';
+import {
+ EmptyAssociatedDetectorMessage,
+ ConfirmUnlinkDetectorModal,
+} from '../components';
+import {
+ ISavedAugmentVis,
+ SavedAugmentVisLoader,
+ getAugmentVisSavedObjs,
+} from '../../../../../../../src/plugins/vis_augmenter/public';
+import { ASSOCIATED_DETECTOR_ACTION } from '../utils/constants';
+import { PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING } from '../../../../../public/expressions/constants';
+
+interface ConfirmModalState {
+ isOpen: boolean;
+ action: ASSOCIATED_DETECTOR_ACTION;
+ isListLoading: boolean;
+ isRequestingToClose: boolean;
+ affectedDetector: DetectorListItem;
+}
+
+function AssociatedDetectors({ embeddable, closeFlyout, setMode }) {
+ const dispatch = useDispatch();
+ const allDetectors = useSelector((state: AppState) => state.ad.detectorList);
+ const isRequestingFromES = useSelector(
+ (state: AppState) => state.ad.requesting
+ );
+ const [isLoadingFinalDetectors, setIsLoadingFinalDetectors] =
+ useState(true);
+ const isLoading = isRequestingFromES || isLoadingFinalDetectors;
+ const errorGettingDetectors = useSelector(
+ (state: AppState) => state.ad.errorMessage
+ );
+ const embeddableTitle = embeddable.getTitle();
+ const [selectedDetectors, setSelectedDetectors] = useState(
+ [] as DetectorListItem[]
+ );
+
+ const [detectorToUnlink, setDetectorToUnlink] = useState(
+ {} as DetectorListItem
+ );
+ const [associationLimitReached, setAssociationLimitReached] =
+ useState(false);
+ const [confirmModalState, setConfirmModalState] = useState(
+ {
+ isOpen: false,
+ //@ts-ignore
+ action: null,
+ isListLoading: false,
+ isRequestingToClose: false,
+ affectedDetector: {} as DetectorListItem,
+ }
+ );
+
+ // Establish savedObjectLoader for all operations on vis_augment saved objects
+ const savedObjectLoader: SavedAugmentVisLoader =
+ getSavedFeatureAnywhereLoader();
+
+ const uiSettings = getUISettings();
+ const notifications = getNotifications();
+ let maxAssociatedCount = uiSettings.get(
+ PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING
+ );
+
+ useEffect(() => {
+ if (
+ errorGettingDetectors &&
+ !errorGettingDetectors.includes(SINGLE_DETECTOR_NOT_FOUND_MSG)
+ ) {
+ console.error(errorGettingDetectors);
+ notifications.toasts.addDanger(
+ typeof errorGettingDetectors === 'string' &&
+ errorGettingDetectors.includes(NO_PERMISSIONS_KEY_WORD)
+ ? prettifyErrorMessage(errorGettingDetectors)
+ : 'Unable to get all detectors'
+ );
+ setIsLoadingFinalDetectors(false);
+ }
+ }, [errorGettingDetectors]);
+
+ // Update modal state if user decides to close modal
+ useEffect(() => {
+ if (confirmModalState.isRequestingToClose) {
+ if (isLoading) {
+ setConfirmModalState({
+ ...confirmModalState,
+ isListLoading: true,
+ });
+ } else {
+ setConfirmModalState({
+ ...confirmModalState,
+ isOpen: false,
+ isListLoading: false,
+ isRequestingToClose: false,
+ });
+ }
+ }
+ }, [confirmModalState.isRequestingToClose, isLoading]);
+
+ useEffect(() => {
+ getDetectors();
+ }, []);
+
+ // Handles all changes in the assoicated detectors such as unlinking or new detectors associated
+ useEffect(() => {
+ // Gets all augmented saved objects that are associated to the given visualization
+ getAugmentVisSavedObjs(embeddable.vis.id, savedObjectLoader, uiSettings)
+ .then((savedAugmentObjectsArr: any) => {
+ if (savedAugmentObjectsArr != undefined) {
+ if (maxAssociatedCount <= savedAugmentObjectsArr.length) {
+ setAssociationLimitReached(true);
+ } else {
+ setAssociationLimitReached(false);
+ }
+ const curSelectedDetectors = getAssociatedDetectors(
+ Object.values(allDetectors),
+ savedAugmentObjectsArr
+ );
+ setSelectedDetectors(curSelectedDetectors);
+ maxAssociatedCount = uiSettings.get(
+ PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING
+ );
+ setIsLoadingFinalDetectors(false);
+ }
+ })
+ .catch((error) => {
+ notifications.toasts.addDanger(
+ prettifyErrorMessage(`Unable to fetch associated detectors: ${error}`)
+ );
+ });
+ }, [allDetectors]);
+
+ // cross checks all the detectors that exist with all the savedAugment Objects to only display ones
+ // that are associated to the current visualization
+ const getAssociatedDetectors = (
+ detectors: DetectorListItem[],
+ savedAugmentForThisVisualization: ISavedAugmentVis[]
+ ) => {
+ // Map all detector IDs for all the found augmented vis objects
+ const savedAugmentDetectorsSet = new Set(
+ savedAugmentForThisVisualization.map((savedObject) =>
+ get(savedObject, 'pluginResource.id', '')
+ )
+ );
+
+ // filter out any detectors that aren't on the set of detectors IDs from the augmented vis objects.
+ const detectorsToDisplay = detectors.filter((detector) =>
+ savedAugmentDetectorsSet.has(detector.id)
+ );
+ return detectorsToDisplay;
+ };
+
+ const onUnlinkDetector = async () => {
+ setIsLoadingFinalDetectors(true);
+ // Gets all augmented saved objects that are associated to the given visualization
+ await getAugmentVisSavedObjs(
+ embeddable.vis.id,
+ savedObjectLoader,
+ uiSettings
+ ).then(async (savedAugmentForThisVisualization: any) => {
+ if (savedAugmentForThisVisualization != undefined) {
+ // find saved augment object matching detector we want to unlink
+ // There should only be one detector and vis pairing
+ const savedAugmentToUnlink = savedAugmentForThisVisualization.find(
+ (savedObject) =>
+ get(savedObject, 'pluginResource.id', '') === detectorToUnlink.id
+ );
+ await savedObjectLoader
+ .delete(get(savedAugmentToUnlink, 'id', ''))
+ .then(async (resp: any) => {
+ notifications.toasts.addSuccess({
+ title: `Association removed between the ${detectorToUnlink.name}
+ and the ${embeddableTitle} visualization`,
+ text: "The detector's anomalies do not appear on the visualization. Refresh your dashboard to update the visualization",
+ });
+ })
+ .catch((error) => {
+ notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ `Error unlinking selected detector: ${error}`
+ )
+ );
+ })
+ .finally(() => {
+ getDetectors();
+ });
+ }
+ });
+ };
+
+ const handleHideModal = () => {
+ setConfirmModalState({
+ ...confirmModalState,
+ isOpen: false,
+ });
+ };
+
+ const handleConfirmModal = () => {
+ setConfirmModalState({
+ ...confirmModalState,
+ isRequestingToClose: true,
+ });
+ };
+
+ const getDetectors = async () => {
+ dispatch(getDetectorList(GET_ALL_DETECTORS_QUERY_PARAMS));
+ };
+
+ const handleUnlinkDetectorAction = (detector: DetectorListItem) => {
+ setDetectorToUnlink(detector);
+ setConfirmModalState({
+ isOpen: true,
+ action: ASSOCIATED_DETECTOR_ACTION.UNLINK,
+ isListLoading: false,
+ isRequestingToClose: false,
+ affectedDetector: detector,
+ });
+ };
+
+ const columns = useMemo(
+ () => getColumns({ handleUnlinkDetectorAction }),
+ [handleUnlinkDetectorAction]
+ );
+
+ const renderEmptyMessage = () => {
+ if (isLoading) {
+ return 'Loading detectors...';
+ } else if (!isEmpty(selectedDetectors)) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ };
+
+ const tableProps = {
+ items: selectedDetectors,
+ columns,
+ search: {
+ box: {
+ disabled: selectedDetectors.length === 0,
+ incremental: true,
+ schema: true,
+ },
+ },
+ hasActions: true,
+ pagination: true,
+ sorting: true,
+ message: renderEmptyMessage(),
+ };
+ return (
+
+
+
+
+
+ Associated anomaly detectors
+
+
+
+ {associationLimitReached ? (
+
+ Adding more objects may affect cluster performance and prevent
+ dashboards from rendering properly. Remove associations before
+ adding new ones.
+
+ ) : null}
+
+ {confirmModalState.isOpen ? (
+
+ ) : null}
+
+
+
+ Visualization: {embeddableTitle}
+
+
+
+
+ {
+ setMode('existing');
+ }}
+ >
+ Associate a detector
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default AssociatedDetectors;
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx
new file mode 100644
index 00000000..7ee94119
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx
@@ -0,0 +1,389 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import AssociatedDetectors from '../AssociatedDetectors';
+import { createMockVisEmbeddable } from '../../../../../../../../src/plugins/vis_augmenter/public/mocks';
+import { FLYOUT_MODES } from '../../../../../../public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants';
+import { CoreServicesContext } from '../../../../../../public/components/CoreServices/CoreServices';
+import { coreServicesMock, httpClientMock } from '../../../../../../test/mocks';
+import {
+ HashRouter as Router,
+ RouteComponentProps,
+ Route,
+ Switch,
+} from 'react-router-dom';
+import { Provider } from 'react-redux';
+import configureStore from '../../../../../../public/redux/configureStore';
+import { VisualizeEmbeddable } from '../../../../../../../../src/plugins/visualizations/public';
+import {
+ setSavedFeatureAnywhereLoader,
+ setUISettings,
+} from '../../../../../services';
+import {
+ generateAugmentVisSavedObject,
+ VisLayerExpressionFn,
+ VisLayerTypes,
+ createSavedAugmentVisLoader,
+ setUISettings as setVisAugUISettings,
+ getMockAugmentVisSavedObjectClient,
+ SavedObjectLoaderAugmentVis,
+} from '../../../../../../../../src/plugins/vis_augmenter/public';
+import { getAugmentVisSavedObjs } from '../../../../../../../../src/plugins/vis_augmenter/public/utils';
+import { uiSettingsServiceMock } from '../../../../../../../../src/core/public/mocks';
+import {
+ PLUGIN_AUGMENTATION_ENABLE_SETTING,
+ PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING,
+} from '../../../../../../../../src/plugins/vis_augmenter/common';
+import userEvent from '@testing-library/user-event';
+const fn = {
+ type: VisLayerTypes.PointInTimeEvents,
+ name: 'test-fn',
+ args: {
+ testArg: 'test-value',
+ },
+} as VisLayerExpressionFn;
+const originPlugin = 'test-plugin';
+
+const uiSettingsMock = uiSettingsServiceMock.createStartContract();
+setUISettings(uiSettingsMock);
+setVisAugUISettings(uiSettingsMock);
+const setUIAugSettings = (isEnabled = true, maxCount = 10) => {
+ uiSettingsMock.get.mockImplementation((key: string) => {
+ if (key === PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING) return maxCount;
+ else if (key === PLUGIN_AUGMENTATION_ENABLE_SETTING) return isEnabled;
+ else return false;
+ });
+};
+
+setUIAugSettings();
+
+jest.mock('../../../../../services', () => ({
+ ...jest.requireActual('../../../../../services'),
+
+ getUISettings: () => {
+ return {
+ get: (config: string) => {
+ switch (config) {
+ case 'visualization:enablePluginAugmentation':
+ return true;
+ case 'visualization:enablePluginAugmentation.maxPluginObjects':
+ return 10;
+ default:
+ throw new Error(
+ `Accessing ${config} is not supported in the mock.`
+ );
+ }
+ },
+ };
+ },
+ getNotifications: () => {
+ return {
+ toasts: {
+ addDanger: jest.fn().mockName('addDanger'),
+ addSuccess: jest.fn().mockName('addSuccess'),
+ },
+ };
+ },
+}));
+
+jest.mock(
+ '../../../../../../../../src/plugins/vis_augmenter/public/utils',
+ () => ({
+ getAugmentVisSavedObjs: jest.fn(),
+ })
+);
+const visEmbeddable = createMockVisEmbeddable(
+ 'test-saved-obj-id',
+ 'test-title',
+ false
+);
+
+const renderWithRouter = (visEmbeddable: VisualizeEmbeddable) => ({
+ ...render(
+
+
+
+ (
+
+
+
+ )}
+ />
+
+
+
+ ),
+});
+describe('AssociatedDetectors spec', () => {
+ let augmentVisLoader: SavedObjectLoaderAugmentVis;
+ let mockDeleteFn: jest.Mock;
+ let detectorsToAssociate = new Array(2).fill(null).map((_, index) => {
+ return {
+ id: `detector_id_${index}`,
+ name: `detector_name_${index}`,
+ indices: [`index_${index}`],
+ totalAnomalies: 5,
+ lastActiveAnomaly: Date.now() + index,
+ };
+ });
+ //change one of the two detectors to have an ID not matching the ID in saved object
+ detectorsToAssociate[1].id = '5';
+
+ const savedObjects = new Array(2).fill(null).map((_, index) => {
+ const pluginResource = {
+ type: 'test-plugin',
+ id: `detector_id_${index}`,
+ };
+ return generateAugmentVisSavedObject(
+ `valid-obj-id-${index}`,
+ fn,
+ `vis-id-${index}`,
+ originPlugin,
+ pluginResource
+ );
+ });
+ beforeEach(() => {
+ mockDeleteFn = jest.fn().mockResolvedValue('someValue');
+ augmentVisLoader = createSavedAugmentVisLoader({
+ savedObjectsClient: {
+ ...getMockAugmentVisSavedObjectClient(savedObjects),
+ delete: mockDeleteFn,
+ },
+ } as any) as SavedObjectLoaderAugmentVis;
+ setSavedFeatureAnywhereLoader(augmentVisLoader);
+ });
+ describe('Renders loading component', () => {
+ test('renders the detector is loading', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: { detectorList: [], totalDetectors: 0 },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+ const { getByText } = renderWithRouter(visEmbeddable);
+ getByText('Loading detectors...');
+ getByText('Real-time state');
+ getByText('Associate a detector');
+ });
+ });
+
+ describe('renders either one or zero detectors', () => {
+ test('renders one associated detector', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: {
+ detectorList: detectorsToAssociate,
+ totalDetectors: detectorsToAssociate.length,
+ },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+ const { getByText, queryByText } = renderWithRouter(visEmbeddable);
+ getByText('Loading detectors...');
+ await waitFor(() => getByText('detector_name_0'));
+ getByText('5');
+ expect(queryByText('detector_name_1')).toBeNull();
+ }, 80000);
+ test('renders no associated detectors', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: {
+ detectorList: [detectorsToAssociate[1]],
+ totalDetectors: 1,
+ },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+ const { getByText, findByText } = renderWithRouter(visEmbeddable);
+ getByText('Loading detectors...');
+ await waitFor(() =>
+ findByText(
+ 'There are no anomaly detectors associated with test-title visualization.',
+ undefined,
+ { timeout: 100000 }
+ )
+ );
+ }, 150000);
+ });
+
+ describe('tests unlink functionality', () => {
+ test('unlinks a single detector', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: {
+ detectorList: detectorsToAssociate,
+ totalDetectors: detectorsToAssociate.length,
+ },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+ const { getByText, queryByText, getAllByTestId } =
+ renderWithRouter(visEmbeddable);
+ getByText('Loading detectors...');
+ await waitFor(() => getByText('detector_name_0'));
+ getByText('5');
+ expect(queryByText('detector_name_1')).toBeNull();
+ userEvent.click(getAllByTestId('unlinkButton')[0]);
+ await waitFor(() =>
+ getByText(
+ 'Removing association unlinks detector_name_0 detector from the visualization but does not delete it. The detector association can be restored.'
+ )
+ );
+ userEvent.click(getAllByTestId('confirmUnlinkButton')[0]);
+ expect(
+ (
+ await getAugmentVisSavedObjs(
+ 'valid-obj-id-0',
+ augmentVisLoader,
+ uiSettingsMock
+ )
+ ).length
+ ).toEqual(2);
+ await waitFor(() => expect(mockDeleteFn).toHaveBeenCalledTimes(1));
+ }, 100000);
+ });
+});
+
+//I have a new beforeEach because I making a lot more detectors and saved objects for these tests
+describe('test over 10 associated objects functionality', () => {
+ let augmentVisLoader: SavedObjectLoaderAugmentVis;
+ let mockDeleteFn: jest.Mock;
+ const detectorsToAssociate = new Array(16).fill(null).map((_, index) => {
+ const hasAnomaly = Math.random() > 0.5;
+ return {
+ id: `detector_id_${index}`,
+ name: `detector_name_${index}`,
+ indices: [`index_${index}`],
+ totalAnomalies: hasAnomaly ? Math.floor(Math.random() * 10) : 0,
+ lastActiveAnomaly: hasAnomaly ? Date.now() + index : 0,
+ };
+ });
+
+ const savedObjects = new Array(16).fill(null).map((_, index) => {
+ const pluginResource = {
+ type: 'test-plugin',
+ id: `detector_id_${index}`,
+ };
+ return generateAugmentVisSavedObject(
+ `valid-obj-id-${index}`,
+ fn,
+ `vis-id-${index}`,
+ originPlugin,
+ pluginResource
+ );
+ });
+ beforeEach(() => {
+ mockDeleteFn = jest.fn().mockResolvedValue('someValue');
+ augmentVisLoader = createSavedAugmentVisLoader({
+ savedObjectsClient: {
+ ...getMockAugmentVisSavedObjectClient(savedObjects),
+ delete: mockDeleteFn,
+ },
+ } as any) as SavedObjectLoaderAugmentVis;
+ setSavedFeatureAnywhereLoader(augmentVisLoader);
+ });
+ test('create 20 detectors and saved objects', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: {
+ detectorList: detectorsToAssociate,
+ totalDetectors: detectorsToAssociate.length,
+ },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+
+ const { getByText, queryByText, getAllByTestId, findByText } =
+ renderWithRouter(visEmbeddable);
+
+ await waitFor(() =>
+ findByText('detector_name_1', undefined, { timeout: 200000 })
+ );
+ expect(queryByText('detector_name_15')).toBeNull();
+ // Navigate to next page
+ await waitFor(() =>
+ userEvent.click(getAllByTestId('pagination-button-next')[0])
+ );
+ await waitFor(() => findByText('detector_name_15'));
+
+ expect(queryByText('detector_name_0')).toBeNull();
+ // Navigate to previous page
+ await waitFor(() =>
+ userEvent.click(getAllByTestId('pagination-button-previous')[0])
+ );
+ getByText('detector_name_0');
+ expect(queryByText('detector_name_15')).toBeNull();
+ }, 200000);
+
+ test('searching functionality', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: {
+ detectorList: detectorsToAssociate,
+ totalDetectors: detectorsToAssociate.length,
+ },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+
+ const { queryByText, getByPlaceholderText, findByText } =
+ renderWithRouter(visEmbeddable);
+
+ // initial load only first 10 detectors
+ await waitFor(() =>
+ findByText('detector_name_1', undefined, { timeout: 60000 })
+ );
+ expect(queryByText('detector_name_15')).toBeNull();
+
+ //Input search event
+ userEvent.type(getByPlaceholderText('Search...'), 'detector_name_15');
+ await waitFor(() => {
+ findByText('detector_name_15');
+ });
+ expect(queryByText('detector_name_1')).toBeNull();
+ }, 100000);
+
+ test('sorting functionality', async () => {
+ httpClientMock.get = jest.fn().mockResolvedValue({
+ ok: true,
+ response: {
+ detectorList: detectorsToAssociate,
+ totalDetectors: detectorsToAssociate.length,
+ },
+ });
+ (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() =>
+ Promise.resolve(savedObjects)
+ );
+
+ const { queryByText, getAllByTestId, findByText } =
+ renderWithRouter(visEmbeddable);
+
+ // initial load only first 10 detectors (string sort means detector_name_0 -> detector_name_9 show up)
+ await waitFor(() =>
+ findByText('detector_name_0', undefined, { timeout: 100000 })
+ );
+ expect(queryByText('detector_name_15')).toBeNull();
+
+ // Sort by name (string sorting)
+ userEvent.click(getAllByTestId('tableHeaderSortButton')[0]);
+ await waitFor(() =>
+ findByText('detector_name_15', undefined, { timeout: 150000 })
+ );
+ expect(queryByText('detector_name_9')).toBeNull();
+ }, 200000);
+});
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts
new file mode 100644
index 00000000..39483649
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { AssociatedDetectors } from './containers/AssociatedDetectors';
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss
new file mode 100644
index 00000000..0c3fe230
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/styles.scss
@@ -0,0 +1,23 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+.associated-detectors {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ .euiFlyoutBody__overflowContent {
+ height: 100%;
+ padding-bottom: 0;
+ }
+
+ &__flex-group {
+ height: 100%;
+ }
+
+ &__associate-button {
+ flex: 0 0 auto;
+ }
+}
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx
new file mode 100644
index 00000000..37236349
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/constants.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export enum ASSOCIATED_DETECTOR_ACTION {
+ UNLINK,
+}
diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx
new file mode 100644
index 00000000..e01a4505
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx
@@ -0,0 +1,76 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { EuiBasicTableColumn, EuiHealth, EuiLink } from '@elastic/eui';
+import { DETECTOR_STATE } from 'server/utils/constants';
+import { stateToColorMap } from '../../../../pages/utils/constants';
+import { PLUGIN_NAME } from '../../../../utils/constants';
+import { Detector } from '../../../../models/interfaces';
+
+export const renderState = (state: DETECTOR_STATE) => {
+ return (
+ //@ts-ignore
+ {state}
+ );
+};
+
+export const getColumns = ({ handleUnlinkDetectorAction }) =>
+ [
+ {
+ field: 'name',
+ name: 'Detector',
+ sortable: true,
+ truncateText: true,
+ width: '30%',
+ align: 'left',
+ render: (name: string, detector: Detector) => (
+
+ {name}
+
+ ),
+ },
+ {
+ field: 'curState',
+ name: 'Real-time state',
+ sortable: true,
+ align: 'left',
+ width: '30%',
+ truncateText: true,
+ render: renderState,
+ },
+ {
+ field: 'totalAnomalies',
+ name: 'Anomalies/24hr',
+ sortable: true,
+ dataType: 'number',
+ align: 'left',
+ truncateText: true,
+ width: '30%',
+ },
+ {
+ name: 'Actions',
+ align: 'left',
+ truncateText: true,
+ width: '10%',
+ actions: [
+ {
+ type: 'icon',
+ name: 'Remove association',
+ description: 'Remove association',
+ icon: 'unlink',
+ onClick: handleUnlinkDetectorAction,
+ 'data-test-subj': 'unlinkButton',
+ },
+ ],
+ },
+ ] as EuiBasicTableColumn[];
+
+export const search = {
+ box: {
+ incremental: true,
+ schema: true,
+ },
+};
diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx
new file mode 100644
index 00000000..106d0dff
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx
@@ -0,0 +1,1006 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState, useEffect, Fragment } from 'react';
+import {
+ EuiFlyoutHeader,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiTitle,
+ EuiButton,
+ EuiFormFieldset,
+ EuiCheckableCard,
+ EuiSpacer,
+ EuiIcon,
+ EuiText,
+ EuiSwitch,
+ EuiFormRow,
+ EuiFieldText,
+ EuiCheckbox,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiFieldNumber,
+ EuiCallOut,
+ EuiButtonEmpty,
+ EuiPanel,
+} from '@elastic/eui';
+import './styles.scss';
+import {
+ createAugmentVisSavedObject,
+ fetchVisEmbeddable,
+ ISavedAugmentVis,
+ ISavedPluginResource,
+ SavedAugmentVisLoader,
+ VisLayerExpressionFn,
+ VisLayerTypes,
+} from '../../../../../../src/plugins/vis_augmenter/public';
+import { useDispatch } from 'react-redux';
+import { isEmpty, get } from 'lodash';
+import {
+ Field,
+ FieldArray,
+ FieldArrayRenderProps,
+ FieldProps,
+ Formik,
+} from 'formik';
+import {
+ createDetector,
+ getDetectorCount,
+ matchDetector,
+ startDetector,
+} from '../../../../public/redux/reducers/ad';
+import {
+ EmbeddableRenderer,
+ ErrorEmbeddable,
+} from '../../../../../../src/plugins/embeddable/public';
+import './styles.scss';
+import EnhancedAccordion from '../EnhancedAccordion';
+import MinimalAccordion from '../MinimalAccordion';
+import { DataFilterList } from '../../../../public/pages/DefineDetector/components/DataFilterList/DataFilterList';
+import {
+ getError,
+ getErrorMessage,
+ isInvalid,
+ validateDetectorName,
+ validateNonNegativeInteger,
+ validatePositiveInteger,
+} from '../../../../public/utils/utils';
+import {
+ CUSTOM_AD_RESULT_INDEX_PREFIX,
+ MAX_DETECTORS,
+} from '../../../../server/utils/constants';
+import {
+ focusOnFirstWrongFeature,
+ initialFeatureValue,
+ validateFeatures,
+} from '../../../../public/pages/ConfigureModel/utils/helpers';
+import {
+ getIndices,
+ getMappings,
+} from '../../../../public/redux/reducers/opensearch';
+import { formikToDetector } from '../../../../public/pages/ReviewAndCreate/utils/helpers';
+import { FormattedFormRow } from '../../../../public/components/FormattedFormRow/FormattedFormRow';
+import { FeatureAccordion } from '../../../../public/pages/ConfigureModel/components/FeatureAccordion';
+import {
+ AD_DOCS_LINK,
+ AD_HIGH_CARDINALITY_LINK,
+ DEFAULT_SHINGLE_SIZE,
+ MAX_FEATURE_NUM,
+} from '../../../../public/utils/constants';
+import {
+ getEmbeddable,
+ getNotifications,
+ getSavedFeatureAnywhereLoader,
+ getUISettings,
+ getUiActions,
+ getQueryService,
+} from '../../../../public/services';
+import { prettifyErrorMessage } from '../../../../server/utils/helpers';
+import {
+ ORIGIN_PLUGIN_VIS_LAYER,
+ OVERLAY_ANOMALIES,
+ VIS_LAYER_PLUGIN_TYPE,
+ PLUGIN_AUGMENTATION_ENABLE_SETTING,
+ PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING,
+} from '../../../../public/expressions/constants';
+import { formikToDetectorName, visFeatureListToFormik } from './helpers';
+import { AssociateExisting } from './AssociateExisting';
+import { mountReactNode } from '../../../../../../src/core/public/utils';
+import { FLYOUT_MODES } from '../AnywhereParentFlyout/constants';
+import { DetectorListItem } from '../../../../public/models/interfaces';
+import { VisualizeEmbeddable } from '../../../../../../src/plugins/visualizations/public';
+
+function AddAnomalyDetector({
+ embeddable,
+ closeFlyout,
+ mode,
+ setMode,
+ selectedDetector,
+ setSelectedDetector,
+}) {
+ const dispatch = useDispatch();
+ const [queryText, setQueryText] = useState('');
+ const [generatedEmbeddable, setGeneratedEmbeddable] = useState<
+ VisualizeEmbeddable | ErrorEmbeddable
+ >();
+
+ useEffect(() => {
+ const getInitialIndices = async () => {
+ await dispatch(getIndices(queryText));
+ };
+ getInitialIndices();
+ dispatch(getMappings(embeddable.vis.data.aggs.indexPattern.title));
+
+ const createEmbeddable = async () => {
+ const visEmbeddable = await fetchVisEmbeddable(
+ embeddable.vis.id,
+ getEmbeddable(),
+ getQueryService()
+ );
+ setGeneratedEmbeddable(visEmbeddable);
+ };
+
+ createEmbeddable();
+ }, []);
+ const [isShowVis, setIsShowVis] = useState(false);
+ const [accordionsOpen, setAccordionsOpen] = useState({ modelFeatures: true });
+ const [detectorNameFromVis, setDetectorNameFromVis] = useState(
+ formikToDetectorName(embeddable.vis.title)
+ );
+ const [intervalValue, setIntervalalue] = useState(10);
+ const [delayValue, setDelayValue] = useState(1);
+ const [enabled, setEnabled] = useState(false);
+ const [associationLimitReached, setAssociationLimitReached] =
+ useState(false);
+
+ const title = embeddable.getTitle();
+ const onAccordionToggle = (key) => {
+ const newAccordionsOpen = { ...accordionsOpen };
+ newAccordionsOpen[key] = !accordionsOpen[key];
+ setAccordionsOpen(newAccordionsOpen);
+ };
+ const onDetectorNameChange = (e, field) => {
+ field.onChange(e);
+ setDetectorNameFromVis(e.target.value);
+ };
+ const onIntervalChange = (e, field) => {
+ field.onChange(e);
+ setIntervalalue(e.target.value);
+ };
+ const onDelayChange = (e, field) => {
+ field.onChange(e);
+ setDelayValue(e.target.value);
+ };
+ const aggList = embeddable.vis.data.aggs.aggs.filter(
+ (feature) => feature.schema == 'metric'
+ );
+ const featureList = aggList.filter(
+ (feature, index) =>
+ index <
+ (aggList.length < MAX_FEATURE_NUM ? aggList.length : MAX_FEATURE_NUM)
+ );
+
+ const notifications = getNotifications();
+ const handleValidationAndSubmit = (formikProps) => {
+ if (formikProps.values.featureList.length !== 0) {
+ formikProps.setFieldTouched('featureList', true);
+ formikProps.validateForm().then(async (errors) => {
+ if (!isEmpty(errors)) {
+ focusOnFirstWrongFeature(errors, formikProps.setFieldTouched);
+ notifications.toasts.addDanger(
+ 'One or more input fields is invalid.'
+ );
+ } else {
+ const isAugmentationEnabled = uiSettings.get(
+ PLUGIN_AUGMENTATION_ENABLE_SETTING
+ );
+ if (!isAugmentationEnabled) {
+ notifications.toasts.addDanger(
+ 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.'
+ );
+ } else {
+ const maxAssociatedCount = uiSettings.get(
+ PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING
+ );
+ await savedObjectLoader.findAll().then(async (resp) => {
+ if (resp !== undefined) {
+ const savedAugmentObjects = get(resp, 'hits', []);
+ // gets all the saved object for this visualization
+ const savedObjectsForThisVisualization =
+ savedAugmentObjects.filter(
+ (savedObj) =>
+ get(savedObj, 'visId', '') === embeddable.vis.id
+ );
+ if (
+ maxAssociatedCount <= savedObjectsForThisVisualization.length
+ ) {
+ notifications.toasts.addDanger(
+ `Cannot create the detector and associate it to the visualization due to the limit of the max
+ amount of associated plugin resources (${maxAssociatedCount}) with
+ ${savedObjectsForThisVisualization.length} associated to the visualization`
+ );
+ } else {
+ handleSubmit(formikProps);
+ }
+ }
+ });
+ }
+ }
+ });
+ } else {
+ notifications.toasts.addDanger('One or more features are required.');
+ }
+ };
+
+ const uiSettings = getUISettings();
+ const savedObjectLoader: SavedAugmentVisLoader =
+ getSavedFeatureAnywhereLoader();
+
+ let maxAssociatedCount = uiSettings.get(
+ PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING
+ );
+
+ useEffect(async () => {
+ // Gets all augmented saved objects
+ await savedObjectLoader.findAll().then(async (resp) => {
+ if (resp !== undefined) {
+ const savedAugmentObjects = get(resp, 'hits', []);
+ // gets all the saved object for this visualization
+ const savedObjectsForThisVisualization = savedAugmentObjects.filter(
+ (savedObj) => get(savedObj, 'visId', '') === embeddable.vis.id
+ );
+ if (maxAssociatedCount <= savedObjectsForThisVisualization.length) {
+ setAssociationLimitReached(true);
+ } else {
+ setAssociationLimitReached(false);
+ }
+ }
+ });
+ }, []);
+
+ const getEmbeddableSection = () => {
+ return (
+ <>
+
+
+ Create and configure an anomaly detector to automatically detect
+ anomalies in your data and to view real-time results on the
+ visualization.{' '}
+
+ Learn more
+
+
+
+
+
+
+
+
+ {title}
+
+
+ setIsShowVis(!isShowVis)}
+ />
+
+
+
+
+
+ >
+ );
+ };
+
+ const getAugmentVisSavedObject = (detectorId: string) => {
+ const fn = {
+ type: VisLayerTypes.PointInTimeEvents,
+ name: OVERLAY_ANOMALIES,
+ args: {
+ detectorId: detectorId,
+ },
+ } as VisLayerExpressionFn;
+
+ const pluginResource = {
+ type: VIS_LAYER_PLUGIN_TYPE,
+ id: detectorId,
+ } as ISavedPluginResource;
+
+ return {
+ title: embeddable.vis.title,
+ originPlugin: ORIGIN_PLUGIN_VIS_LAYER,
+ pluginResource: pluginResource,
+ visId: embeddable.vis.id,
+ visLayerExpressionFn: fn,
+ } as ISavedAugmentVis;
+ };
+
+ // Error handeling/notification cases listed here as many things are being done sequentially
+ //1. if detector is created succesfully, started succesfully and associated succesfully and alerting exists -> show end message with alerting button
+ //2. If detector is created succesfully, started succesfully and associated succesfully and alerting doesn't exist -> show end message with OUT alerting button
+ //3. If detector is created succesfully, started succesfully and fails association -> show one toast with detector created, and one toast with failed association
+ //4. If detector is created succesfully, fails starting and fails association -> show one toast with detector created succesfully, one toast with failed association
+ //5. If detector is created successfully, fails starting and fails associating -> show one toast with detector created succesfully, one toast with fail starting, one toast with failed association
+ //6. If detector fails creating -> show one toast with detector failed creating
+ const handleSubmit = async (formikProps) => {
+ formikProps.setSubmitting(true);
+ try {
+ const detectorToCreate = formikToDetector(formikProps.values);
+ await dispatch(createDetector(detectorToCreate))
+ .then(async (response) => {
+ dispatch(startDetector(response.response.id))
+ .then((startDetectorResponse) => {})
+ .catch((err: any) => {
+ notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ getErrorMessage(
+ err,
+ 'There was a problem starting the real-time detector'
+ )
+ )
+ );
+ });
+
+ const detectorId = response.response.id;
+ const augmentVisSavedObjectToCreate: ISavedAugmentVis =
+ getAugmentVisSavedObject(detectorId);
+
+ await createAugmentVisSavedObject(
+ augmentVisSavedObjectToCreate,
+ savedObjectLoader,
+ uiSettings
+ )
+ .then((savedObject: any) => {
+ savedObject
+ .save({})
+ .then((response: any) => {
+ const shingleSize = get(
+ formikProps.values,
+ 'shingleSize',
+ DEFAULT_SHINGLE_SIZE
+ );
+ const detectorId = get(savedObject, 'pluginResource.id', '');
+ notifications.toasts.addSuccess({
+ title: `The ${formikProps.values.name} is associated with the ${title} visualization`,
+ text: mountReactNode(
+ getEverythingSuccessfulButton(detectorId, shingleSize)
+ ),
+ className: 'createdAndAssociatedSuccessToast',
+ });
+ closeFlyout();
+ })
+ .catch((error) => {
+ console.error(
+ `Error associating selected detector in save process: ${error}`
+ );
+ notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ `Error associating selected detector in save process: ${error}`
+ )
+ );
+ notifications.toasts.addSuccess(
+ `Detector created: ${formikProps.values.name}`
+ );
+ });
+ })
+ .catch((error) => {
+ console.error(
+ `Error associating selected detector in create process: ${error}`
+ );
+ notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ `Error associating selected detector in create process: ${error}`
+ )
+ );
+ notifications.toasts.addSuccess(
+ `Detector created: ${formikProps.values.name}`
+ );
+ });
+ })
+ .catch((err: any) => {
+ dispatch(getDetectorCount()).then((response: any) => {
+ const totalDetectors = get(response, 'response.count', 0);
+ if (totalDetectors === MAX_DETECTORS) {
+ notifications.toasts.addDanger(
+ 'Cannot create detector - limit of ' +
+ MAX_DETECTORS +
+ ' detectors reached'
+ );
+ } else {
+ notifications.toasts.addDanger(
+ prettifyErrorMessage(
+ getErrorMessage(
+ err,
+ 'There was a problem creating the detector'
+ )
+ )
+ );
+ }
+ });
+ });
+ closeFlyout();
+ } catch (e) {
+ } finally {
+ formikProps.setSubmitting(false);
+ }
+ };
+
+ const getEverythingSuccessfulButton = (detectorId, shingleSize) => {
+ return (
+
+
+ Attempting to initialize the detector with historical data. This
+ initializing process takes approximately 1 minute if you have data in
+ each of the last {32 + shingleSize} consecutive intervals.
+
+ {alertingExists() ? (
+
+
+ Set up alerts to be notified of any anomalies.
+
+
+
+ openAlerting(detectorId)}>
+ Set up alerts
+
+
+
+
+ ) : null}
+
+ );
+ };
+
+ const alertingExists = () => {
+ try {
+ const uiActionService = getUiActions();
+ uiActionService.getTrigger('ALERTING_TRIGGER_AD_ID');
+ return true;
+ } catch (e) {
+ console.error('No alerting trigger exists', e);
+ return false;
+ }
+ };
+
+ const openAlerting = (detectorId: string) => {
+ const uiActionService = getUiActions();
+ uiActionService
+ .getTrigger('ALERTING_TRIGGER_AD_ID')
+ .exec({ embeddable, detectorId });
+ };
+
+ const handleAssociate = async (detector: DetectorListItem) => {
+ const augmentVisSavedObjectToCreate: ISavedAugmentVis =
+ getAugmentVisSavedObject(detector.id);
+
+ createAugmentVisSavedObject(
+ augmentVisSavedObjectToCreate,
+ savedObjectLoader,
+ uiSettings
+ )
+ .then((savedObject: any) => {
+ savedObject
+ .save({})
+ .then((response: any) => {
+ notifications.toasts.addSuccess({
+ title: `The ${detector.name} is associated with the ${title} visualization`,
+ text: "The detector's anomalies do not appear on the visualization. Refresh your dashboard to update the visualization",
+ });
+ closeFlyout();
+ })
+ .catch((error) => {
+ notifications.toasts.addDanger(prettifyErrorMessage(error));
+ });
+ })
+ .catch((error) => {
+ notifications.toasts.addDanger(prettifyErrorMessage(error));
+ });
+ };
+
+ const validateVisDetectorName = async (detectorName: string) => {
+ if (isEmpty(detectorName)) {
+ return 'Detector name cannot be empty';
+ } else {
+ const error = validateDetectorName(detectorName);
+ if (error) {
+ return error;
+ }
+ const resp = await dispatch(matchDetector(detectorName));
+ const match = get(resp, 'response.match', false);
+ if (!match) {
+ return undefined;
+ }
+ //If more than one detectors found, duplicate exists.
+ if (match) {
+ return 'Duplicate detector name';
+ }
+ }
+ };
+
+ const initialDetectorValue = {
+ name: detectorNameFromVis,
+ index: [{ label: embeddable.vis.data.aggs.indexPattern.title }],
+ timeField: embeddable.vis.data.indexPattern.timeFieldName,
+ interval: intervalValue,
+ windowDelay: delayValue,
+ shingleSize: 8,
+ filterQuery: { match_all: {} },
+ description: 'Created based on ' + embeddable.vis.title,
+ resultIndex: undefined,
+ filters: [],
+ featureList: visFeatureListToFormik(
+ featureList,
+ embeddable.vis.params.seriesParams
+ ),
+ categoryFieldEnabled: false,
+ realTime: true,
+ historical: false,
+ };
+
+ return (
+
+
+ {(formikProps) => (
+ <>
+
+
+ Add anomaly detector
+
+
+
+ {associationLimitReached ? (
+
+
+ Adding more objects may affect cluster performance and
+ prevent dashboards from rendering properly. Remove
+ associations before adding new ones.
+
+ {getEmbeddableSection()}
+
+ ) : (
+
+
+
+ Options to create a new detector or associate an
+ existing detector
+
+
+ ),
+ }}
+ className="add-anomaly-detector__modes"
+ >
+ {[
+ {
+ id: 'add-anomaly-detector__create',
+ label: 'Create new detector',
+ value: 'create',
+ },
+ {
+ id: 'add-anomaly-detector__existing',
+ label: 'Associate existing detector',
+ value: 'existing',
+ },
+ ].map((option) => (
+ setMode(option.value),
+ }}
+ />
+ ))}
+
+
+ {mode === FLYOUT_MODES.existing && (
+
+ )}
+ {mode === FLYOUT_MODES.create && (
+
+ {getEmbeddableSection()}
+
+
+ Detector details
+
+
+
+
onAccordionToggle('detectorDetails')}
+ subTitle={
+
+
+ Detector interval: {intervalValue} minute(s);
+ Window delay: {delayValue} minute(s)
+
+
+ }
+ >
+
+ {({ field, form }: FieldProps) => (
+
+ onDetectorNameChange(e, field)}
+ />
+
+ )}
+
+
+
+
+ {({ field, form }: FieldProps) => (
+
+
+
+
+
+
+ onIntervalChange(e, field)
+ }
+ />
+
+
+
+ minute(s)
+
+
+
+
+
+
+ )}
+
+
+
+
+ {({ field, form }: FieldProps) => (
+
+
+
+ onDelayChange(e, field)}
+ />
+
+
+
+ minute(s)
+
+
+
+
+ )}
+
+
+
+
+
+
+ onAccordionToggle('advancedConfiguration')
+ }
+ initialIsOpen={false}
+ >
+
+
+
+
+
+ Source:{' '}
+ {embeddable.vis.data.aggs.indexPattern.title}
+
+
+
+
+
+
+
+
+
+ {({ field, form }: FieldProps) => (
+
+
+
+
+
+
+
+ intervals
+
+
+
+
+ )}
+
+
+
+
+
+ {({ field, form }: FieldProps) => (
+
+
+ {
+ if (enabled) {
+ form.setFieldValue('resultIndex', '');
+ }
+ setEnabled(!enabled);
+ }}
+ />
+
+
+ {enabled ? (
+
+
+
+ ) : null}
+
+ {enabled ? (
+
+
+
+
+
+ ) : null}
+
+ )}
+
+
+
+
+
+
+ The dashboard does not support high-cardinality
+ detectors.
+
+ Learn more
+
+
+
+
+
+
+
+
+ Model Features
+
+
+
+
onAccordionToggle('modelFeatures')}
+ >
+
+ {({
+ push,
+ remove,
+ form: { values },
+ }: FieldArrayRenderProps) => {
+ return (
+
+ {values.featureList.map(
+ (feature: any, index: number) => (
+ {
+ remove(index);
+ }}
+ index={index}
+ feature={feature}
+ handleChange={formikProps.handleChange}
+ displayMode="flyout"
+ />
+ )
+ )}
+
+
+
+ =
+ MAX_FEATURE_NUM
+ }
+ onClick={() => {
+ push(initialFeatureValue());
+ }}
+ >
+ Add another feature
+
+
+
+
+
+ You can add up to{' '}
+ {Math.max(
+ MAX_FEATURE_NUM -
+ values.featureList.length,
+ 0
+ )}{' '}
+ more features.
+
+
+
+ );
+ }}
+
+
+
+
+ )}
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {mode === FLYOUT_MODES.existing ? (
+ handleAssociate(selectedDetector)}
+ >
+ Associate detector
+
+ ) : (
+ {
+ handleValidationAndSubmit(formikProps);
+ }}
+ >
+ Create detector
+
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default AddAnomalyDetector;
diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx
new file mode 100644
index 00000000..cad7a718
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx
@@ -0,0 +1,281 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ EuiTitle,
+ EuiSpacer,
+ EuiIcon,
+ EuiText,
+ EuiComboBox,
+ EuiLoadingSpinner,
+ EuiLink,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiHealth,
+ EuiHorizontalRule,
+} from '@elastic/eui';
+import { useDispatch, useSelector } from 'react-redux';
+import { get } from 'lodash';
+import { CoreServicesContext } from '../../../../../components/CoreServices/CoreServices';
+import { CoreStart } from '../../../../../../../../src/core/public';
+import { AppState } from '../../../../../redux/reducers';
+import { DetectorListItem } from '../../../../../models/interfaces';
+import {
+ GET_ALL_DETECTORS_QUERY_PARAMS,
+ SINGLE_DETECTOR_NOT_FOUND_MSG,
+} from '../../../../../pages/utils/constants';
+import {
+ NO_PERMISSIONS_KEY_WORD,
+ prettifyErrorMessage,
+} from '../../../../../../server/utils/helpers';
+import { getDetectorList } from '../../../../../redux/reducers/ad';
+import {
+ getSavedFeatureAnywhereLoader,
+ getUISettings,
+} from '../../../../../services';
+import {
+ ISavedAugmentVis,
+ SavedAugmentVisLoader,
+ getAugmentVisSavedObjs,
+} from '../../../../../../../../src/plugins/vis_augmenter/public';
+import { stateToColorMap } from '../../../../../pages/utils/constants';
+import {
+ BASE_DOCS_LINK,
+ PLUGIN_NAME,
+} from '../../../../../../public/utils/constants';
+import { renderTime } from '../../../../../../public/pages/DetectorsList/utils/tableUtils';
+
+interface AssociateExistingProps {
+ embeddableVisId: string;
+ selectedDetector: DetectorListItem | undefined;
+ setSelectedDetector(detector: DetectorListItem | undefined): void;
+}
+
+export function AssociateExisting(
+ associateExistingProps: AssociateExistingProps
+) {
+ const core = React.useContext(CoreServicesContext) as CoreStart;
+ const dispatch = useDispatch();
+ const allDetectors = useSelector((state: AppState) => state.ad.detectorList);
+ const isRequestingFromES = useSelector(
+ (state: AppState) => state.ad.requesting
+ );
+ const uiSettings = getUISettings();
+ const [isLoadingFinalDetectors, setIsLoadingFinalDetectors] =
+ useState(true);
+ const isLoading = isRequestingFromES || isLoadingFinalDetectors;
+ const errorGettingDetectors = useSelector(
+ (state: AppState) => state.ad.errorMessage
+ );
+ const [
+ existingDetectorsAvailableToAssociate,
+ setExistingDetectorsAvailableToAssociate,
+ ] = useState([] as DetectorListItem[]);
+
+ // Establish savedObjectLoader for all operations on vis augmented saved objects
+ const savedObjectLoader: SavedAugmentVisLoader =
+ getSavedFeatureAnywhereLoader();
+
+ useEffect(() => {
+ if (
+ errorGettingDetectors &&
+ !errorGettingDetectors.includes(SINGLE_DETECTOR_NOT_FOUND_MSG)
+ ) {
+ console.error(errorGettingDetectors);
+ core.notifications.toasts.addDanger(
+ typeof errorGettingDetectors === 'string' &&
+ errorGettingDetectors.includes(NO_PERMISSIONS_KEY_WORD)
+ ? prettifyErrorMessage(errorGettingDetectors)
+ : 'Unable to get all detectors'
+ );
+ setIsLoadingFinalDetectors(false);
+ }
+ }, [errorGettingDetectors]);
+
+ // Handle all changes in the assoicated detectors such as unlinking or new detectors associated
+ useEffect(() => {
+ // Gets all augmented saved objects for the given visualization
+ getAugmentVisSavedObjs(
+ associateExistingProps.embeddableVisId,
+ savedObjectLoader,
+ uiSettings
+ ).then((savedAugmentObjectsArr: any) => {
+ if (savedAugmentObjectsArr != undefined) {
+ const curDetectorsToDisplayOnList =
+ getExistingDetectorsAvailableToAssociate(
+ Object.values(allDetectors),
+ savedAugmentObjectsArr
+ );
+ setExistingDetectorsAvailableToAssociate(curDetectorsToDisplayOnList);
+ setIsLoadingFinalDetectors(false);
+ }
+ });
+ }, [allDetectors]);
+
+ // cross checks all the detectors that exist with all the savedAugment Objects to only display ones
+ // that are associated to the current visualization
+ const getExistingDetectorsAvailableToAssociate = (
+ detectors: DetectorListItem[],
+ savedAugmentForThisVisualization: ISavedAugmentVis[]
+ ) => {
+ // Map all detector IDs for all the found augmented vis objects
+ const savedAugmentDetectorsSet = new Set(
+ savedAugmentForThisVisualization.map((savedObject) =>
+ get(savedObject, 'pluginResource.id', '')
+ )
+ );
+
+ // detectors here is all detectors
+ // for each detector in all detectors return that detector if that detector ID isnt in the set
+ // filter out any detectors that aren't on the set of detectors IDs from the augmented vis objects.
+ const detectorsToDisplay = detectors.filter((detector) => {
+ if (
+ !savedAugmentDetectorsSet.has(detector.id) &&
+ detector.detectorType === 'SINGLE_ENTITY'
+ ) {
+ return detector;
+ }
+ });
+ return detectorsToDisplay;
+ };
+
+ useEffect(() => {
+ getDetectors();
+ }, []);
+
+ const getDetectors = async () => {
+ dispatch(getDetectorList(GET_ALL_DETECTORS_QUERY_PARAMS));
+ };
+
+ const selectedOptions = useMemo(() => {
+ if (
+ !existingDetectorsAvailableToAssociate ||
+ !associateExistingProps.selectedDetector
+ ) {
+ return [];
+ }
+
+ const detector = (existingDetectorsAvailableToAssociate || []).find(
+ (detector) =>
+ detector.id === get(associateExistingProps.selectedDetector, 'id', '')
+ );
+ return detector ? [{ label: detector.name }] : [];
+ }, [
+ associateExistingProps.selectedDetector,
+ existingDetectorsAvailableToAssociate,
+ ]);
+
+ const detector = associateExistingProps.selectedDetector;
+
+ const options = useMemo(() => {
+ if (!existingDetectorsAvailableToAssociate) {
+ return [];
+ }
+
+ return existingDetectorsAvailableToAssociate.map((detector) => ({
+ label: detector.name,
+ }));
+ }, [existingDetectorsAvailableToAssociate]);
+
+ return (
+
+
+
+ View existing anomaly detectors across your system and add the
+ detector(s) to a dashboard and visualization.{' '}
+
+ Learn more
+
+
+
+
+
+ Select detector to associate
+
+
+
+ Eligible detectors don't include high-cardinality detectors.
+
+ {existingDetectorsAvailableToAssociate ? (
+
{
+ let detector = undefined as DetectorListItem | undefined;
+
+ if (selectedOptions && selectedOptions.length) {
+ const match = existingDetectorsAvailableToAssociate.find(
+ (detector) => detector.name === selectedOptions[0].label
+ );
+ detector = match;
+ }
+ associateExistingProps.setSelectedDetector(detector);
+ }}
+ aria-label="Select an anomaly detector to associate"
+ isClearable
+ singleSelection={{ asPlainText: true }}
+ placeholder="Search for an anomaly detector"
+ />
+ ) : (
+
+ )}
+
+ {detector && (
+ <>
+
+
+
+ {detector.name}
+
+
+
+ Running since {renderTime(detector.enabledTime)}
+
+
+
+
+ View detector page
+
+
+
+
+
+ {[
+ ['Indices', (detector) => detector.indices],
+ [
+ 'Anomalies last 24 hours',
+ (detector) => detector.totalAnomalies,
+ ],
+ [
+ 'Last real-time occurrence',
+ (detector) => renderTime(detector.lastActiveAnomaly),
+ ],
+ ].map(([label, getValue]) => (
+
+
+ {label} : {getValue(detector)}
+
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+export default AssociateExisting;
diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts
new file mode 100644
index 00000000..90aa3ae3
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { AssociateExisting } from './containers/AssociateExisting';
diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx
new file mode 100644
index 00000000..685571e9
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers.tsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { FEATURE_TYPE } from '../../../../public/models/interfaces';
+import { FeaturesFormikValues } from '../../../../public/pages/ConfigureModel/models/interfaces';
+import { find, snakeCase } from 'lodash';
+import { AGGREGATION_TYPES } from '../../../../public/pages/ConfigureModel/utils/constants';
+
+export function visFeatureListToFormik(
+ featureList,
+ seriesParams
+): FeaturesFormikValues[] {
+ return featureList.map((feature) => {
+ return {
+ featureId: feature.id,
+ featureName: getFeatureNameFromVisParams(feature.id, seriesParams),
+ featureEnabled: true,
+ featureType: FEATURE_TYPE.SIMPLE,
+ importance: 1,
+ newFeature: false,
+ aggregationBy: visAggregationTypeToFormik(feature),
+ aggregationOf: visAggregationToFormik(feature),
+ aggregationQuery: JSON.stringify(
+ visAggregationQueryToFormik(feature, seriesParams)
+ ),
+ };
+ });
+}
+
+export function formikToDetectorName(title) {
+ const detectorName =
+ title + '_anomaly_detector_' + Math.floor(100000 + Math.random() * 900000);
+ const formattedName = detectorName.replace(/[^a-zA-Z0-9\-_]/g, '_');
+ return formattedName;
+}
+
+const getFeatureNameFromVisParams = (id, seriesParams) => {
+ const name = find(seriesParams, function (param) {
+ if (param.data.id === id) {
+ return true;
+ }
+ });
+
+ const formattedFeatureName = name.data.label.replace(/[^a-zA-Z0-9-_]/g, '_');
+ return formattedFeatureName;
+};
+
+function visAggregationToFormik(value) {
+ if (Object.values(value.params).length !== 0) {
+ return [
+ {
+ label: value.params?.field?.name,
+ type: value.type,
+ },
+ ];
+ }
+ // for count type of vis, there's no field name in the embeddable-vis schema
+ return [];
+}
+
+function visAggregationQueryToFormik(value, seriesParams) {
+ if (Object.values(value.params).length !== 0) {
+ return {
+ [snakeCase(getFeatureNameFromVisParams(value.id, seriesParams))]: {
+ [visAggregationTypeToFormik(value)]: {
+ field: value.params?.field?.name,
+ },
+ },
+ };
+ }
+ // for count type of vis, there's no field name in the embeddable-vis schema
+ // return '' as the custom expression query
+ return '';
+}
+
+function visAggregationTypeToFormik(feature) {
+ const aggType = feature.__type.name;
+ if (AGGREGATION_TYPES.some((type) => type.value === aggType)) {
+ return aggType;
+ }
+ if (aggType === 'count') {
+ return 'value_count';
+ }
+ return 'sum';
+}
diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx
new file mode 100644
index 00000000..cacc501e
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import AddAnomalyDetector from './AddAnomalyDetector';
+
+export default AddAnomalyDetector;
diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss
new file mode 100644
index 00000000..e16e3895
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss
@@ -0,0 +1,68 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+.add-anomaly-detector {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ .euiFlyoutBody__overflowContent {
+ height: 100%;
+ padding-bottom: 0;
+ }
+
+ .euiFlexItem.add-anomaly-detector__scroll {
+ overflow-y: auto;
+ }
+
+ &__flex-group {
+ height: 100%;
+ }
+
+ &__modes {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ }
+}
+
+.create-new {
+ &__vis {
+ height: 400px;
+
+ &--hidden {
+ display: none;
+ }
+ }
+
+ &__title-and-toggle {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ &__title-icon {
+ margin-right: 10px;
+ vertical-align: middle;
+ }
+
+ .visualization {
+ padding: 0;
+ }
+}
+
+.featureButton {
+ width: 100%;
+ height: 100%;
+ min-height: 40px;
+}
+
+.euiGlobalToastList {
+ width: 650px;
+}
+
+.createdAndAssociatedSuccessToast {
+ width: 550px;
+ position: relative;
+ right: 15px;
+}
diff --git a/public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx
new file mode 100644
index 00000000..3ee81e65
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle.tsx
@@ -0,0 +1,28 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { EuiIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
+import { i18n } from '@osd/i18n';
+
+const DocumentationTitle = () => (
+
+
+
+ {i18n.translate(
+ 'dashboard.actions.adMenuItem.documentation.displayName',
+ {
+ defaultMessage: 'Documentation',
+ }
+ )}
+
+
+
+
+
+
+);
+
+export default DocumentationTitle;
diff --git a/public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx
new file mode 100644
index 00000000..e9f1bd89
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/DocumentationTitle/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import DocumentationTitle from './containers/DocumentationTitle';
+
+export default DocumentationTitle;
diff --git a/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx
new file mode 100644
index 00000000..b129bc20
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/EnhancedAccordion.tsx
@@ -0,0 +1,88 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import React from 'react';
+import {
+ EuiTitle,
+ EuiSpacer,
+ EuiButtonIcon,
+ EuiButtonEmpty,
+ EuiAccordion,
+ EuiPanel,
+} from '@elastic/eui';
+import './styles.scss';
+
+const EnhancedAccordion = ({
+ id,
+ title,
+ subTitle,
+ isOpen,
+ onToggle,
+ children,
+ isButton,
+ iconType,
+ extraAction,
+ initialIsOpen,
+}) => (
+
+
+
+
+
+ {!isButton && (
+ {extraAction}
+ }
+ forceState={isOpen ? 'open' : 'closed'}
+ onToggle={onToggle}
+ initialIsOpen={initialIsOpen}
+ buttonContent={
+
+
+ {title}
+
+
+ {subTitle && (
+ <>
+
+ {subTitle}
+ >
+ )}
+
+ }
+ >
+
+ {children}
+
+
+ )}
+ {isButton && (
+
+ )}
+
+
+);
+
+export default EnhancedAccordion;
diff --git a/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx
new file mode 100644
index 00000000..0b994f5f
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import EnhancedAccordion from './EnhancedAccordion';
+
+export default EnhancedAccordion;
diff --git a/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss
new file mode 100644
index 00000000..4615733d
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/EnhancedAccordion/styles.scss
@@ -0,0 +1,32 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+.enhanced-accordion {
+ &__arrow {
+ transition: rotate 0.3s;
+ rotate: 0deg;
+
+ &--open {
+ rotate: 90deg;
+ }
+
+ &--hidden {
+ visibility: hidden;
+ }
+ }
+
+ &__title {
+ padding: 12px 16px;
+ }
+
+ &__extra {
+ padding-right: 16px;
+ }
+
+ &__button {
+ width: 100%;
+ height: 100%;
+ min-height: 50px;
+ }
+}
diff --git a/public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx
new file mode 100644
index 00000000..ec290cd2
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/MinimalAccordion.tsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import React from 'react';
+import {
+ EuiHorizontalRule,
+ EuiTitle,
+ EuiAccordion,
+ EuiSpacer,
+ EuiPanel,
+ EuiTextColor,
+ EuiText,
+} from '@elastic/eui';
+import './styles.scss';
+
+function MinimalAccordion({
+ id,
+ title,
+ subTitle,
+ children,
+ isUsingDivider,
+ extraAction,
+}) {
+ return (
+
+ {isUsingDivider && (
+ <>
+
+
+ >
+ )}
+
+
+ {title}
+
+ {subTitle && (
+
+ {subTitle}
+
+ )}
+ >
+ }
+ extraAction={
+ {extraAction}
+ }
+ >
+
+ {children}
+
+
+
+ );
+}
+
+export default MinimalAccordion;
diff --git a/public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx
new file mode 100644
index 00000000..7f222f69
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import MinimalAccordion from './MinimalAccordion';
+
+export default MinimalAccordion;
diff --git a/public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss
new file mode 100644
index 00000000..3b64d5ee
--- /dev/null
+++ b/public/components/FeatureAnywhereContextMenu/MinimalAccordion/styles.scss
@@ -0,0 +1,28 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+.minimal-accordion {
+ .euiAccordion__button {
+ align-items: flex-start;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+
+ .minimal-accordion__title {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ &__title {
+ margin-top: -5px;
+ font-weight: 400;
+ }
+
+ &__panel {
+ padding-left: 28px;
+ padding-bottom: 0;
+ }
+}
diff --git a/public/components/FormattedFormRow/FormattedFormRow.tsx b/public/components/FormattedFormRow/FormattedFormRow.tsx
index 7a125664..20cf8daa 100644
--- a/public/components/FormattedFormRow/FormattedFormRow.tsx
+++ b/public/components/FormattedFormRow/FormattedFormRow.tsx
@@ -35,7 +35,7 @@ export const FormattedFormRow = (props: FormattedFormRowProps) => {
{props.hintLink ? ' ' : null}
{props.hintLink ? (
- Learn more
+ Learn more
) : null}
diff --git a/public/expressions/__tests__/overlay_anomalies.test.ts b/public/expressions/__tests__/overlay_anomalies.test.ts
new file mode 100644
index 00000000..c503c601
--- /dev/null
+++ b/public/expressions/__tests__/overlay_anomalies.test.ts
@@ -0,0 +1,154 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { setClient } from '../../services';
+import { httpClientMock } from '../../../test/mocks';
+import {
+ convertAnomaliesToPointInTimeEventsVisLayer,
+ getAnomalies,
+ getVisLayerError,
+ getDetectorResponse,
+} from '../helpers';
+import {
+ ANOMALY_RESULT_SUMMARY,
+ ANOMALY_RESULT_SUMMARY_DETECTOR_ID,
+ NO_ANOMALIES_RESULT_RESPONSE,
+ PARSED_ANOMALIES,
+ SELECTED_DETECTORS,
+} from '../../pages/utils/__tests__/constants';
+import {
+ DETECTOR_HAS_BEEN_DELETED,
+ PLUGIN_EVENT_TYPE,
+ START_OR_END_TIME_INVALID_ERROR,
+ VIS_LAYER_PLUGIN_TYPE,
+} from '../constants';
+import { PLUGIN_NAME } from '../../utils/constants';
+import { VisLayerErrorTypes } from '../../../../../src/plugins/vis_augmenter/public';
+import { DOES_NOT_HAVE_PERMISSIONS_KEY_WORD } from '../../../server/utils/helpers';
+
+describe('overlay_anomalies spec', () => {
+ setClient(httpClientMock);
+
+ const ADPluginResource = {
+ type: VIS_LAYER_PLUGIN_TYPE,
+ id: ANOMALY_RESULT_SUMMARY_DETECTOR_ID,
+ name: 'test-1',
+ urlPath: `${PLUGIN_NAME}#/detectors/${ANOMALY_RESULT_SUMMARY_DETECTOR_ID}/results`, //details page for detector in AD plugin
+ };
+
+ describe('getAnomalies()', () => {
+ test('One anomaly', async () => {
+ httpClientMock.post = jest.fn().mockResolvedValue(ANOMALY_RESULT_SUMMARY);
+ const receivedAnomalies = await getAnomalies(
+ ANOMALY_RESULT_SUMMARY_DETECTOR_ID,
+ 1589258564789,
+ 1589258684789,
+ ''
+ );
+ expect(receivedAnomalies).toStrictEqual(PARSED_ANOMALIES);
+ });
+ test('No Anomalies', async () => {
+ httpClientMock.post = jest
+ .fn()
+ .mockResolvedValue(NO_ANOMALIES_RESULT_RESPONSE);
+ const receivedAnomalies = await getAnomalies(
+ ANOMALY_RESULT_SUMMARY_DETECTOR_ID,
+ 1589258564789,
+ 1589258684789,
+ ''
+ );
+ expect(receivedAnomalies).toStrictEqual([]);
+ });
+ test('Failed response', async () => {
+ httpClientMock.post = jest.fn().mockResolvedValue({ ok: false });
+ const receivedAnomalies = await getAnomalies(
+ ANOMALY_RESULT_SUMMARY_DETECTOR_ID,
+ 1589258564789,
+ 1589258684789,
+ ''
+ );
+ expect(receivedAnomalies).toStrictEqual([]);
+ });
+ });
+ describe('getDetectorResponse()', () => {
+ test('get detector', async () => {
+ httpClientMock.get = jest
+ .fn()
+ .mockResolvedValue({ ok: true, response: SELECTED_DETECTORS[0] });
+ const receivedAnomalies = await getDetectorResponse(
+ 'gtU2l4ABuV34PY9ITTdm'
+ );
+ expect(receivedAnomalies).toStrictEqual({
+ ok: true,
+ response: SELECTED_DETECTORS[0],
+ });
+ });
+ });
+ describe('convertAnomaliesToPointInTimeEventsVisLayer()', () => {
+ test('convert anomalies to PointInTimeEventsVisLayer', async () => {
+ const expectedTimeStamp =
+ PARSED_ANOMALIES[0].startTime +
+ (PARSED_ANOMALIES[0].endTime - PARSED_ANOMALIES[0].startTime) / 2;
+ const expectedPointInTimeEventsVisLayer = {
+ events: [
+ {
+ metadata: {},
+ timestamp: expectedTimeStamp,
+ },
+ ],
+ originPlugin: 'anomalyDetectionDashboards',
+ pluginResource: {
+ id: ANOMALY_RESULT_SUMMARY_DETECTOR_ID,
+ name: 'test-1',
+ type: 'Anomaly Detectors',
+ urlPath: `anomaly-detection-dashboards#/detectors/${ANOMALY_RESULT_SUMMARY_DETECTOR_ID}/results`,
+ },
+ pluginEventType: PLUGIN_EVENT_TYPE,
+ type: 'PointInTimeEvents',
+ };
+ const pointInTimeEventsVisLayer =
+ await convertAnomaliesToPointInTimeEventsVisLayer(
+ PARSED_ANOMALIES,
+ ADPluginResource
+ );
+ expect(pointInTimeEventsVisLayer).toStrictEqual(
+ expectedPointInTimeEventsVisLayer
+ );
+ });
+ });
+ describe('getErrorLayerVisLayer()', () => {
+ test('get resource deleted ErrorVisLayer', async () => {
+ const error = new Error(
+ 'Anomaly Detector - ' + DETECTOR_HAS_BEEN_DELETED
+ );
+ const expectedVisLayerError = {
+ type: VisLayerErrorTypes.RESOURCE_DELETED,
+ message: error.message,
+ };
+ const visLayerError = await getVisLayerError(error);
+ expect(visLayerError).toStrictEqual(expectedVisLayerError);
+ });
+ test('get no permission ErrorVisLayer', async () => {
+ const error = new Error(
+ 'Anomaly Detector - ' + DOES_NOT_HAVE_PERMISSIONS_KEY_WORD
+ );
+ const expectedVisLayerError = {
+ type: VisLayerErrorTypes.PERMISSIONS_FAILURE,
+ message: error.message,
+ };
+ const visLayerError = await getVisLayerError(error);
+ expect(visLayerError).toStrictEqual(expectedVisLayerError);
+ });
+ test('get fetch issue ErrorVisLayer', async () => {
+ const error = new Error(START_OR_END_TIME_INVALID_ERROR);
+ const expectedVisLayerError = {
+ type: VisLayerErrorTypes.FETCH_FAILURE,
+ message: error.message,
+ };
+ const visLayerError = await getVisLayerError(error);
+ expect(visLayerError).toStrictEqual(expectedVisLayerError);
+ });
+ });
+});
diff --git a/public/expressions/constants.ts b/public/expressions/constants.ts
new file mode 100644
index 00000000..71d696bc
--- /dev/null
+++ b/public/expressions/constants.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export const ORIGIN_PLUGIN_VIS_LAYER = 'anomalyDetectionDashboards';
+
+// Defines the header used when categorizing and grouping the VisLayers on the view event flyout in OSD.
+export const VIS_LAYER_PLUGIN_TYPE = 'Anomaly Detectors';
+
+export const TYPE_OF_EXPR_VIS_LAYERS = 'vis_layers';
+
+export const OVERLAY_ANOMALIES = 'overlay_anomalies';
+
+export const PLUGIN_EVENT_TYPE = 'Anomalies';
+
+export const PLUGIN_AUGMENTATION_ENABLE_SETTING =
+ 'visualization:enablePluginAugmentation';
+
+export const PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING =
+ 'visualization:enablePluginAugmentation.maxPluginObjects';
+
+export const DETECTOR_HAS_BEEN_DELETED = 'detector has been deleted';
+
+export const START_OR_END_TIME_INVALID_ERROR = 'start or end time invalid';
diff --git a/public/expressions/helpers.ts b/public/expressions/helpers.ts
new file mode 100644
index 00000000..298e14ba
--- /dev/null
+++ b/public/expressions/helpers.ts
@@ -0,0 +1,139 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ getAnomalySummaryQuery,
+ parsePureAnomalies,
+} from '../pages/utils/anomalyResultUtils';
+import { AD_NODE_API } from '../../utils/constants';
+import { AnomalyData } from '../models/interfaces';
+import { getClient } from '../services';
+import {
+ PluginResource,
+ PointInTimeEventsVisLayer,
+ VisLayerError,
+ VisLayerErrorTypes,
+ VisLayerTypes,
+} from '../../../../src/plugins/vis_augmenter/public';
+import {
+ DETECTOR_HAS_BEEN_DELETED,
+ ORIGIN_PLUGIN_VIS_LAYER,
+ PLUGIN_EVENT_TYPE,
+} from './constants';
+import {
+ DOES_NOT_HAVE_PERMISSIONS_KEY_WORD,
+ NO_PERMISSIONS_KEY_WORD,
+} from '../../server/utils/helpers';
+import { get } from 'lodash';
+
+// This gets all the needed anomalies for the given detector ID and time range
+export const getAnomalies = async (
+ detectorId: string,
+ startTime: number,
+ endTime: number,
+ resultIndex: string
+): Promise => {
+ const anomalySummaryQuery = getAnomalySummaryQuery(
+ startTime,
+ endTime,
+ detectorId,
+ undefined,
+ false
+ );
+ let anomalySummaryResponse;
+ if (resultIndex === '') {
+ anomalySummaryResponse = await getClient().post(
+ `..${AD_NODE_API.DETECTOR}/results/_search`,
+ {
+ body: JSON.stringify(anomalySummaryQuery),
+ }
+ );
+ } else {
+ anomalySummaryResponse = await getClient().post(
+ `..${AD_NODE_API.DETECTOR}/results/_search/${resultIndex}/true`,
+ {
+ body: JSON.stringify(anomalySummaryQuery),
+ }
+ );
+ }
+
+ return parsePureAnomalies(anomalySummaryResponse);
+};
+
+export const getDetectorResponse = async (detectorId: string) => {
+ const resp = await getClient().get(`..${AD_NODE_API.DETECTOR}/${detectorId}`);
+ return resp;
+};
+
+// This takes anomalies and returns them as vis layer of type PointInTimeEvents
+export const convertAnomaliesToPointInTimeEventsVisLayer = (
+ anomalies: AnomalyData[],
+ ADPluginResource: PluginResource
+): PointInTimeEventsVisLayer => {
+ const events = anomalies.map((anomaly: AnomalyData) => {
+ return {
+ timestamp: anomaly.startTime,
+ metadata: {},
+ };
+ });
+ return {
+ originPlugin: ORIGIN_PLUGIN_VIS_LAYER,
+ type: VisLayerTypes.PointInTimeEvents,
+ pluginResource: ADPluginResource,
+ events: events,
+ pluginEventType: PLUGIN_EVENT_TYPE,
+ } as PointInTimeEventsVisLayer;
+};
+
+const checkIfPermissionErrors = (error): boolean => {
+ return typeof error === 'string'
+ ? error.includes(NO_PERMISSIONS_KEY_WORD) ||
+ error.includes(DOES_NOT_HAVE_PERMISSIONS_KEY_WORD)
+ : get(error, 'message', '').includes(NO_PERMISSIONS_KEY_WORD) ||
+ get(error, 'message', '').includes(DOES_NOT_HAVE_PERMISSIONS_KEY_WORD);
+};
+
+const checkIfDeletionErrors = (error): boolean => {
+ return typeof error === 'string'
+ ? error.includes(DETECTOR_HAS_BEEN_DELETED)
+ : get(error, 'message', '').includes(DETECTOR_HAS_BEEN_DELETED);
+};
+
+//Helps convert any possible errors into either permission, deletion or fetch related failures
+export const getVisLayerError = (error): VisLayerError => {
+ let visLayerError: VisLayerError = {} as VisLayerError;
+ if (checkIfPermissionErrors(error)) {
+ visLayerError = {
+ type: VisLayerErrorTypes.PERMISSIONS_FAILURE,
+ message:
+ error === 'string'
+ ? error
+ : error instanceof Error
+ ? get(error, 'message', '')
+ : '',
+ };
+ } else if (checkIfDeletionErrors(error)) {
+ visLayerError = {
+ type: VisLayerErrorTypes.RESOURCE_DELETED,
+ message:
+ error === 'string'
+ ? error
+ : error instanceof Error
+ ? get(error, 'message', '')
+ : '',
+ };
+ } else {
+ visLayerError = {
+ type: VisLayerErrorTypes.FETCH_FAILURE,
+ message:
+ error === 'string'
+ ? error
+ : error instanceof Error
+ ? get(error, 'message', '')
+ : '',
+ };
+ }
+ return visLayerError;
+};
diff --git a/public/expressions/overlay_anomalies.ts b/public/expressions/overlay_anomalies.ts
new file mode 100644
index 00000000..0df420b2
--- /dev/null
+++ b/public/expressions/overlay_anomalies.ts
@@ -0,0 +1,161 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { get } from 'lodash';
+import { i18n } from '@osd/i18n';
+import { ExpressionFunctionDefinition } from '../../../../src/plugins/expressions/public';
+import {
+ VisLayerTypes,
+ VisLayers,
+ ExprVisLayers,
+} from '../../../../src/plugins/vis_augmenter/public';
+import {
+ TimeRange,
+ calculateBounds,
+} from '../../../../src/plugins/data/common';
+import { PointInTimeEventsVisLayer } from '../../../../src/plugins/vis_augmenter/public';
+import { PLUGIN_NAME } from '../utils/constants';
+import {
+ CANT_FIND_KEY_WORD,
+ DOES_NOT_HAVE_PERMISSIONS_KEY_WORD,
+} from '../../server/utils/helpers';
+import {
+ DETECTOR_HAS_BEEN_DELETED,
+ OVERLAY_ANOMALIES,
+ PLUGIN_EVENT_TYPE,
+ START_OR_END_TIME_INVALID_ERROR,
+ TYPE_OF_EXPR_VIS_LAYERS,
+ VIS_LAYER_PLUGIN_TYPE,
+} from './constants';
+import {
+ convertAnomaliesToPointInTimeEventsVisLayer,
+ getAnomalies,
+ getDetectorResponse,
+ getVisLayerError,
+} from './helpers';
+
+type Input = ExprVisLayers;
+type Output = Promise;
+type Name = typeof OVERLAY_ANOMALIES;
+
+interface Arguments {
+ detectorId: string;
+}
+
+export type OverlayAnomaliesExpressionFunctionDefinition =
+ ExpressionFunctionDefinition;
+
+/*
+ * This function defines the Anomaly Detection expression function of type vis_layers.
+ * The expression-fn defined takes an argument of detectorId and an array of VisLayers as input,
+ * it then returns back the VisLayers array with an additional vislayer composed of anomalies.
+ *
+ * The purpose of this function is to allow us on the visualization rendering to gather additional
+ * overlays from an associated plugin resource such as an anomaly detector in this occasion. The VisLayers will
+ * now have anomaly data as one of its VisLayers.
+ *
+ * To create the new added VisLayer the function uses the detectorId and daterange from the search context
+ * to fetch anomalies. Next, the anomalies are mapped into events based on timestamps in order to convert them to a
+ * PointInTimeEventsVisLayer.
+ *
+ * If there are any errors fetching the anomalies the function will return a VisLayerError in the
+ * VisLayer detailing the error type.
+ */
+
+export const overlayAnomaliesFunction =
+ (): OverlayAnomaliesExpressionFunctionDefinition => ({
+ name: OVERLAY_ANOMALIES,
+ type: TYPE_OF_EXPR_VIS_LAYERS,
+ inputTypes: [TYPE_OF_EXPR_VIS_LAYERS],
+ help: i18n.translate('data.functions.overlay_anomalies.help', {
+ defaultMessage: 'Add an anomaly vis layer',
+ }),
+ args: {
+ detectorId: {
+ types: ['string'],
+ default: '""',
+ help: '',
+ },
+ },
+
+ async fn(input, args, context): Promise {
+ // Parsing all of the args & input
+ const detectorId = get(args, 'detectorId', '');
+ const timeRange = get(
+ context,
+ 'searchContext.timeRange',
+ ''
+ ) as TimeRange;
+ const origVisLayers = get(input, 'layers', [] as VisLayers) as VisLayers;
+ const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null;
+ const startTimeInMillis = parsedTimeRange?.min?.unix()
+ ? parsedTimeRange?.min?.unix() * 1000
+ : undefined;
+ const endTimeInMillis = parsedTimeRange?.max?.unix()
+ ? parsedTimeRange?.max?.unix() * 1000
+ : undefined;
+ var ADPluginResource = {
+ type: VIS_LAYER_PLUGIN_TYPE,
+ id: detectorId,
+ name: '',
+ urlPath: `${PLUGIN_NAME}#/detectors/${detectorId}/results`, //details page for detector in AD plugin
+ };
+ try {
+ const detectorResponse = await getDetectorResponse(detectorId);
+ if (get(detectorResponse, 'error', '').includes(CANT_FIND_KEY_WORD)) {
+ throw new Error('Anomaly Detector - ' + DETECTOR_HAS_BEEN_DELETED);
+ } else if (
+ get(detectorResponse, 'error', '').includes(
+ DOES_NOT_HAVE_PERMISSIONS_KEY_WORD
+ )
+ ) {
+ throw new Error(get(detectorResponse, 'error', ''));
+ }
+ const detectorName = get(detectorResponse.response, 'name', '');
+ const resultIndex = get(detectorResponse.response, 'resultIndex', '');
+ if (detectorName === '') {
+ throw new Error('Anomaly Detector - Unable to get detector');
+ }
+ ADPluginResource.name = detectorName;
+
+ if (startTimeInMillis === undefined || endTimeInMillis === undefined) {
+ throw new RangeError(START_OR_END_TIME_INVALID_ERROR);
+ }
+ const anomalies = await getAnomalies(
+ detectorId,
+ startTimeInMillis,
+ endTimeInMillis,
+ resultIndex
+ );
+ const anomalyLayer = convertAnomaliesToPointInTimeEventsVisLayer(
+ anomalies,
+ ADPluginResource
+ );
+ return {
+ type: TYPE_OF_EXPR_VIS_LAYERS,
+ layers: origVisLayers
+ ? origVisLayers.concat(anomalyLayer)
+ : ([anomalyLayer] as VisLayers),
+ };
+ } catch (error) {
+ console.error('Anomaly Detector - Unable to get anomalies: ', error);
+ const visLayerError = getVisLayerError(error);
+ const anomalyErrorLayer = {
+ type: VisLayerTypes.PointInTimeEvents,
+ originPlugin: PLUGIN_NAME,
+ pluginResource: ADPluginResource,
+ events: [],
+ error: visLayerError,
+ pluginEventType: PLUGIN_EVENT_TYPE,
+ } as PointInTimeEventsVisLayer;
+ return {
+ type: TYPE_OF_EXPR_VIS_LAYERS,
+ layers: origVisLayers
+ ? origVisLayers.concat(anomalyErrorLayer)
+ : ([anomalyErrorLayer] as VisLayers),
+ };
+ }
+ },
+ });
diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts
index e2eea176..eff5ead5 100644
--- a/public/models/interfaces.ts
+++ b/public/models/interfaces.ts
@@ -82,27 +82,27 @@ export type FeatureAttributes = {
// all possible valid units accepted by the backend
export enum UNITS {
- NANOS = "Nanos",
- MICROS = "Micros",
- MILLIS = "Millis",
- SECONDS = "Seconds",
+ 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"
+ 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) {
+ switch (units) {
case UNITS.NANOS: {
// Duration in moment library does not support
return moment.duration(0.000000001, 'seconds');
@@ -155,7 +155,7 @@ export function toDuration(units: UNITS): Duration {
default:
break;
}
- throw new Error("Unexpected unit: " + units);
+ throw new Error('Unexpected unit: ' + units);
}
export type Schedule = {
@@ -217,6 +217,7 @@ export type DetectorListItem = {
lastActiveAnomaly: number;
lastUpdateTime: number;
enabledTime?: number;
+ detectorType?: string;
};
export type EntityData = {
@@ -235,7 +236,7 @@ export type AnomalyData = {
entity?: EntityData[];
features?: { [key: string]: FeatureAggregationData };
contributions?: { [key: string]: FeatureContributionData };
- aggInterval?: string;
+ aggInterval?: string;
};
export type FeatureAggregationData = {
diff --git a/public/pages/AnomalyCharts/components/AnomaliesStat/AnomalyStat.tsx b/public/pages/AnomalyCharts/components/AnomaliesStat/AnomalyStat.tsx
index 56c4db8c..2f82cccf 100644
--- a/public/pages/AnomalyCharts/components/AnomaliesStat/AnomalyStat.tsx
+++ b/public/pages/AnomalyCharts/components/AnomaliesStat/AnomalyStat.tsx
@@ -91,7 +91,7 @@ export const AlertsStat = (props: {
target="_blank"
style={{ fontSize: '14px' }}
>
- View monitor
+ View monitor
) : null}
diff --git a/public/pages/AnomalyCharts/components/AnomaliesStat/__tests__/__snapshots__/AnomalyStat.test.tsx.snap b/public/pages/AnomalyCharts/components/AnomaliesStat/__tests__/__snapshots__/AnomalyStat.test.tsx.snap
index fbc59913..4577f90a 100644
--- a/public/pages/AnomalyCharts/components/AnomaliesStat/__tests__/__snapshots__/AnomalyStat.test.tsx.snap
+++ b/public/pages/AnomalyCharts/components/AnomaliesStat/__tests__/__snapshots__/AnomalyStat.test.tsx.snap
@@ -38,21 +38,7 @@ exports[` spec Alert Stat renders component with monitor and loadi
style="font-size: 14px;"
target="_blank"
>
- View monitor
-
-
-
+ View monitor
spec Alert Stat renders component with monitor and not l
style="font-size: 14px;"
target="_blank"
>
- View monitor
-
+ View monitor
{
* 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 ? (
{
', '
)})`
: props.featureDataSeriesName;
- timeSeriesList.push(
+ timeSeriesList.push(
{
yAccessors={[CHART_FIELDS.DATA]}
data={featureTimeSeries}
/>
- )
- if (featureTimeSeries.map(
- (item: FeatureAggregationData) => {
- if(item.hasOwnProperty('expectedValue')) {
+ );
+ if (
+ featureTimeSeries.map((item: FeatureAggregationData) => {
+ if (item.hasOwnProperty('expectedValue')) {
timeSeriesList.push(
- )
+ );
}
- }
- ))
- return timeSeriesList;
+ })
+ )
+ return timeSeriesList;
}
)}
-
{showCustomExpression ? (
{
@@ -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);
@@ -163,6 +171,9 @@ export const AnomaliesChart = React.memo((props: AnomaliesChartProps) => {
}}
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 ef078d93..e2116e30 100644
--- a/public/pages/AnomalyCharts/containers/AnomalyDetailsChart.tsx
+++ b/public/pages/AnomalyCharts/containers/AnomalyDetailsChart.tsx
@@ -31,6 +31,7 @@ import {
EuiStat,
EuiButtonGroup,
EuiText,
+ EuiCallOut,
} from '@elastic/eui';
import { forEach, get } from 'lodash';
import moment from 'moment';
@@ -111,6 +112,7 @@ interface AnomalyDetailsChartProps {
isHistorical?: boolean;
selectedHeatmapCell?: HeatmapCell;
onDatePickerRangeChange?(startDate: number, endDate: number): void;
+ openOutOfRangeCallOut?: boolean;
}
export const AnomalyDetailsChart = React.memo(
@@ -348,30 +350,33 @@ export const AnomalyDetailsChart = React.memo(
zoomRange.endDate,
]);
-
const customAnomalyContributionTooltip = (details?: string) => {
const anomaly = details ? JSON.parse(details) : undefined;
- const contributionData = get(anomaly, `contributions`, [])
+ const contributionData = get(anomaly, `contributions`, []);
- const featureData = get(anomaly, `features`, {})
+ 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) + "%"
+ 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 (
@@ -379,35 +384,33 @@ export const AnomalyDetailsChart = React.memo(
Feature Contribution:
{anomaly ? (
-
-
- {featureAttributionList}
-
- ) : null}
+
+
+ {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)}`
- }))
- ) : []
+ 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;
@@ -423,6 +426,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 ? (
- )
+ : props.anomalyGradeSeriesName;
+ return (
+
+ );
}
)}
diff --git a/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx b/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx
index 51dd1318..e939e64d 100644
--- a/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx
+++ b/public/pages/AnomalyCharts/containers/FeatureBreakDown.tsx
@@ -176,11 +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 },
- })
- }
+ 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/index.scss b/public/pages/AnomalyCharts/index.scss
index 7ec70974..ee30bd28 100644
--- a/public/pages/AnomalyCharts/index.scss
+++ b/public/pages/AnomalyCharts/index.scss
@@ -10,8 +10,3 @@
*/
@import 'components/AlertsFlyout/alertsFlyout.scss';
-
-.euiSuperUpdateButton {
- background-color: transparent !important;
- color: #006bb4 !important;
-}
diff --git a/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx b/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx
index 23c7031d..b2603ada 100644
--- a/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx
+++ b/public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx
@@ -640,9 +640,7 @@ export const getFeatureBreakdownWording = (
return isNotSample ? 'Feature breakdown' : 'Sample feature breakdown';
};
-export const getFeatureDataWording = (
- isNotSample: boolean | undefined
-) => {
+export const getFeatureDataWording = (isNotSample: boolean | undefined) => {
return isNotSample ? 'Feature output' : 'Sample feature output';
};
diff --git a/public/pages/AnomalyCharts/utils/constants.ts b/public/pages/AnomalyCharts/utils/constants.ts
index 5b699a1b..88afc988 100644
--- a/public/pages/AnomalyCharts/utils/constants.ts
+++ b/public/pages/AnomalyCharts/utils/constants.ts
@@ -24,7 +24,7 @@ export enum CHART_FIELDS {
CONFIDENCE = 'confidence',
DATA = 'data',
AGG_INTERVAL = 'aggInterval',
- EXPECTED_VALUE = 'expectedValue'
+ EXPECTED_VALUE = 'expectedValue',
}
export enum CHART_COLORS {
@@ -91,7 +91,7 @@ export const DEFAULT_ANOMALY_SUMMARY = {
maxAnomalyGrade: 0,
minConfidence: 0,
maxConfidence: 0,
- lastAnomalyOccurrence: '-'
+ lastAnomalyOccurrence: '-',
};
export const HEATMAP_CHART_Y_AXIS_WIDTH = 30;
diff --git a/public/pages/ConfigureModel/components/CategoryField/CategoryField.tsx b/public/pages/ConfigureModel/components/CategoryField/CategoryField.tsx
index 9afe53a1..130e64d5 100644
--- a/public/pages/ConfigureModel/components/CategoryField/CategoryField.tsx
+++ b/public/pages/ConfigureModel/components/CategoryField/CategoryField.tsx
@@ -76,7 +76,7 @@ export function CategoryField(props: CategoryFieldProps) {
Split a single time series into multiple time series based on
categorical fields. You can select up to 2.{' '}
- Learn more
+ Learn more
}
diff --git a/public/pages/ConfigureModel/components/CategoryField/__tests__/__snapshots__/CategoryField.test.tsx.snap b/public/pages/ConfigureModel/components/CategoryField/__tests__/__snapshots__/CategoryField.test.tsx.snap
index a1117607..557d3b8d 100644
--- a/public/pages/ConfigureModel/components/CategoryField/__tests__/__snapshots__/CategoryField.test.tsx.snap
+++ b/public/pages/ConfigureModel/components/CategoryField/__tests__/__snapshots__/CategoryField.test.tsx.snap
@@ -50,17 +50,7 @@ exports[` spec renders the component when disabled 1`] = `
rel="noopener noreferrer"
target="_blank"
>
- Learn more
-
+ Learn more
spec renders the component when enabled 1`] = `
rel="noopener noreferrer"
target="_blank"
>
- Learn more
-
-
-
+ Learn more
): void;
+ displayMode?: string;
}
export const FeatureAccordion = (props: FeatureAccordionProps) => {
@@ -78,6 +81,18 @@ export const FeatureAccordion = (props: FeatureAccordionProps) => {
};
const featureButtonContent = (feature: any, index: number) => {
+ if (props.displayMode === 'flyout') {
+ return (
+
+ );
+ }
return (