From a54387ace266277775a2a97c1368295ace518073 Mon Sep 17 00:00:00 2001 From: Yizhe Liu Date: Wed, 15 Apr 2020 18:30:01 -0700 Subject: [PATCH] =?UTF-8?q?Add=20Dashboard=20page=20with=20following=20com?= =?UTF-8?q?ponents=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Anomaly Live Chart for 10 detectors with most recent anomaly occurrence in last 30 mins 2. Anomaly Distribution Chart for anomalies by index and detector 3. Detector/Feature list to redirect user to detector details page PR: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/pull/17 --- package.json | 6 +- public/app.scss | 2 + .../components/ContentPanel/ContentPanel.tsx | 3 + public/components/ContentPanel/index.scss | 25 + public/models/interfaces.ts | 1 + .../Components/AnomaliesDistribution.tsx | 244 ++++++++ .../Components/AnomaliesLiveChart.tsx | 267 ++++++++ .../Components/AnomalousDetectorsList.tsx | 95 +++ .../pages/Dashboard/Container/Dashboard.tsx | 90 ++- .../Dashboard/Container/DashboardOverview.tsx | 254 ++++++++ public/pages/Dashboard/index.scss | 46 ++ public/pages/Dashboard/utils/constants.ts | 90 +++ public/pages/Dashboard/utils/utils.tsx | 583 ++++++++++++++++++ .../pages/DetectorResults/utils/constants.ts | 7 +- public/pages/main/Main.tsx | 6 +- public/redux/reducers/anomalyResults.ts | 3 +- public/utils/constants.ts | 12 + server/models/types.ts | 3 +- server/routes/ad.ts | 44 +- server/utils/constants.ts | 15 + yarn.lock | 91 ++- 21 files changed, 1804 insertions(+), 83 deletions(-) create mode 100644 public/components/ContentPanel/index.scss create mode 100644 public/pages/Dashboard/Components/AnomaliesDistribution.tsx create mode 100644 public/pages/Dashboard/Components/AnomaliesLiveChart.tsx create mode 100644 public/pages/Dashboard/Components/AnomalousDetectorsList.tsx create mode 100644 public/pages/Dashboard/Container/DashboardOverview.tsx create mode 100644 public/pages/Dashboard/index.scss create mode 100644 public/pages/Dashboard/utils/constants.ts create mode 100644 public/pages/Dashboard/utils/utils.tsx diff --git a/package.json b/package.json index 2bba0aca..5bbef8a3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@babel/preset-typescript": "^7.3.3", "@elastic/eslint-config-kibana": "link:../../packages/eslint-config-kibana", "@elastic/eslint-import-resolver-kibana": "link:../../packages/kbn-eslint-import-resolver-kibana", - "@elastic/eui": "6.10.7", "@kbn/config-schema": "link:../../packages/kbn-config-schema", "@kbn/plugin-helpers": "link:../../packages/kbn-plugin-helpers", "@testing-library/jest-dom": "^4.0.0", @@ -71,10 +70,11 @@ "typescript": "3.0.3" }, "dependencies": { + "@elastic/charts": "^18.2.2", + "babel-polyfill": "^6.26.0", "formik": "^1.5.8", "query-string": "^6.8.2", "react-redux": "^7.1.0", - "reselect": "^4.0.0", - "babel-polyfill": "^6.26.0" + "reselect": "^4.0.0" } } diff --git a/public/app.scss b/public/app.scss index 652f8173..d9295a33 100644 --- a/public/app.scss +++ b/public/app.scss @@ -13,5 +13,7 @@ * permissions and limitations under the License. */ +@import 'components/ContentPanel/index.scss'; @import 'pages/createDetector/index.scss'; @import 'pages/PreviewDetector/index.scss'; +@import 'pages/Dashboard/index.scss'; diff --git a/public/components/ContentPanel/ContentPanel.tsx b/public/components/ContentPanel/ContentPanel.tsx index df6bc9f4..cd9b02ca 100644 --- a/public/components/ContentPanel/ContentPanel.tsx +++ b/public/components/ContentPanel/ContentPanel.tsx @@ -25,6 +25,9 @@ import { } from '@elastic/eui'; type ContentPanelProps = { + // keep title string part for backwards compatibility + // might need to refactor code and + // deprecate support for 'string' in the near future title: string | React.ReactNode | React.ReactNode[]; titleSize?: EuiTitleSize; subTitle?: React.ReactNode | React.ReactNode[]; diff --git a/public/components/ContentPanel/index.scss b/public/components/ContentPanel/index.scss new file mode 100644 index 00000000..a4a3ab4a --- /dev/null +++ b/public/components/ContentPanel/index.scss @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +.content-panel-title { + color: #3f3f3f; +} + +.content-panel-subTitle { + color: #879196; + font-family: 'Helvetica Neue'; + font-size: 12px; + letter-spacing: 0; +} diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index 3a384494..1b930e06 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -110,6 +110,7 @@ export type DetectorListItem = { name: string; indices: string[]; curState: DETECTOR_STATE; + featureAttributes: FeatureAttributes[]; totalAnomalies: number; lastActiveAnomaly: number; lastUpdateTime: number; diff --git a/public/pages/Dashboard/Components/AnomaliesDistribution.tsx b/public/pages/Dashboard/Components/AnomaliesDistribution.tsx new file mode 100644 index 00000000..73e6bb5f --- /dev/null +++ b/public/pages/Dashboard/Components/AnomaliesDistribution.tsx @@ -0,0 +1,244 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { DetectorListItem } from '../../../models/interfaces'; +import { useState, useEffect } from 'react'; +import { + fillOutColors, + visualizeAnomalyResultForSunburstChart, + getLatestAnomalyResultsForDetectorsByTimeRange, +} from '../utils/utils'; +import ContentPanel from '../../../components/ContentPanel/ContentPanel'; +import { + EuiSelect, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingChart, + //@ts-ignore + EuiStat, +} from '@elastic/eui'; +import { Chart, Partition, PartitionLayout } from '@elastic/charts'; +import { useDispatch } from 'react-redux'; +import { Datum } from '@elastic/charts/dist/utils/commons'; +import React from 'react'; +import { TIME_RANGE_OPTIONS } from '../../Dashboard/utils/constants'; +import { get } from 'lodash'; +import { searchES } from '../../../redux/reducers/elasticsearch'; +import { MAX_DETECTORS } from '../../../utils/constants'; +import { AD_DOC_FIELDS } from '../../../../server/utils/constants'; +export interface AnomaliesDistributionChartProps { + allDetectorsSelected: boolean; + selectedDetectors: DetectorListItem[]; +} + +export const AnomaliesDistributionChart = ( + props: AnomaliesDistributionChartProps +) => { + const dispatch = useDispatch(); + + const [anomalyResults, setAnomalyResults] = useState([] as object[]); + + // TODO: try to find a better way of using redux, + // which can leverage redux, and also get rid of issue with multiple redux on same page, + // so that we don't need to manualy update loading status + // Issue link: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/issues/23 + const [anomalyResultsLoading, setAnomalyResultsLoading] = useState(true); + const [finalDetectors, setFinalDetectors] = useState( + [] as DetectorListItem[] + ); + + const [indicesNumber, setIndicesNumber] = useState(0); + + const [timeRange, setTimeRange] = useState(TIME_RANGE_OPTIONS[0].value); + + const getAnomalyResult = async (currentDetectors: DetectorListItem[]) => { + const finalAnomalyResult = await getLatestAnomalyResultsForDetectorsByTimeRange( + searchES, + props.selectedDetectors, + timeRange, + MAX_DETECTORS, + dispatch + ); + setAnomalyResults(finalAnomalyResult); + + const resultDetectors = getFinalDetectors( + finalAnomalyResult, + props.selectedDetectors + ); + setIndicesNumber(getFinalIndices(resultDetectors).size); + setFinalDetectors(resultDetectors); + setAnomalyResultsLoading(false); + }; + + const getFinalIndices = (detectorList: DetectorListItem[]) => { + const indicesSet = new Set(); + detectorList.forEach(detectorItem => { + indicesSet.add(detectorItem.indices.toString()); + }); + + return indicesSet; + }; + + const getFinalDetectors = ( + finalLiveAnomalyResult: object[], + detectorList: DetectorListItem[] + ): DetectorListItem[] => { + const detectorSet = new Set(); + finalLiveAnomalyResult.forEach(anomalyResult => { + detectorSet.add(get(anomalyResult, AD_DOC_FIELDS.DETECTOR_ID, '')); + }); + + const filteredDetectors = detectorList.filter(detector => + detectorSet.has(detector.id) + ); + + return filteredDetectors; + }; + + const handleOnChange = (e: any) => { + setTimeRange(e.target.value); + }; + + useEffect(() => { + getAnomalyResult(props.selectedDetectors); + }, [timeRange, props.selectedDetectors]); + + return ( + + +

