diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 293c8f6a..7c1909fd 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,8 +3,19 @@ "version": "2.9.0.0", "opensearchDashboardsVersion": "2.9.0", "configPath": ["anomaly_detection_dashboards"], - "requiredPlugins": ["navigation"], - "optionalPlugins": [], + "requiredPlugins": [ + "opensearchDashboardsUtils", + "expressions", + "data", + "visAugmenter", + "uiActions", + "dashboard", + "embeddable", + "opensearchDashboardsReact", + "savedObjects", + "visAugmenter", + "opensearchDashboardsUtils" + ], "server": true, "ui": true } diff --git a/public/action/ad_dashboard_action.tsx b/public/action/ad_dashboard_action.tsx new file mode 100644 index 00000000..2cde952b --- /dev/null +++ b/public/action/ad_dashboard_action.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { IEmbeddable } from '../../../../src/plugins/dashboard/public/embeddable_plugin'; +import { + DASHBOARD_CONTAINER_TYPE, + DashboardContainer, +} from '../../../../src/plugins/dashboard/public'; +import { + IncompatibleActionError, + createAction, + Action, +} from '../../../../src/plugins/ui_actions/public'; +import { isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { VisualizeEmbeddable } from '../../../../src/plugins/visualizations/public'; +import { isEligibleForVisLayers } from '../../../../src/plugins/vis_augmenter/public'; +import { getUISettings } from '../services'; + +export const ACTION_AD = 'ad'; + +function isDashboard( + embeddable: IEmbeddable +): embeddable is DashboardContainer { + return embeddable.type === DASHBOARD_CONTAINER_TYPE; +} + +export interface ActionContext { + embeddable: IEmbeddable; +} + +export interface CreateOptions { + grouping: Action['grouping']; + title: string; + icon: EuiIconType; + id: string; + order: number; + onClick: Function; +} + +export const createADAction = ({ + grouping, + title, + icon, + id, + order, + onClick, +}: CreateOptions) => + createAction({ + id, + order, + getDisplayName: ({ embeddable }: ActionContext) => { + if (!embeddable.parent || !isDashboard(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return title; + }, + getIconType: () => icon, + type: ACTION_AD, + grouping, + isCompatible: async ({ embeddable }: ActionContext) => { + const vis = (embeddable as VisualizeEmbeddable).vis; + return Boolean( + embeddable.parent && + embeddable.getInput()?.viewMode === 'view' && + isDashboard(embeddable.parent) && + vis !== undefined && + isEligibleForVisLayers(vis, getUISettings()) + ); + }, + execute: async ({ embeddable }: ActionContext) => { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + onClick({ embeddable }); + }, + }); diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx new file mode 100644 index 00000000..5ab72b2d --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/AnywhereParentFlyout.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { useState } from 'react'; +import { get } from 'lodash'; +import AssociatedDetectors from '../AssociatedDetectors/containers/AssociatedDetectors'; +import { getEmbeddable } from '../../../../public/services'; +import AddAnomalyDetector from '../CreateAnomalyDetector'; +import { FLYOUT_MODES } from './constants'; + +const AnywhereParentFlyout = ({ startingFlyout, ...props }) => { + const embeddable = getEmbeddable().getEmbeddableFactory; + const indices: { label: string }[] = [ + { label: get(embeddable, 'vis.data.indexPattern.title', '') }, + ]; + + const [mode, setMode] = useState(startingFlyout); + const [selectedDetector, setSelectedDetector] = useState(undefined); + + const AnywhereFlyout = { + [FLYOUT_MODES.create]: AddAnomalyDetector, + [FLYOUT_MODES.associated]: AssociatedDetectors, + [FLYOUT_MODES.existing]: AddAnomalyDetector, + }[mode]; + + return ( + + ); +}; + +export default AnywhereParentFlyout; diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts new file mode 100644 index 00000000..fa470962 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +//created: Flyout for creating a new anomaly detector from a visualization +//associated: Flyout for listing all the associated detectors to the given visualization +//existing: Flyout for associating existing detectors with the current visualizations +export enum FLYOUT_MODES { + create = 'create', + associated = 'associated', + existing = 'existing', +} diff --git a/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx new file mode 100644 index 00000000..591d4b6d --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import AnywhereParentFlyout from './AnywhereParentFlyout'; + +export default AnywhereParentFlyout; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx new file mode 100644 index 00000000..98d5d155 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/ConfirmUnlinkDetectorModal/ConfirmUnlinkDetectorModal.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiOverlayMask, + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalHeader, + EuiModalFooter, + EuiModalBody, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { DetectorListItem } from '../../../../../models/interfaces'; +import { EuiSpacer } from '@elastic/eui'; + +interface ConfirmUnlinkDetectorModalProps { + detector: DetectorListItem; + onUnlinkDetector(): void; + onHide(): void; + onConfirm(): void; + isListLoading: boolean; +} + +export const ConfirmUnlinkDetectorModal = ( + props: ConfirmUnlinkDetectorModalProps +) => { + const [isModalLoading, setIsModalLoading] = useState(false); + const isLoading = isModalLoading || props.isListLoading; + return ( + + + + {'Remove association?'} + + + + Removing association unlinks {props.detector.name} detector from the + visualization but does not delete it. The detector association can + be restored. + + + + + {isLoading ? null : ( + + Cancel + + )} + { + setIsModalLoading(true); + props.onUnlinkDetector(); + props.onConfirm(); + }} + > + {'Remove association'} + + + + + ); +}; diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx new file mode 100644 index 00000000..d005e087 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/components/EmptyAssociatedDetectorMessage/EmptyAssociatedDetectorMessage.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import React from 'react'; + +const FILTER_TEXT = 'There are no detectors matching your search'; + +interface EmptyDetectorProps { + isFilterApplied: boolean; + embeddableTitle: string; +} + +export const EmptyAssociatedDetectorMessage = (props: EmptyDetectorProps) => ( + No anomaly detectors to display} + titleSize="s" + data-test-subj="emptyAssociatedDetectorFlyoutMessage" + style={{ maxWidth: '45em' }} + body={ + +

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

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

+ No anomaly detectors to display +

+ +
+
+
+

+ There are no detectors matching your search +

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

+ No anomaly detectors to display +

+ +
+
+
+

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

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

+ Associated anomaly detectors +

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

Visualization: {embeddableTitle}

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

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

+
+ +
+ +

+ + {title} +

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

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

+ {alertingExists() ? ( + + +

Set up alerts to be notified of any anomalies.

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

Add anomaly detector

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

Detector details

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

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

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

minute(s)

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

minute(s)

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

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

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

intervals

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

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

+
+
+
+ + + +

Model Features

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

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

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

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

+
+ + +

Select detector to associate

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

{detector.name}

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

{title}

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