+ { + 'The inner circle shows the anomaly distribution by your indices. The outer circle shows the anomaly distribution by your detector' + } +

+
+ + } + actions={ + + } + > + + + + + + + + + {anomalyResultsLoading ? ( + + + + + + + + + + + + ) : ( + + + + d.count as number} + valueFormatter={(d: number) => d.toString()} + layers={[ + { + groupByRollup: (d: Datum) => d.indices, + nodeLabel: (d: Datum) => { + return d; + }, + fillLabel: { + textInvertible: false, + }, + shape: { + fillColor: d => { + return fillOutColors( + d, + (d.x0 + d.x1) / 3 / (2 * Math.PI), + [] + ); + }, + }, + }, + { + groupByRollup: (d: Datum) => d.name, + nodeLabel: (d: Datum) => { + return d; + }, + fillLabel: { + textInvertible: true, + }, + shape: { + fillColor: d => { + return fillOutColors( + d, + (d.x0 + d.x1) / 2 / (2 * Math.PI), + [] + ); + }, + }, + }, + ]} + config={{ + partitionLayout: PartitionLayout.sunburst, + fontFamily: 'Arial', + outerSizeRatio: 0.6, + fillLabel: { + textInvertible: true, + fontStyle: 'italic', + }, + // TODO: Given only 1 detector exists, the inside Index circle will have issue in following scenarios: + // 1: if Linked Label is configured for identifying index, label of Index circle will be invisible; + // 2: if Fill Label is configured for identifying index, label of it will be overlapped with outer Detector circle + // Issue link: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/issues/24 + }} + /> + + + + )} +
+ ); +}; diff --git a/public/pages/Dashboard/Components/AnomaliesLiveChart.tsx b/public/pages/Dashboard/Components/AnomaliesLiveChart.tsx new file mode 100644 index 00000000..3f30118d --- /dev/null +++ b/public/pages/Dashboard/Components/AnomaliesLiveChart.tsx @@ -0,0 +1,267 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { DetectorListItem } from '../../../models/interfaces'; +import { + AD_DOC_FIELDS, + MIN_IN_MILLI_SECS, +} from '../../../../server/utils/constants'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingChart, + //@ts-ignore + EuiStat, +} from '@elastic/eui'; +import { searchES } from '../../../redux/reducers/elasticsearch'; +import { get, isEmpty } from 'lodash'; +import moment, { Moment } from 'moment'; +import ContentPanel from '../../../components/ContentPanel/ContentPanel'; +import { + Chart, + Axis, + Settings, + Position, + BarSeries, + niceTimeFormatter, + ScaleType, + LineAnnotation, + AnnotationDomainTypes, + LineAnnotationDatum, +} from '@elastic/charts'; +import { EuiText, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { TIME_NOW_LINE_STYLE } from '../utils/constants'; +import { + visualizeAnomalyResultForXYChart, + getFloorPlotTime, + getLatestAnomalyResultsForDetectorsByTimeRange, +} from '../utils/utils'; +import { AppState } from '../../../redux/reducers'; + +export interface AnomaliesLiveChartProps { + allDetectorsSelected: boolean; + selectedDetectors: DetectorListItem[]; +} + +interface LiveTimeRangeState { + startDateTime: Moment; + endDateTime: Moment; +} + +const MAX_LIVE_DETECTORS = 10; + +export const AnomaliesLiveChart = (props: AnomaliesLiveChartProps) => { + const dispatch = useDispatch(); + + const [liveTimeRange, setLiveTimeRange] = useState({ + startDateTime: moment().subtract(30, 'minutes'), + endDateTime: moment(), + }); + + const elasticsearchState = useSelector( + (state: AppState) => state.elasticsearch + ); + + const [lastAnomalyResult, setLastAnomalyResult] = useState(); + + const [liveAnomalyData, setLiveAnomalyData] = useState([] as object[]); + + const getLiveAnomalyResults = async () => { + const finalLiveAnomalyResult = await getLatestAnomalyResultsForDetectorsByTimeRange( + searchES, + props.selectedDetectors, + '30m', + MAX_LIVE_DETECTORS, + dispatch + ); + + setLiveAnomalyData(finalLiveAnomalyResult); + if (!isEmpty(finalLiveAnomalyResult)) { + setLastAnomalyResult(finalLiveAnomalyResult[0]); + } else { + setLastAnomalyResult(undefined); + } + setLiveTimeRange({ + startDateTime: moment().subtract(30, 'minutes'), + endDateTime: moment(), + }); + }; + + useEffect(() => { + getLiveAnomalyResults(); + const id = setInterval(getLiveAnomalyResults, MIN_IN_MILLI_SECS); + return () => { + clearInterval(id); + }; + }, [props.selectedDetectors]); + + const timeFormatter = niceTimeFormatter([ + liveTimeRange.startDateTime.valueOf(), + liveTimeRange.endDateTime.valueOf(), + ]); + + const visualizedAnomalies = liveAnomalyData.flatMap(anomalyResult => + visualizeAnomalyResultForXYChart(anomalyResult) + ); + const prepareVisualizedAnomalies = ( + liveVisualizedAnomalies: object[] + ): object[] => { + // add data point placeholder at every minute, + // to ensure chart evenly distrubted + const existingPlotTimes = liveVisualizedAnomalies.map(anomaly => + getFloorPlotTime(get(anomaly, AD_DOC_FIELDS.PLOT_TIME, 0)) + ); + const result = [...liveVisualizedAnomalies]; + + for ( + let currentTime = getFloorPlotTime(liveTimeRange.startDateTime.valueOf()); + currentTime <= liveTimeRange.endDateTime.valueOf(); + currentTime += MIN_IN_MILLI_SECS + ) { + if (existingPlotTimes.includes(currentTime)) { + continue; + } + result.push({ + [AD_DOC_FIELDS.DETECTOR_NAME]: '', + [AD_DOC_FIELDS.PLOT_TIME]: currentTime, + [AD_DOC_FIELDS.ANOMALY_GRADE]: null, + }); + } + return result; + }; + + const timeNowAnnotation = { + dataValue: getFloorPlotTime(liveTimeRange.endDateTime.valueOf()), + header: 'Now', + details: liveTimeRange.endDateTime.format('MM/DD/YY h:mm a'), + } as LineAnnotationDatum; + + const annotations = [timeNowAnnotation]; + + // Add View full screen button + // Issue link: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/issues/26 + return ( + +

{'Live Anomalies'}

+ , + {'Live'}, + ]} + subTitle={ + + +

+ {'Live anomaly results across detectors for the last 30 minutes'} +

+
+
+ } + > + + + + + + + + + + + +
+ {elasticsearchState.requesting ? ( + + + + + + + + + + + + ) : ( + [ + +

10 detectors with the most recent anomaly occurrence

+
, + + + + + + + , + ] + )} +
+
+ ); +}; diff --git a/public/pages/Dashboard/Components/AnomalousDetectorsList.tsx b/public/pages/Dashboard/Components/AnomalousDetectorsList.tsx new file mode 100644 index 00000000..9e398675 --- /dev/null +++ b/public/pages/Dashboard/Components/AnomalousDetectorsList.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { DetectorListItem } from '../../../models/interfaces'; +import ContentPanel from '../../../components/ContentPanel/ContentPanel'; +import { + //@ts-ignore + EuiBasicTable, +} from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { anomalousDetectorsStaticColumn } from '../utils/utils'; +import { SORT_DIRECTION } from '../../../../server/utils/constants'; +import { MAX_DETECTORS } from '../../../utils/constants'; +import { get, orderBy } from 'lodash'; + +export interface AnomalousDetectorsListProps { + selectedDetectors: DetectorListItem[]; +} + +export const AnomalousDetectorsList = (props: AnomalousDetectorsListProps) => { + const [fieldForSort, setFieldForSort] = useState('name'); + const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); + const [indexOfPage, setIndexOfPage] = useState(0); + const [sizeOfPage, setSizeOfPage] = useState(10); + + const sorting = { + sort: { + direction: sortDirection, + field: fieldForSort, + }, + }; + const pagination = { + pageIndex: indexOfPage, + pageSize: sizeOfPage, + totalItemCount: Math.min(MAX_DETECTORS, props.selectedDetectors.length), + pageSizeOptions: [5, 10, 20, 50], + }; + + useEffect(() => {}); + const handleTableChange = ({ page: tablePage = {}, sort = {} }: any) => { + const { index: page, size } = tablePage; + const { field: sortField, direction: direction } = sort; + setIndexOfPage(page); + setSizeOfPage(size); + setSortDirection(direction); + setFieldForSort(sortField); + }; + + const getOrderedDetectorsForPage = ( + selectedDetectors: DetectorListItem[], + pageIdx: number, + sizePerPage: number, + directionForSort: SORT_DIRECTION, + fieldForSort: string + ) => { + const orderedDetectors = orderBy( + selectedDetectors, + detector => get(detector, fieldForSort, ''), + directionForSort + ); + return orderedDetectors.slice( + pageIdx * sizePerPage, + (pageIdx + 1) * sizePerPage + ); + }; + + return ( + + + + ); +}; diff --git a/public/pages/Dashboard/Container/Dashboard.tsx b/public/pages/Dashboard/Container/Dashboard.tsx index 11a01741..1da34348 100644 --- a/public/pages/Dashboard/Container/Dashboard.tsx +++ b/public/pages/Dashboard/Container/Dashboard.tsx @@ -13,79 +13,59 @@ * permissions and limitations under the License. */ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useState, Fragment } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { AppState } from '../../../redux/reducers'; import { getDetectorList } from '../../../redux/reducers/ad'; import { SORT_DIRECTION } from '../../../../server/utils/constants'; import { EmptyDashboard } from '../Components/EmptyDashboard/EmptyDashboard'; -import { - EuiPageHeaderSection, - EuiTitle, - EuiPageHeader, - EuiPage, - EuiPageBody, - EuiLoadingSpinner, -} from '@elastic/eui'; -import { SideBar } from '../../utils/SideBar'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { DashboardHeader } from '../Components/utils/DashboardHeader'; +import { DashboardOverview } from './DashboardOverview'; export const Dashboard = () => { const dispatch = useDispatch(); - const isLoading = useSelector((state: AppState) => state.ad.requesting); + const [isLoading, setIsLoading] = useState(true); - // useCallBack ensures we don't recreate the funciton - const onRefreshPage = useCallback( - () => - dispatch( - getDetectorList({ - from: 0, - size: 1, - search: '', - sortDirection: SORT_DIRECTION.DESC, - sortField: 'name', - }) - ), - [] - ); + const onRefreshPage = async () => { + await dispatch( + getDetectorList({ + from: 0, + size: 1, + search: '', + sortDirection: SORT_DIRECTION.DESC, + sortField: 'name', + }) + ); + setIsLoading(false); + }; const totalDetectors = useSelector( (state: AppState) => state.ad.totalDetectors ); - // onRefreshPage is called whenever onRefreshPage funciton is recreated useEffect(() => { onRefreshPage(); - }, [onRefreshPage]); - + }, []); return ( - - - - - {isLoading == true ? ( -
- -    - -    - -    - -
- ) : totalDetectors == 0 ? ( - - ) : ( - - - -

Page under construction

-
-
-
- )} -
-
+ + + {isLoading == true ? ( +
+ +    + +    + +    + +
+ ) : totalDetectors == 0 ? ( + + ) : ( + + )} +
); }; diff --git a/public/pages/Dashboard/Container/DashboardOverview.tsx b/public/pages/Dashboard/Container/DashboardOverview.tsx new file mode 100644 index 00000000..05cf7970 --- /dev/null +++ b/public/pages/Dashboard/Container/DashboardOverview.tsx @@ -0,0 +1,254 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import React, { Fragment, useState, useEffect } from 'react'; +import { AnomaliesLiveChart } from '../Components/AnomaliesLiveChart'; +import { AnomaliesDistributionChart } from '../Components/AnomaliesDistribution'; + +import { useDispatch, useSelector } from 'react-redux'; +import { get, isEmpty, cloneDeep } from 'lodash'; + +import { DetectorListItem } from '../../../models/interfaces'; +import { getIndices, getAliases } from '../../../redux/reducers/elasticsearch'; +import { getDetectorList } from '../../../redux/reducers/ad'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiComboBox, + EuiComboBoxOptionProps, + EuiSpacer, +} from '@elastic/eui'; +import { AnomalousDetectorsList } from '../Components/AnomalousDetectorsList'; +import { + GET_ALL_DETECTORS_QUERY_PARAMS, + ALL_DETECTORS_MESSAGE, + ALL_DETECTOR_STATES_MESSAGE, + ALL_INDICES_MESSAGE, +} from '../utils/constants'; +import { AppState } from '../../../redux/reducers'; +import { CatIndex, IndexAlias } from '../../../../server/models/types'; +// import { getVisibleOptions } from '../../../pages/createDetector/containers/DataSource/utils/helpers'; +import { getVisibleOptions } from '../../utils/helpers'; +import { + DETECTOR_STATE, + PLUGIN_NAME, + APP_PATH, +} from '../../../utils/constants'; + +export function DashboardOverview() { + const dispatch = useDispatch(); + + const allDetectorList = useSelector( + (state: AppState) => state.ad.detectorList + ); + + const [currentDetectors, setCurrentDetectors] = useState( + Object.values(allDetectorList) + ); + + const [allDetectorsSelected, setAllDetectorsSelected] = useState(true); + + const [selectedDetectorsName, setSelectedDetectorsName] = useState( + [] as string[] + ); + const getDetectorOptions = (detectorsIdMap: { + [key: string]: DetectorListItem; + }) => { + const detectorNames = Object.values(detectorsIdMap).map( + detectorListItem => { + return detectorListItem.name; + } + ); + return detectorNames.map(buildItemOption); + }; + + const buildItemOption = (name: string) => { + return { + label: name, + }; + }; + + const handleDetectorsFilterChange = ( + options: EuiComboBoxOptionProps[] + ): void => { + const selectedNames = options.map(option => option.label); + + setSelectedDetectorsName(selectedNames); + setAllDetectorsSelected(isEmpty(selectedNames)); + }; + + const allDetectorStates = Object.values(DETECTOR_STATE); + + const [selectedDetectorStates, setSelectedDetectorStates] = useState( + [] as string[] + ); + // TODO: DetectorStates is placeholder for now until backend profile API is ready + // Issue link: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/issues/25 + const [allDetectorStatesSelected, setAllDetectorStatesSelected] = useState( + true + ); + + const getDetectorStateOptions = (states: string[]) => { + return states.map(state => { + return { label: state }; + }); + }; + + const handleDetectorStateFilterChange = ( + options: EuiComboBoxOptionProps[] + ): void => { + const selectedStates = options.map(option => option.label); + setSelectedDetectorStates(selectedStates); + setAllDetectorStatesSelected(isEmpty(selectedStates)); + }; + + const elasticsearchState = useSelector( + (state: AppState) => state.elasticsearch + ); + + const [selectedIndices, setSelectedIndices] = useState([] as string[]); + const [allIndicesSelected, setAllIndicesSelected] = useState(true); + + const visibleIndices = get(elasticsearchState, 'indices', []) as CatIndex[]; + const visibleAliases = get(elasticsearchState, 'aliases', []) as IndexAlias[]; + + const handleIndicesFilterChange = ( + options: EuiComboBoxOptionProps[] + ): void => { + const selectedIndices = options.map(option => option.label); + setSelectedIndices(selectedIndices); + setAllIndicesSelected(isEmpty(selectedIndices)); + }; + + const filterSelectedDetectors = async ( + selectedNameList: string[], + // TODO: DetectorStates is placeholder for now until backend profile API is ready + // Issue link: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/issues/25 + selectedStateList: string[], + selectedIndexList: string[] + ) => { + let detectorsToFilter: DetectorListItem[]; + if (allDetectorsSelected) { + detectorsToFilter = cloneDeep(Object.values(allDetectorList)); + } else { + detectorsToFilter = cloneDeep(Object.values(allDetectorList)).filter( + detectorItem => selectedNameList.includes(detectorItem.name) + ); + } + + let filteredDetectorItemsByNamesAndIndex = detectorsToFilter; + if (!allIndicesSelected) { + filteredDetectorItemsByNamesAndIndex = detectorsToFilter.filter( + detectorItem => + selectedIndexList.includes(detectorItem.indices.toString()) + ); + } + + setCurrentDetectors(filteredDetectorItemsByNamesAndIndex); + }; + + const intializeDetectors = () => { + dispatch(getDetectorList(GET_ALL_DETECTORS_QUERY_PARAMS)); + dispatch(getIndices('')); + dispatch(getAliases('')); + }; + + useEffect(() => { + intializeDetectors(); + }, []); + + useEffect(() => { + // this is needed because on Dashboard.tsx, getDetectorList API is called as 1s time, but it only gets 1 detector + // and such result is rendered to this component since this component also relies on result from getDetectorList API. + // And after this component does call getDetectorList to get all + // detectors, we need to refresh the page with result from 2nd call + // TODO: we need to implement namespacing for redux as per https://tiny.amazon.com/3eszqzpx/stacques4290 + // Issue link: https://github.com/opendistro-for-elasticsearch/anomaly-detection-kibana-plugin/issues/23 + setCurrentDetectors(Object.values(allDetectorList)); + }, [allDetectorList]); + + useEffect(() => { + filterSelectedDetectors( + selectedDetectorsName, + selectedDetectorStates, + selectedIndices + ); + }, [selectedDetectorsName, selectedIndices, selectedDetectorStates]); + + return ( + + + + Create detector + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/public/pages/Dashboard/index.scss b/public/pages/Dashboard/index.scss new file mode 100644 index 00000000..e4ce35bc --- /dev/null +++ b/public/pages/Dashboard/index.scss @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +.live-anomaly-results-subtile { + height: 14px; + width: 376px; + color: #879196; + font-family: 'Helvetica Neue'; + font-size: 12px; + letter-spacing: 0; + line-height: 14px; +} + +.anomaly-distribution-subtitle { + height: 28px; + width: 505px; + color: #879196; + font-family: 'Helvetica Neue'; + font-size: 12px; + letter-spacing: 0; + line-height: 14px; +} + +.live-badge { + height: 20px; + width: 46px; + border-radius: 2px; + background-color: #db1374; +} + +.anomalies-distribution-sunburst { + height: 443px; + width: 441px; +} diff --git a/public/pages/Dashboard/utils/constants.ts b/public/pages/Dashboard/utils/constants.ts new file mode 100644 index 00000000..00850fb8 --- /dev/null +++ b/public/pages/Dashboard/utils/constants.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + AD_DOC_FIELDS, + SORT_DIRECTION, +} from '../../../../server/utils/constants'; +import { ANOMALY_RESULT_INDEX, MAX_DETECTORS } from '../../../utils/constants'; + +export const TIME_RANGE_OPTIONS = [ + { value: '24h', text: 'Last 24 hours' }, + { value: '7d', text: 'Last 7 days' }, +]; + +export const GET_RECENT_ANOMALOUS_DETECTORS_QUERY = { + index: ANOMALY_RESULT_INDEX, + size: 10, + query: { + bool: { + must: [ + { + range: { + [AD_DOC_FIELDS.ANOMALY_GRADE]: { + gt: 0.0, + }, + }, + }, + ], + must_not: [ + { + exists: { + field: AD_DOC_FIELDS.ERROR, + }, + }, + ], + }, + }, + sort: { + [AD_DOC_FIELDS.DATA_START_TIME]: SORT_DIRECTION.DESC, + }, + collapse: { + field: AD_DOC_FIELDS.DETECTOR_ID, + }, +}; + +export const GET_RECENT_ANOMALY_RESULT_QUERY = { + range: { + [AD_DOC_FIELDS.DATA_START_TIME]: { + gte: 'now-30m', + }, + }, + size: 30, + sortField: AD_DOC_FIELDS.DATA_START_TIME, + from: 0, + sortDirection: SORT_DIRECTION.DESC, +}; + +export const TIME_NOW_LINE_STYLE = { + line: { + strokeWidth: 1, + stroke: '#3F3F3F', + dash: [1, 2], + opacity: 0.8, + }, +}; + +export const GET_ALL_DETECTORS_QUERY_PARAMS = { + from: 0, + search: '', + indices: '', + size: MAX_DETECTORS, + sortDirection: SORT_DIRECTION.ASC, + sortField: 'name', +}; + +export const ALL_DETECTORS_MESSAGE = 'All detectors'; +export const ALL_DETECTOR_STATES_MESSAGE = 'All detector states'; +export const ALL_INDICES_MESSAGE = 'All indices'; diff --git a/public/pages/Dashboard/utils/utils.tsx b/public/pages/Dashboard/utils/utils.tsx new file mode 100644 index 00000000..47123fe4 --- /dev/null +++ b/public/pages/Dashboard/utils/utils.tsx @@ -0,0 +1,583 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { + AD_DOC_FIELDS, + SORT_DIRECTION, + MIN_IN_MILLI_SECS, +} from '../../../../server/utils/constants'; +import { + Detector, + FeatureAttributes, + DetectorListItem, +} from '../../../models/interfaces'; +import { + PLUGIN_NAME, + ANOMALY_RESULT_INDEX, + MAX_ANOMALIES, + MAX_DETECTORS, +} from '../../../utils/constants'; +import { get, orderBy } from 'lodash'; +import { APIAction } from 'public/redux/middleware/types'; +import { useDispatch } from 'react-redux'; + +/** + * Get the recent anomaly result query for the last timeRange period(Date-Math) + * Given timeRange is 24h, it return a query which is used to get anomaly result + * within last 24 hours. + * This query can be only used for getAnomalyResults API in ad.ts file + * @param {[string]} timeRange [last time period which query is for] + * @returns query which is used to get anomaly result for the last timeRange period + */ +export const buildGetRecentAnomalyResultQuery = (timeRange: string) => { + return { + range: { + [AD_DOC_FIELDS.DATA_START_TIME]: { + gte: 'now-' + timeRange, + }, + }, + size: 30, + sortField: AD_DOC_FIELDS.DATA_START_TIME, + from: 0, + sortDirection: SORT_DIRECTION.DESC, + }; +}; + +export type RgbColor = [number, number, number, number?]; +export const rgbColors: RgbColor[] = [ + [46, 34, 235], + [49, 32, 237], + [52, 30, 238], + [56, 29, 239], + [59, 28, 240], + [63, 27, 241], + [66, 27, 242], + [70, 27, 242], + [73, 27, 243], + [77, 28, 244], + [80, 29, 244], + [84, 30, 245], + [87, 31, 245], + [91, 32, 246], + [94, 33, 246], + [97, 35, 246], + [100, 36, 247], + [103, 38, 247], + [106, 39, 248], + [109, 41, 248], + [112, 42, 248], + [115, 44, 249], + [118, 45, 249], + [121, 47, 249], + [123, 48, 250], + [126, 49, 250], + [129, 51, 250], + [132, 52, 251], + [135, 53, 251], + [137, 54, 251], + [140, 56, 251], + [143, 57, 251], + [146, 58, 252], + [149, 59, 252], + [152, 60, 252], + [155, 60, 252], + [158, 61, 252], + [162, 62, 252], + [165, 63, 252], + [168, 63, 252], + [171, 64, 252], + [175, 65, 252], + [178, 65, 252], + [181, 66, 252], + [185, 66, 252], + [188, 66, 252], + [191, 67, 252], + [195, 67, 252], + [198, 68, 252], + [201, 68, 251], + [204, 69, 251], + [207, 69, 251], + [211, 70, 251], + [214, 70, 251], + [217, 71, 250], + [219, 72, 250], + [222, 73, 250], + [225, 74, 249], + [227, 75, 249], + [230, 76, 248], + [232, 78, 247], + [234, 79, 246], + [236, 81, 245], + [238, 83, 244], + [240, 85, 243], + [242, 88, 241], + [243, 90, 240], + [244, 93, 238], + [245, 96, 236], + [246, 99, 234], + [247, 102, 232], + [248, 105, 230], + [249, 108, 227], + [249, 111, 225], + [250, 114, 223], + [250, 117, 220], + [251, 120, 217], + [251, 123, 215], + [252, 127, 212], + [252, 130, 210], + [252, 133, 207], + [252, 136, 204], + [252, 139, 201], + [253, 141, 199], + [253, 144, 196], + [253, 147, 193], + [253, 150, 190], + [253, 153, 188], + [253, 156, 185], + [253, 158, 182], + [253, 161, 179], + [253, 164, 177], + [253, 166, 174], + [253, 169, 171], + [253, 171, 168], + [253, 174, 165], + [252, 176, 162], + [252, 179, 160], + [252, 181, 157], + [252, 184, 154], + [252, 186, 151], + [253, 188, 148], + [253, 191, 145], + [253, 193, 142], + [253, 195, 139], + [253, 198, 136], + [253, 200, 133], + [253, 202, 130], + [253, 204, 127], + [253, 207, 124], + [253, 209, 120], + [253, 211, 117], + [253, 213, 114], + [253, 215, 110], + [253, 217, 107], + [253, 219, 104], + [253, 221, 100], + [252, 223, 96], + [252, 225, 93], + [252, 227, 89], + [251, 229, 85], + [250, 231, 81], + [250, 232, 77], + [249, 234, 73], + [248, 235, 69], + [246, 236, 65], + [245, 237, 61], + [243, 238, 57], + [242, 239, 54], + [240, 239, 50], + [238, 239, 46], + [235, 239, 43], + [233, 239, 40], + [231, 239, 37], + [228, 239, 35], + [225, 238, 33], + [223, 238, 31], + [220, 237, 29], + [217, 236, 27], + [214, 235, 26], + [211, 234, 25], + [209, 233, 24], + [206, 232, 24], + [203, 231, 23], + [200, 230, 22], + [197, 229, 22], + [194, 228, 21], + [191, 227, 21], + [188, 226, 21], + [185, 225, 20], + [182, 224, 20], + [179, 223, 20], + [176, 221, 19], + [173, 220, 19], + [170, 219, 19], + [167, 218, 18], + [164, 217, 18], + [161, 216, 17], + [158, 215, 17], + [154, 214, 17], + [151, 213, 16], + [148, 211, 16], + [145, 210, 16], + [142, 209, 15], + [139, 208, 15], + [136, 207, 15], + [132, 206, 14], + [129, 205, 14], + [126, 204, 14], + [122, 202, 13], + [119, 201, 13], + [116, 200, 13], + [112, 199, 13], + [109, 198, 12], + [105, 197, 12], + [102, 196, 12], + [98, 194, 12], + [94, 193, 12], + [91, 192, 12], + [87, 191, 12], + [83, 190, 13], + [79, 188, 14], + [76, 187, 15], + [72, 186, 16], + [68, 185, 18], + [65, 183, 20], + [62, 182, 22], + [59, 181, 25], + [56, 179, 27], + [54, 178, 30], + [52, 176, 34], + [51, 175, 37], + [50, 173, 40], + [50, 172, 44], + [50, 170, 48], + [51, 168, 51], + [52, 167, 55], + [53, 165, 59], + [54, 163, 63], + [56, 161, 67], + [57, 160, 71], + [59, 158, 74], + [60, 156, 78], + [62, 154, 82], + [63, 152, 86], + [64, 150, 90], + [66, 148, 93], + [67, 147, 97], + [67, 145, 101], + [68, 143, 104], + [69, 141, 108], + [69, 139, 111], + [69, 137, 115], + [70, 135, 118], + [70, 133, 122], + [69, 131, 125], + [69, 129, 129], + [69, 128, 132], + [68, 126, 135], + [67, 124, 139], + [67, 122, 142], + [66, 120, 145], + [64, 118, 149], + [63, 116, 152], + [62, 114, 155], + [60, 112, 158], + [59, 110, 162], + [57, 108, 165], + [56, 106, 168], + [54, 104, 171], + [53, 102, 174], + [51, 100, 177], + [50, 98, 180], + [48, 96, 183], + [47, 93, 185], + [46, 91, 188], + [45, 89, 191], + [44, 86, 193], + [43, 84, 196], + [42, 81, 199], + [41, 79, 201], + [40, 76, 204], + [40, 73, 206], + [39, 70, 209], + [38, 68, 211], + [38, 65, 213], + [37, 62, 216], + [37, 59, 218], + [37, 56, 220], + [37, 53, 222], + [37, 50, 224], + [37, 47, 227], + [38, 44, 228], + [40, 41, 230], + [42, 39, 232], + [44, 36, 234], +]; + +export function palleteBuilder(colors: RgbColor[]) { + return (d: number) => { + const index = Math.round(d * 255); + const [r, g, b, a] = colors[index]; + return colors[index].length === 3 + ? `rgb(${r},${g},${b})` + : `rgba(${r},${g},${b},${a})`; + }; +} +export const buildColors = palleteBuilder( + rgbColors.map(([r, g, b]) => [r, g, b, 0.8]) +); + +// referred to here: https://tiny.amazon.com/337xpvcq/githelaselasblobv1822stor +export const fillOutColors = (d: any, i: number, a: any[]) => { + return buildColors(i / (a.length + 1)); +}; + +export const anomalousDetectorsStaticColumn = [ + { + field: 'name', + name: 'Detector', + sortable: true, + truncateText: false, + textOnly: true, + width: '150px', + render: (name: string, detector: Detector) => ( + + {name} + + ), + }, + { + field: 'featureAttributes', + name: 'features', + sortable: false, + truncateText: false, + textOnly: true, + width: '150px', + render: (featureAttributes: FeatureAttributes[]) => { + return featureAttributes.map(feature => { + return

{feature.featureName}

; + }); + }, + }, +]; + +export const visualizeAnomalyResultForSunburstChart = ( + anomalyResults: any[], + detectors: DetectorListItem[] +): object[] => { + const detectorAnomalyResultMap = buildDetectorAnomalyResultMap( + anomalyResults, + detectors + ); + const visualizedResult = [] as object[]; + for (let detectorInfo of detectorAnomalyResultMap.values()) { + visualizedResult.push(detectorInfo); + } + return visualizedResult; +}; + +const buildDetectorAnomalyResultMap = ( + anomalyResults: any[], + detectors: DetectorListItem[] +): Map => { + const detectorAndIdMap = buildDetectorAndIdMap(detectors); + const detectorAnomalyResultMap = new Map(); + anomalyResults.forEach(anomalyResult => { + const detectorId = get(anomalyResult, AD_DOC_FIELDS.DETECTOR_ID, ''); + const detector = detectorAndIdMap.get(detectorId); + if (detectorAnomalyResultMap.has(detectorId)) { + const detectorInfo = detectorAnomalyResultMap.get(detectorId); + let currentCount = get(detectorInfo, 'count', 0); + currentCount++; + detectorAnomalyResultMap.set( + detectorId, + Object.assign({}, detectorInfo, { count: currentCount }) + ); + } else { + detectorAnomalyResultMap.set(detectorId, { + [AD_DOC_FIELDS.DETECTOR_NAME]: get( + anomalyResult, + AD_DOC_FIELDS.DETECTOR_NAME, + '' + ), + [AD_DOC_FIELDS.INDICES]: get( + detector, + AD_DOC_FIELDS.INDICES, + '' + ).toString(), + count: 1, + }); + } + }); + return detectorAnomalyResultMap; +}; + +export const visualizeAnomalyResultForXYChart = ( + anomalyResult: any +): object => { + return { + ...anomalyResult, + [AD_DOC_FIELDS.PLOT_TIME]: getFloorPlotTime( + get(anomalyResult, AD_DOC_FIELDS.DATA_START_TIME, 0) + ), + }; +}; + +export const getFloorPlotTime = (plotTime: number): number => { + return Math.floor(plotTime / MIN_IN_MILLI_SECS) * MIN_IN_MILLI_SECS; +}; + +export const buildGetAnomalyResultQueryByRange = ( + timeRange: string, + from: number, + size: number +) => { + return { + index: `${ANOMALY_RESULT_INDEX}*`, + size: size, + from: from, + query: { + bool: { + must: [ + { + range: { + [AD_DOC_FIELDS.ANOMALY_GRADE]: { + gt: 0.0, + }, + }, + }, + { + range: { + [AD_DOC_FIELDS.DATA_START_TIME]: { + gte: `now-${timeRange}`, + }, + }, + }, + ], + must_not: [ + { + exists: { + field: AD_DOC_FIELDS.ERROR, + }, + }, + ], + }, + }, + sort: { + [AD_DOC_FIELDS.DATA_START_TIME]: SORT_DIRECTION.DESC, + }, + }; +}; + +export const getLatestAnomalyResultsForDetectorsByTimeRange = async ( + func: (request: any) => APIAction, + selectedDetectors: DetectorListItem[], + timeRange: string, + detectorNum = MAX_DETECTORS, + dispatch = useDispatch() +): Promise => { + const detectorAndIdMap = buildDetectorAndIdMap(selectedDetectors); + let from = 0; + let numResults: number; + let anomalyResults = [] as object[]; + do { + const searchResponse = await dispatch( + func(buildGetAnomalyResultQueryByRange(timeRange, from, MAX_ANOMALIES)) + ); + const searchAnomalyResponse = searchResponse.data.response; + + numResults = get(searchAnomalyResponse, 'hits.total.value', 0); + if (numResults === 0) { + break; + } + + const anomalies: any[] = get(searchAnomalyResponse, 'hits.hits', []).map( + (result: any) => { + const detector = detectorAndIdMap.get(result._source.detector_id); + return { + [AD_DOC_FIELDS.DETECTOR_ID]: result._source.detector_id, + [AD_DOC_FIELDS.ANOMALY_GRADE]: Number( + result._source.anomaly_grade + ).toFixed(2), + [AD_DOC_FIELDS.DATA_START_TIME]: result._source.data_start_time, + [AD_DOC_FIELDS.DATA_END_TIME]: result._source.data_end_time, + [AD_DOC_FIELDS.DETECTOR_NAME]: get( + detector, + AD_DOC_FIELDS.DETECTOR_NAME, + '' + ), + }; + } + ); + anomalyResults = [...anomalyResults, ...anomalies]; + from++; + } while (numResults === MAX_ANOMALIES); + + const filteredAnomalyResults = anomalyResults.filter(anomaly => + detectorAndIdMap.has(get(anomaly, AD_DOC_FIELDS.DETECTOR_ID, '')) + ); + + const orderedLiveAnomalyData = orderBy( + filteredAnomalyResults, + // sort by data start time in desc order + anomalyData => get(anomalyData, AD_DOC_FIELDS.DATA_START_TIME, ''), + SORT_DIRECTION.DESC + ); + + const latestDetetorIds = selectLatestDetetorIds( + orderedLiveAnomalyData, + detectorNum + ); + + const finalLiveAnomalyResult = orderedLiveAnomalyData.filter(anomalyData => + latestDetetorIds.includes(get(anomalyData, AD_DOC_FIELDS.DETECTOR_ID, '')) + ); + return finalLiveAnomalyResult; +}; + +const buildDetectorAndIdMap = ( + selectedDetectors: DetectorListItem[] +): Map => { + const detectorAndIdMap = new Map(); + if (selectedDetectors) { + selectedDetectors.forEach(detector => { + detectorAndIdMap.set(detector.id, detector); + }); + } + return detectorAndIdMap; +}; + +const selectLatestDetetorIds = ( + orderedAnomalyData: object[], + needeDetectorNum: number +): string[] => { + const uniqueIds = [ + ...new Set( + orderedAnomalyData.map(anomalyData => + get(anomalyData, AD_DOC_FIELDS.DETECTOR_ID, '') + ) + ), + ]; + if (uniqueIds.length <= needeDetectorNum) { + return uniqueIds; + } + const latestDetectorIds = [] as string[]; + for (let anomalyData of orderedAnomalyData) { + if ( + !latestDetectorIds.includes( + get(anomalyData, AD_DOC_FIELDS.DETECTOR_ID, '') + ) + ) { + } + if (latestDetectorIds.length === needeDetectorNum) { + return latestDetectorIds; + } + } + return latestDetectorIds; +}; diff --git a/public/pages/DetectorResults/utils/constants.ts b/public/pages/DetectorResults/utils/constants.ts index 0f054962..8f317d35 100644 --- a/public/pages/DetectorResults/utils/constants.ts +++ b/public/pages/DetectorResults/utils/constants.ts @@ -13,12 +13,15 @@ * permissions and limitations under the License. */ -import { SORT_DIRECTION } from '../../../../server/utils/constants'; +import { + SORT_DIRECTION, + AD_DOC_FIELDS, +} from '../../../../server/utils/constants'; export const DEFAULT_QUERY_PARAMS = { from: 0, search: '', size: 20, sortDirection: SORT_DIRECTION.ASC, - sortField: 'startTime', + sortField: AD_DOC_FIELDS.DATA_START_TIME, }; diff --git a/public/pages/main/Main.tsx b/public/pages/main/Main.tsx index 98fbb4ca..014bdb7c 100644 --- a/public/pages/main/Main.tsx +++ b/public/pages/main/Main.tsx @@ -24,6 +24,7 @@ import { EuiSideNav, EuiPage, EuiPageBody, EuiPageSideBar } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { APP_PATH } from '../../utils/constants'; import { DetectorDetail } from '../DetectorDetail'; +import { Dashboard } from '../Dashboard/Container/Dashboard'; enum Navigation { AnomalyDetection = 'Anomaly detection', @@ -73,10 +74,7 @@ export function Main(props: MainProps) { ( - // place holder for DashboardOverview, please replace with your page -
place holder for DashboardOverview
- )} + render={(props: RouteComponentProps) => } /> ( export const getDetectorResults = ( detectorId: string, - queryParams: DetectorResultsQueryParams + queryParams: any ): APIAction => ({ type: DETECTOR_RESULTS, request: (client: IHttpService) => diff --git a/public/utils/constants.ts b/public/utils/constants.ts index 69496c4a..3c86a154 100644 --- a/public/utils/constants.ts +++ b/public/utils/constants.ts @@ -40,3 +40,15 @@ export const APP_PATH = { export const PLUGIN_NAME = 'opendistro-anomaly-detection'; export const ALERTING_PLUGIN_NAME = 'opendistro-alerting'; + +export const ANOMALY_RESULT_INDEX = '.opendistro-anomaly-results'; + +export const MAX_DETECTORS = 1000; + +export const MAX_ANOMALIES = 10000; + +export const DETECTOR_STATE = Object.freeze({ + DISABLED: 'Disabled', + INIT: 'Initializing', + RUNNING: 'Running', +}); diff --git a/server/models/types.ts b/server/models/types.ts index 13eec27e..009d7480 100644 --- a/server/models/types.ts +++ b/server/models/types.ts @@ -83,7 +83,7 @@ export type GetDetectorsQueryParams = { from: number; size: number; search: string; - indices: string; + indices?: string; sortDirection: SORT_DIRECTION; sortField: string; }; @@ -93,6 +93,7 @@ export type DetectorResultsQueryParams = { size: number; sortDirection: SORT_DIRECTION; sortField: string; + range?: object; }; export type AnomalyResult = { diff --git a/server/routes/ad.ts b/server/routes/ad.ts index 47e6f732..e5e17227 100644 --- a/server/routes/ad.ts +++ b/server/routes/ad.ts @@ -25,12 +25,11 @@ import { AnomalyResult, AnomalyResultsResponse, Detector, - DetectorResultsQueryParams, GetDetectorsQueryParams, ServerResponse, } from '../models/types'; import { Router } from '../router'; -import { SORT_DIRECTION } from '../utils/constants'; +import { SORT_DIRECTION, AD_DOC_FIELDS } from '../utils/constants'; import { mapKeysDeep, toCamel, toSnake } from '../utils/helpers'; import { anomalyResultMapper, @@ -353,12 +352,12 @@ const getDetectors = async ( (acc: any, detector: any) => ({ ...acc, [detector._id]: { - name: get(detector, '_source.name', ''), id: detector._id, description: get(detector, '_source.description', ''), indices: get(detector, '_source.indices', []), lastUpdateTime: get(detector, '_source.last_update_time', 0), // TODO: get the state of the detector once possible (enabled/disabled for now) + ...convertDetectorKeysToCamelCase(get(detector, '_source', {})), }, }), {} @@ -492,23 +491,41 @@ const getAnomalyResults = async ( from = 0, size = 20, sortDirection = SORT_DIRECTION.DESC, - sortField = 'startTime', + sortField = AD_DOC_FIELDS.DATA_START_TIME, + range = undefined, //@ts-ignore - } = req.query as DetectorResultsQueryParams; + } = req.query as { + from: number; + size: number; + sortDirection: SORT_DIRECTION; + sortField?: string; + range?: any; + }; const { detectorId } = req.params; //Allowed sorting columns const sortQueryMap = { anomalyGrade: { anomaly_grade: sortDirection }, confidence: { confidence: sortDirection }, - startTime: { data_start_time: sortDirection }, - endTime: { data_end_time: sortDirection }, + [AD_DOC_FIELDS.DATA_START_TIME]: { + [AD_DOC_FIELDS.DATA_START_TIME]: sortDirection, + }, + [AD_DOC_FIELDS.DATA_END_TIME]: { + [AD_DOC_FIELDS.DATA_END_TIME]: sortDirection, + }, } as { [key: string]: object }; let sort = {}; const sortQuery = sortQueryMap[sortField]; if (sortQuery) { sort = sortQuery; } + + let rangeObj = range; + + if (range !== undefined && typeof range === 'string') { + rangeObj = JSON.parse(range); + } + //Preparing search request const requestBody = { sort, @@ -516,17 +533,22 @@ const getAnomalyResults = async ( from, query: { bool: { - filter: { - term: { - detector_id: detectorId, + filter: [ + { + term: { + detector_id: detectorId, + }, }, - }, + { ...(rangeObj !== undefined && { range: rangeObj }) }, + ], }, }, }; + const response = await callWithRequest(req, 'ad.searchResults', { body: requestBody, }); + const totalResults: number = get(response, 'hits.total.value', 0); // Get all detectors from search detector API const detectorResults: AnomalyResult[] = get(response, 'hits.hits', []).map( diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 6b408905..cd0792eb 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -29,6 +29,10 @@ export const DEFAULT_HEADERS: DefaultHeaders = { 'User-Agent': 'Kibana', }; +export const SEC_IN_MILLI_SECS = 1000; + +export const MIN_IN_MILLI_SECS = 60 * SEC_IN_MILLI_SECS; + export enum CLUSTER { ADMIN = 'admin', AES_AD = 'aes_ad', @@ -39,3 +43,14 @@ export enum SORT_DIRECTION { ASC = 'asc', DESC = 'desc', } + +export enum AD_DOC_FIELDS { + DATA_START_TIME = 'data_start_time', + DATA_END_TIME = 'data_end_time', + DETECTOR_ID = 'detector_id', + DETECTOR_NAME = 'name', + PLOT_TIME = 'plot_time', + ANOMALY_GRADE = 'anomaly_grade', + ERROR = 'error', + INDICES = 'indices', +} diff --git a/yarn.lock b/yarn.lock index 5ea56a90..d7711928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -933,6 +933,29 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@elastic/charts@^18.2.2": + version "18.2.2" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-18.2.2.tgz#f59d6ee597d553d193314d8598561c65da787e8d" + integrity sha512-ss8AqLj9wHa2C+9ULUKbXw8ZCQmEjLuaVU5AkqE2j3hOVtAN75HO2p7nMIsxcSldfmqy+4jSptybJLNAfizegQ== + dependencies: + classnames "^2.2.6" + d3-array "^1.2.4" + d3-collection "^1.0.7" + d3-color "^1.4.0" + d3-scale "^1.0.7" + d3-shape "^1.3.4" + newtype-ts "^0.2.4" + path2d-polyfill "^0.4.2" + prop-types "^15.7.2" + re-reselect "^3.4.0" + react-redux "^7.1.0" + redux "^4.0.4" + reselect "^4.0.0" + resize-observer-polyfill "^1.5.1" + ts-debounce "^1.0.0" + utility-types "^3.10.0" + uuid "^3.3.2" + "@elastic/eslint-config-kibana@link:../../packages/eslint-config-kibana": version "0.0.0" uid "" @@ -2361,7 +2384,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5: +classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -2684,12 +2707,12 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0: +d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0, d3-array@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== -d3-collection@1, d3-collection@^1.0.3: +d3-collection@1, d3-collection@^1.0.3, d3-collection@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== @@ -2699,6 +2722,11 @@ d3-color@1, d3-color@^1.0.3: resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.3.0.tgz#675818359074215b020dc1d41d518136dcb18fa9" integrity sha512-NHODMBlj59xPAwl2BDiO2Mog6V+PrGRtBfWKqKRrs9MCqlSkIEb0Z/SfY7jW29ReHTDC/j+vwXhnZcXI3+3fbg== +d3-color@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.0.tgz#89c45a995ed773b13314f06460df26d60ba0ecaf" + integrity sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg== + d3-contour@^1.1.0: version "1.3.2" resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.2.tgz#652aacd500d2264cb3423cee10db69f6f59bead3" @@ -2749,7 +2777,7 @@ d3-sankey@^0.7.1: d3-collection "1" d3-shape "^1.2.0" -d3-scale@^1.0.5: +d3-scale@^1.0.5, d3-scale@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" integrity sha512-KvU92czp2/qse5tUfGms6Kjig0AhHOwkzXG0+PqIJB3ke0WUv088AHMZI0OssO9NCkXt4RP8yju9rpH8aGB7Lw== @@ -2769,6 +2797,13 @@ d3-shape@^1.1.0, d3-shape@^1.2.0: dependencies: d3-path "1" +d3-shape@^1.3.4: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + d3-time-format@2: version "2.1.3" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b" @@ -3687,6 +3722,11 @@ formik@^1.5.8: tiny-warning "^1.0.2" tslib "^1.9.3" +fp-ts@^1.0.0: + version "1.19.5" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.19.5.tgz#3da865e585dfa1fdfd51785417357ac50afc520a" + integrity sha512-wDNqTimnzs8QqpldiId9OavWK2NptormjXnRJTQecNjzwfyp6P/8s/zG8e4h3ja3oqkKaY72UlTjQYt/1yXf9A== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -5375,6 +5415,11 @@ moment-timezone@^0.5.26: resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== +monocle-ts@^1.0.0: + version "1.7.2" + resolved "https://registry.yarnpkg.com/monocle-ts/-/monocle-ts-1.7.2.tgz#d9825ae18846ab63f915cb6f2194a78a40025610" + integrity sha512-F08hPUzQ14vOtac2vOagnvXPr0R0MRKWXF6Bwd3gQ4XnV2qfU0MzPL+L18kX4dXBkat74pxbL88V1BjAj3YOWg== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -5448,6 +5493,14 @@ neo-async@^2.5.0, neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== +newtype-ts@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/newtype-ts/-/newtype-ts-0.2.4.tgz#a02a8f160a3d179f871848d687a93de73a964a41" + integrity sha512-HrzPdG0+0FK1qHbc3ld/HXu252OYgmN993bFxUtZ6NFCLUk1eq+yKwdvP07BblXQibGqMWNXBUrNoLUq23Ma2Q== + dependencies: + fp-ts "^1.0.0" + monocle-ts "^1.0.0" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -6014,6 +6067,11 @@ path-type@^3.0.0: dependencies: pify "^3.0.0" +path2d-polyfill@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" + integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== + pbkdf2@^3.0.3: version "3.0.17" resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6" @@ -6305,6 +6363,11 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-reselect@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/re-reselect/-/re-reselect-3.4.0.tgz#0f2303f3c84394f57f0cd31fea08a1ca4840a7cd" + integrity sha512-JsecfN+JlckncVXTWFWjn0Vk6uInl8GSf4eEd9tTk5qXHlgqkPdILpnYpgZcISXNYAzvfvsCZviaDk8AxyS5sg== + react-ace@^5.5.0: version "5.10.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.10.0.tgz#e328b37ac52759f700be5afdb86ada2f5ec84c5e" @@ -6563,6 +6626,14 @@ redux@^4.0.0: loose-envify "^1.4.0" symbol-observable "^1.2.0" +redux@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" @@ -6736,7 +6807,7 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== -resize-observer-polyfill@^1.5.0: +resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== @@ -7629,6 +7700,11 @@ trim-right@^1.0.1: dependencies: glob "^7.1.2" +ts-debounce@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-1.0.0.tgz#e433301744ba75fe25466f7f23e1382c646aae6a" + integrity sha512-V+IzWj418IoqqxVJD6I0zjPtgIyvAJ8VyViqzcxZ0JRiJXsi5mCmy1yUKkWd2gUygT28a8JsVFCgqdrf2pLUHQ== + tslib@^1.9.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" @@ -7798,6 +7874,11 @@ util@^0.11.0: dependencies: inherits "2.0.3" +utility-types@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" + integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== + uuid@^3.1.0, uuid@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"