diff --git a/x-pack/platform/plugins/shared/ml/common/types/results.ts b/x-pack/platform/plugins/shared/ml/common/types/results.ts index f6d8caaa512a6..5bb733a137943 100644 --- a/x-pack/platform/plugins/shared/ml/common/types/results.ts +++ b/x-pack/platform/plugins/shared/ml/common/types/results.ts @@ -8,10 +8,19 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { LineAnnotationDatum, RectAnnotationDatum } from '@elastic/charts'; import type { ErrorType } from '@kbn/ml-error-utils'; -import type { ES_AGGREGATION, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils'; +import type { + ES_AGGREGATION, + ML_JOB_AGGREGATION, + MlAnomaliesTableRecord, +} from '@kbn/ml-anomaly-utils'; import { type MlEntityField, type MlRecordForInfluencer } from '@kbn/ml-anomaly-utils'; import type { Datafeed, JobId, ModelSnapshot } from './anomaly_detection_jobs'; +export interface GetAnomaliesTableDataResult { + anomalies: MlAnomaliesTableRecord[]; + interval: string; + examplesByJobId?: Record>; +} export interface GetStoppedPartitionResult { jobs: string[] | Record; } diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table.js deleted file mode 100644 index 4429a3022122e..0000000000000 --- a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table.js +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * React table for displaying a list of anomalies. - */ - -import PropTypes from 'prop-types'; -import { get } from 'lodash'; - -import React, { Component } from 'react'; - -import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiText } from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { usePageUrlState } from '@kbn/ml-url-state'; -import { context } from '@kbn/kibana-react-plugin/public'; - -import { getColumns } from './anomalies_table_columns'; - -import { AnomalyDetails } from './anomaly_details'; - -import { mlTableService } from '../../services/table_service'; -import { RuleEditorFlyout } from '../rule_editor'; -import { INFLUENCERS_LIMIT, ANOMALIES_TABLE_TABS, MAX_CHARS } from './anomalies_table_constants'; - -export class AnomaliesTableInternal extends Component { - static contextType = context; - - constructor(props) { - super(props); - - this.state = { - itemIdToExpandedRowMap: {}, - showRuleEditorFlyout: () => {}, - }; - } - - isShowingAggregatedData = () => { - return this.props.tableData.interval !== 'second'; - }; - - static getDerivedStateFromProps(nextProps, prevState) { - // Update the itemIdToExpandedRowMap state if a change to the table data has resulted - // in an anomaly that was previously expanded no longer being in the data. - const itemIdToExpandedRowMap = prevState.itemIdToExpandedRowMap; - const prevExpandedNotInData = Object.keys(itemIdToExpandedRowMap).find((rowId) => { - const matching = nextProps.tableData.anomalies.find((anomaly) => { - return anomaly.rowId === rowId; - }); - - return matching === undefined; - }); - - if (prevExpandedNotInData !== undefined) { - // Anomaly data has changed and an anomaly that was previously expanded is no longer in the data. - return { - itemIdToExpandedRowMap: {}, - }; - } - - // Return null to indicate no change to state. - return null; - } - - toggleRow = async (item, tab = ANOMALIES_TABLE_TABS.DETAILS) => { - const mlApi = this.context.services.mlServices.mlApi; - const itemIdToExpandedRowMap = { ...this.state.itemIdToExpandedRowMap }; - if (itemIdToExpandedRowMap[item.rowId]) { - delete itemIdToExpandedRowMap[item.rowId]; - } else { - const examples = - item.entityName === 'mlcategory' - ? get(this.props.tableData, ['examplesByJobId', item.jobId, item.entityValue]) - : undefined; - let definition = undefined; - - if (examples !== undefined) { - try { - definition = await mlApi.results.getCategoryDefinition( - item.jobId, - item.source.mlcategory[0] - ); - - if (definition.terms && definition.terms.length > MAX_CHARS) { - definition.terms = `${definition.terms.substring(0, MAX_CHARS)}...`; - } - if (definition.regex && definition.regex.length > MAX_CHARS) { - definition.terms = `${definition.regex.substring(0, MAX_CHARS)}...`; - } - } catch (error) { - console.log('Error fetching category definition for row item.', error); - } - } - - const job = this.props.selectedJobs.find(({ id }) => id === item.jobId); - - itemIdToExpandedRowMap[item.rowId] = ( - - ); - } - this.setState({ itemIdToExpandedRowMap }); - }; - - onMouseOverRow = (record) => { - if (this.mouseOverRecord !== undefined) { - if (this.mouseOverRecord.rowId !== record.rowId) { - // Mouse is over a different row, fire mouseleave on the previous record. - mlTableService.rowMouseleave$.next({ record: this.mouseOverRecord }); - - // fire mouseenter on the new record. - mlTableService.rowMouseenter$.next({ record }); - } - } else { - // Mouse is now over a row, fire mouseenter on the record. - mlTableService.rowMouseenter$.next({ record }); - } - - this.mouseOverRecord = record; - }; - - onMouseLeaveRow = () => { - if (this.mouseOverRecord !== undefined) { - mlTableService.rowMouseleave$.next({ record: this.mouseOverRecord }); - this.mouseOverRecord = undefined; - } - }; - - setShowRuleEditorFlyoutFunction = (func) => { - this.setState({ - showRuleEditorFlyout: func, - }); - }; - - unsetShowRuleEditorFlyoutFunction = () => { - this.setState({ - showRuleEditorFlyout: () => {}, - }); - }; - - onTableChange = ({ page, sort }) => { - const { tableState, updateTableState } = this.props; - const result = { - pageIndex: page && page.index !== undefined ? page.index : tableState.pageIndex, - pageSize: page && page.size !== undefined ? page.size : tableState.pageSize, - sortField: - sort && sort.field !== undefined && typeof sort.field === 'string' - ? sort.field - : tableState.sortField, - sortDirection: - sort && sort.direction !== undefined ? sort.direction : tableState.sortDirection, - }; - updateTableState(result); - }; - - render() { - const { bounds, tableData, filter, influencerFilter, tableState } = this.props; - - if ( - tableData === undefined || - tableData.anomalies === undefined || - tableData.anomalies.length === 0 - ) { - return ( - - - -

- -

-
-
-
- ); - } - - const columns = getColumns( - this.context.services.mlServices.mlFieldFormatService, - tableData.anomalies, - tableData.jobIds, - tableData.examplesByJobId, - this.isShowingAggregatedData(), - tableData.interval, - bounds, - tableData.showViewSeriesLink, - this.state.showRuleEditorFlyout, - this.state.itemIdToExpandedRowMap, - this.toggleRow, - filter, - influencerFilter, - this.props.sourceIndicesWithGeoFields - ); - - // Use auto table layout, unless any columns (categorization examples) have truncateText - // set to true which only works with a fixed layout. - const tableLayout = columns.some((column) => column.truncateText === true) ? 'fixed' : 'auto'; - - const sorting = { - sort: { - field: tableState.sortField, - direction: tableState.sortDirection, - }, - }; - - const getRowProps = (item) => { - return { - onMouseOver: () => this.onMouseOverRow(item), - onMouseLeave: () => this.onMouseLeaveRow(), - 'data-test-subj': `mlAnomaliesListRow row-${item.rowId}`, - }; - }; - - const pagination = { - pageIndex: tableState.pageIndex, - pageSize: tableState.pageSize, - totalItemCount: tableData.anomalies.length, - pageSizeOptions: [10, 25, 100], - }; - - return ( - <> - - - - ); - } -} - -export const getDefaultAnomaliesTableState = () => ({ - pageIndex: 0, - pageSize: 25, - sortField: 'severity', - sortDirection: 'desc', -}); - -export const AnomaliesTable = (props) => { - const [tableState, updateTableState] = usePageUrlState( - 'mlAnomaliesTable', - getDefaultAnomaliesTableState() - ); - return ( - - ); -}; - -AnomaliesTableInternal.propTypes = { - bounds: PropTypes.object.isRequired, - tableData: PropTypes.object, - filter: PropTypes.func, - influencerFilter: PropTypes.func, - tableState: PropTypes.object.isRequired, - updateTableState: PropTypes.func.isRequired, - sourceIndicesWithGeoFields: PropTypes.object.isRequired, - selectedJobs: PropTypes.array.isRequired, -}; diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table.tsx new file mode 100644 index 0000000000000..32c23cd5455f6 --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table.tsx @@ -0,0 +1,338 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimeRangeBounds } from '@kbn/data-plugin/common'; +import React, { useState, type FC, useEffect, useMemo, useCallback, useRef } from 'react'; +import { usePageUrlState } from '@kbn/ml-url-state'; +import type { MlAnomaliesTableRecordExtended } from '@kbn/ml-anomaly-utils'; +import { get, isEqual } from 'lodash'; +import type { CriteriaWithPagination, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; +import type { + AnomaliesTableData, + ExplorerJob, + SourceIndicesWithGeoFields, +} from '../../explorer/explorer_utils'; +import type { FilterAction } from '../../explorer/explorer_constants'; +import { useMlKibana } from '../../contexts/kibana'; +import { ANOMALIES_TABLE_TABS, INFLUENCERS_LIMIT, MAX_CHARS } from './anomalies_table_constants'; +import { AnomalyDetails } from './anomaly_details'; +import { mlTableService } from '../../services/table_service'; +import { getColumns } from './anomalies_table_columns'; +import { RuleEditorFlyout } from '../rule_editor'; + +interface AnomaliesTableProps { + bounds?: TimeRangeBounds; + tableData: AnomaliesTableData; + filter?: (field: string, value: string, operator: string) => void; + influencerFilter?: (fieldName: string, fieldValue: string, action: FilterAction) => void; + sourceIndicesWithGeoFields: SourceIndicesWithGeoFields; + selectedJobs: ExplorerJob[]; +} + +interface AnomaliesTableState { + pageIndex: number; + pageSize: number; + sortField: string; + sortDirection: 'asc' | 'desc'; +} + +interface AnomaliesTablePageUrlState { + pageKey: 'mlAnomaliesTable'; + pageUrlState: AnomaliesTableState; +} + +export const getDefaultAnomaliesTableState = (): AnomaliesTableState => ({ + pageIndex: 0, + pageSize: 25, + sortField: 'severity', + sortDirection: 'desc', +}); + +export const AnomaliesTable: FC = React.memo( + ({ bounds, tableData, filter, influencerFilter, sourceIndicesWithGeoFields, selectedJobs }) => { + const [tableState, updateTableState] = usePageUrlState( + 'mlAnomaliesTable', + getDefaultAnomaliesTableState() + ); + + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< + Record + >({}); + + // When the table data changes, reset the table page index to 0. + useUpdateEffect(() => { + updateTableState({ + ...tableState, + pageIndex: 0, + }); + }, [tableData]); + + const [showRuleEditorFlyout, setShowRuleEditorFlyout] = useState< + ((anomaly: MlAnomaliesTableRecordExtended) => void) | null + >(null); + + const { + services: { mlServices }, + } = useMlKibana(); + + const mouseOverRecordRef = useRef(); + + const handleSetShowFunction = useCallback( + (showFunction: (anomaly: MlAnomaliesTableRecordExtended) => void) => { + setShowRuleEditorFlyout(() => showFunction); + }, + [] + ); + + const handleUnsetShowFunction = useCallback(() => { + setShowRuleEditorFlyout(null); + }, []); + + useEffect( + function resetExpandedRowMap() { + const expandedRowIds = Object.keys(itemIdToExpandedRowMap); + const expandedNotInData = expandedRowIds.find((rowId) => { + return !tableData.anomalies.some((anomaly) => anomaly.rowId === rowId); + }); + + if (expandedNotInData !== undefined) { + setItemIdToExpandedRowMap({}); + } + }, + [itemIdToExpandedRowMap, tableData.anomalies] + ); + + const isShowingAggregatedData = useMemo(() => { + return tableData.interval !== 'second'; + }, [tableData.interval]); + + const toggleRow = useCallback( + async (item: MlAnomaliesTableRecordExtended, tab = ANOMALIES_TABLE_TABS.DETAILS) => { + const newItemIdToExpandedRowMap = { ...itemIdToExpandedRowMap }; + + if (newItemIdToExpandedRowMap[item.rowId]) { + delete newItemIdToExpandedRowMap[item.rowId]; + } else { + const examples = + item.entityName === 'mlcategory' + ? get(tableData, ['examplesByJobId', item.jobId, item.entityValue]) + : undefined; + let definition; + + if (examples !== undefined) { + try { + definition = await mlServices.mlApi.results.getCategoryDefinition( + item.jobId, + item.source.mlcategory[0] + ); + + if (definition.terms && definition.terms.length > MAX_CHARS) { + definition.terms = `${definition.terms.substring(0, MAX_CHARS)}...`; + } + + if (definition.regex && definition.regex.length > MAX_CHARS) { + definition.terms = `${definition.regex.substring(0, MAX_CHARS)}...`; + } + } catch (error) { + // Do nothing + } + } + + const job = selectedJobs.find(({ id }) => id === item.jobId); + + newItemIdToExpandedRowMap[item.rowId] = ( + + ); + } + + setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); + }, + [ + filter, + influencerFilter, + isShowingAggregatedData, + itemIdToExpandedRowMap, + mlServices.mlApi.results, + selectedJobs, + tableData, + ] + ); + + const onMouseOverRow = useCallback((record: MlAnomaliesTableRecordExtended) => { + if (mouseOverRecordRef.current !== undefined) { + if (mouseOverRecordRef.current.rowId !== record.rowId) { + // Mouse is over a different row, fire mouseleave on the previous record. + mlTableService.rowMouseleave$.next({ record: mouseOverRecordRef.current }); + + // fire mouseenter on the new record. + mlTableService.rowMouseenter$.next({ record }); + } + } else { + // Mouse is now over a row, fire mouseenter on the record. + mlTableService.rowMouseenter$.next({ record }); + } + + mouseOverRecordRef.current = record; + }, []); + + const onMouseLeaveRow = useCallback(() => { + if (mouseOverRecordRef.current !== undefined) { + mlTableService.rowMouseleave$.next({ record: mouseOverRecordRef.current }); + mouseOverRecordRef.current = undefined; + } + }, []); + + const onTableChange = useCallback( + (criteria: CriteriaWithPagination) => { + const { page, sort } = criteria; + const result = { + pageIndex: page && page.index !== undefined ? page.index : tableState.pageIndex, + pageSize: page && page.size !== undefined ? page.size : tableState.pageSize, + sortField: + sort && sort.field !== undefined && typeof sort.field === 'string' + ? sort.field + : tableState.sortField, + sortDirection: + sort && sort.direction !== undefined ? sort.direction : tableState.sortDirection, + }; + updateTableState(result); + }, + [tableState, updateTableState] + ); + + const sorting = useMemo(() => { + return { + sort: { + field: tableState.sortField, + direction: tableState.sortDirection, + }, + }; + }, [tableState.sortField, tableState.sortDirection]); + + const pagination = useMemo(() => { + return { + pageIndex: tableState.pageIndex, + pageSize: tableState.pageSize, + totalItemCount: tableData.anomalies.length, + pageSizeOptions: [10, 25, 100], + }; + }, [tableState.pageIndex, tableState.pageSize, tableData.anomalies.length]); + + const columns = useMemo( + () => + getColumns( + mlServices.mlFieldFormatService, + tableData.anomalies, + tableData.jobIds, + tableData.examplesByJobId, + isShowingAggregatedData, + tableData.interval, + bounds, + tableData.showViewSeriesLink, + showRuleEditorFlyout, + itemIdToExpandedRowMap, + toggleRow, + filter, + influencerFilter, + sourceIndicesWithGeoFields + ), + [ + bounds, + filter, + influencerFilter, + isShowingAggregatedData, + itemIdToExpandedRowMap, + mlServices.mlFieldFormatService, + showRuleEditorFlyout, + sourceIndicesWithGeoFields, + tableData.anomalies, + tableData.examplesByJobId, + tableData.interval, + tableData.jobIds, + tableData.showViewSeriesLink, + toggleRow, + ] + ); + + if ( + tableData === undefined || + tableData.anomalies === undefined || + tableData.anomalies.length === 0 + ) { + return ( + + + +

+ +

+
+
+
+ ); + } + + // Use auto table layout, unless any columns (categorization examples) have truncateText + // set to true which only works with a fixed layout. + const tableLayout = columns.some( + (column) => 'truncateText' in column && column.truncateText === true + ) + ? 'fixed' + : 'auto'; + + const getRowProps = (item: MlAnomaliesTableRecordExtended) => { + return { + onMouseOver: () => onMouseOverRow(item), + onMouseLeave: () => onMouseLeaveRow(), + 'data-test-subj': `mlAnomaliesListRow row-${item.rowId}`, + }; + }; + + return ( + <> + + >} + tableLayout={tableLayout} + pagination={pagination} + sorting={sorting} + itemId="rowId" + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + compressed={true} + rowProps={getRowProps} + data-test-subj="mlAnomaliesTable" + onTableChange={onTableChange} + /> + + ); + }, + (prevProps, nextProps) => { + return isEqual(prevProps, nextProps); + } +); diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details.tsx index aa71612b70fa3..67da37de7480f 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details.tsx @@ -40,14 +40,14 @@ import { interface Props { anomaly: MlAnomaliesTableRecordExtended; - examples: string[]; - definition: CategoryDefinition; isAggregatedData: boolean; - filter: EntityCellFilter; influencersLimit: number; - influencerFilter: EntityCellFilter; tabIndex: number; job: ExplorerJob; + definition?: CategoryDefinition; + examples?: string[]; + filter?: EntityCellFilter; + influencerFilter?: EntityCellFilter; } export const AnomalyDetails: FC = ({ @@ -117,10 +117,10 @@ export const AnomalyDetails: FC = ({ const Contents: FC<{ anomaly: MlAnomaliesTableRecordExtended; isAggregatedData: boolean; - filter: EntityCellFilter; influencersLimit: number; - influencerFilter: EntityCellFilter; job: ExplorerJob; + filter?: EntityCellFilter; + influencerFilter?: EntityCellFilter; }> = ({ anomaly, isAggregatedData, filter, influencersLimit, influencerFilter, job }) => { const { euiTheme: { colors }, @@ -185,7 +185,7 @@ const Description: FC<{ anomaly: MlAnomaliesTableRecordExtended }> = ({ anomaly const Details: FC<{ anomaly: MlAnomaliesTableRecordExtended; isAggregatedData: boolean; - filter: EntityCellFilter; + filter?: EntityCellFilter; job: ExplorerJob; }> = ({ anomaly, isAggregatedData, filter, job }) => { const isInterimResult = anomaly.source?.is_interim ?? false; @@ -230,7 +230,7 @@ const Details: FC<{ const Influencers: FC<{ anomaly: MlAnomaliesTableRecordExtended; influencersLimit: number; - influencerFilter: EntityCellFilter; + influencerFilter?: EntityCellFilter; }> = ({ anomaly, influencersLimit, influencerFilter }) => { const [showAllInfluencers, setShowAllInfluencers] = useState(false); const toggleAllInfluencers = setShowAllInfluencers.bind(null, (prev) => !prev); @@ -239,7 +239,7 @@ const Influencers: FC<{ let listItems: Array<{ title: string; description: React.ReactElement }> = []; let othersCount = 0; let numToDisplay = 0; - if (anomalyInfluencers !== undefined) { + if (anomalyInfluencers !== undefined && influencerFilter !== undefined) { numToDisplay = showAllInfluencers === true ? anomalyInfluencers.length @@ -302,7 +302,7 @@ const Influencers: FC<{ return null; }; -const CategoryExamples: FC<{ definition: CategoryDefinition; examples: string[] }> = ({ +const CategoryExamples: FC<{ definition?: CategoryDefinition; examples: string[] }> = ({ definition, examples, }) => { diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx index 87620dde12f40..3853daf36153a 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx @@ -69,7 +69,7 @@ export function getInfluencersItems( export const DetailsItems: FC<{ anomaly: MlAnomaliesTableRecord; - filter: EntityCellFilter; + filter?: EntityCellFilter; modelPlotEnabled: boolean; }> = ({ anomaly, filter, modelPlotEnabled }) => { const source = anomaly.source; @@ -103,37 +103,39 @@ export const DetailsItems: FC<{ } const items = []; - if (source.partition_field_value !== undefined && source.partition_field_name !== undefined) { - items.push({ - title: source.partition_field_name, - description: getFilterEntity( - source.partition_field_name, - String(source.partition_field_value), - filter - ), - }); - } + if (filter !== undefined) { + if (source.partition_field_value !== undefined && source.partition_field_name !== undefined) { + items.push({ + title: source.partition_field_name, + description: getFilterEntity( + source.partition_field_name, + String(source.partition_field_value), + filter + ), + }); + } - if (source.by_field_value !== undefined && source.by_field_name !== undefined) { - items.push({ - title: source.by_field_name, - description: getFilterEntity(source.by_field_name, source.by_field_value, filter), - }); - } + if (source.by_field_value !== undefined && source.by_field_name !== undefined) { + items.push({ + title: source.by_field_name, + description: getFilterEntity(source.by_field_name, source.by_field_value, filter), + }); + } - if (singleCauseByFieldName !== undefined && singleCauseByFieldValue !== undefined) { - // Display byField of single cause. - items.push({ - title: singleCauseByFieldName, - description: getFilterEntity(singleCauseByFieldName, singleCauseByFieldValue, filter), - }); - } + if (singleCauseByFieldName !== undefined && singleCauseByFieldValue !== undefined) { + // Display byField of single cause. + items.push({ + title: singleCauseByFieldName, + description: getFilterEntity(singleCauseByFieldName, singleCauseByFieldValue, filter), + }); + } - if (source.over_field_value !== undefined && source.over_field_name !== undefined) { - items.push({ - title: source.over_field_name, - description: getFilterEntity(source.over_field_name, source.over_field_value, filter), - }); + if (source.over_field_value !== undefined && source.over_field_name !== undefined) { + items.push({ + title: source.over_field_name, + description: getFilterEntity(source.over_field_name, source.over_field_value, filter), + }); + } } const anomalyTime = source[TIME_FIELD_NAME]; diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/index.js b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/index.ts similarity index 100% rename from x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/index.js rename to x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/index.ts diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/controls/select_interval/select_interval.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/controls/select_interval/select_interval.tsx index a2da5656dd33d..bfb165d0c48ed 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/controls/select_interval/select_interval.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -9,7 +9,7 @@ import type { FC } from 'react'; import React from 'react'; import { EuiIcon, EuiSelect, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { usePageUrlState } from '@kbn/ml-url-state'; +import { usePageUrlState, type UrlStateService } from '@kbn/ml-url-state'; interface TableIntervalPageUrlState { pageKey: 'mlSelectInterval'; @@ -60,12 +60,14 @@ function optionValueToInterval(value: string) { export const TABLE_INTERVAL_DEFAULT = optionValueToInterval('auto'); -export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] => { - const [interval, updateCallback] = usePageUrlState( - 'mlSelectInterval', - TABLE_INTERVAL_DEFAULT - ); - return [interval, updateCallback]; +export const useTableInterval = (): [ + TableInterval, + (v: TableInterval) => void, + UrlStateService +] => { + const [interval, updateCallback, tableIntervalUrlStateService] = + usePageUrlState('mlSelectInterval', TABLE_INTERVAL_DEFAULT); + return [interval, updateCallback, tableIntervalUrlStateService]; }; /* diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts index 97a65d7c13273..f88d6fa6c4222 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/actions/load_explorer_data.ts @@ -17,28 +17,24 @@ import { useCallback, useMemo } from 'react'; import type { TimefilterContract } from '@kbn/data-plugin/public'; import { useTimefilter } from '@kbn/ml-date-picker'; import type { InfluencersFilterQuery } from '@kbn/ml-anomaly-utils'; -import type { TimeBucketsInterval, TimeRangeBounds } from '@kbn/ml-time-buckets'; -import type { IUiSettingsClient } from '@kbn/core/public'; +import type { TimeRangeBounds } from '@kbn/ml-time-buckets'; import type { AppStateSelectedCells, ExplorerJob } from '../explorer_utils'; import { - getDateFormatTz, getSelectionInfluencers, getSelectionJobIds, getSelectionTimeRange, loadAnnotationsTableData, - loadAnomaliesTableData, loadFilteredTopInfluencers, loadTopInfluencers, loadOverallAnnotations, } from '../explorer_utils'; -import { useMlApi, useUiSettings } from '../../contexts/kibana'; +import { useMlApi } from '../../contexts/kibana'; import type { MlResultsService } from '../../services/results_service'; import { mlResultsServiceProvider } from '../../services/results_service'; import type { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service'; import { useAnomalyExplorerContext } from '../anomaly_explorer_context'; import type { MlApi } from '../../services/ml_api_service'; -import { useMlJobService, type MlJobService } from '../../services/job_service'; import type { ExplorerState } from '../explorer_data'; // Memoize the data fetching methods. @@ -68,20 +64,13 @@ const memoizedLoadFilteredTopInfluencers = memoize(loadFilteredTopInfluencers); const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); -const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); - export interface LoadExplorerDataConfig { influencersFilterQuery: InfluencersFilterQuery; lastRefresh: number; noInfluencersConfigured: boolean; selectedCells: AppStateSelectedCells | undefined | null; selectedJobs: ExplorerJob[]; - swimlaneBucketInterval: TimeBucketsInterval; - swimlaneLimit: number; - tableInterval: string; - tableSeverity: number; viewBySwimlaneFieldName: string; - swimlaneContainerWidth: number; } export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { @@ -97,9 +86,7 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi * Fetches the data necessary for the Anomaly Explorer using observables. */ const loadExplorerDataProvider = ( - uiSettings: IUiSettingsClient, mlApi: MlApi, - mlJobService: MlJobService, mlResultsService: MlResultsService, anomalyExplorerChartsService: AnomalyExplorerChartsService, timefilter: TimefilterContract @@ -115,8 +102,6 @@ const loadExplorerDataProvider = ( noInfluencersConfigured, selectedCells, selectedJobs, - tableInterval, - tableSeverity, viewBySwimlaneFieldName, } = config; @@ -127,10 +112,8 @@ const loadExplorerDataProvider = ( const timerange = getSelectionTimeRange(selectedCells, bounds); - const dateFormatTz = getDateFormatTz(uiSettings); - // First get the data where we have all necessary args at hand using forkJoin: - // annotationsData, anomalyChartRecords, influencers, overallState, tableData + // annotationsData, anomalyChartRecords, influencers, overallState return forkJoin({ overallAnnotations: memoizedLoadOverallAnnotations(lastRefresh, mlApi, selectedJobs, bounds), annotationsData: memoizedLoadAnnotationsTableData( @@ -161,70 +144,54 @@ const loadExplorerDataProvider = ( influencersFilterQuery ) : Promise.resolve({}), - tableData: memoizedLoadAnomaliesTableData( - lastRefresh, - mlApi, - mlJobService, - selectedCells, - selectedJobs, - dateFormatTz, - bounds, - viewBySwimlaneFieldName, - tableInterval, - tableSeverity, - influencersFilterQuery - ), }).pipe( - switchMap( - ({ overallAnnotations, anomalyChartRecords, influencers, annotationsData, tableData }) => - forkJoin({ - filteredTopInfluencers: - (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && - anomalyChartRecords !== undefined && - anomalyChartRecords.length > 0 - ? memoizedLoadFilteredTopInfluencers( - lastRefresh, - mlResultsService, - jobIds, - timerange.earliestMs, - timerange.latestMs, - anomalyChartRecords, - selectionInfluencers, - noInfluencersConfigured, - influencersFilterQuery - ) - : Promise.resolve(influencers), - }).pipe( - map(({ filteredTopInfluencers }) => { - return { - overallAnnotations, - annotations: annotationsData, - influencers: filteredTopInfluencers as any, - loading: false, - anomalyChartsDataLoading: false, - tableData, - }; - }) - ) + switchMap(({ overallAnnotations, anomalyChartRecords, influencers, annotationsData }) => + forkJoin({ + filteredTopInfluencers: + (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && + anomalyChartRecords !== undefined && + anomalyChartRecords.length > 0 + ? memoizedLoadFilteredTopInfluencers( + lastRefresh, + mlResultsService, + jobIds, + timerange.earliestMs, + timerange.latestMs, + anomalyChartRecords, + selectionInfluencers, + noInfluencersConfigured, + influencersFilterQuery + ) + : Promise.resolve(influencers), + }).pipe( + map(({ filteredTopInfluencers }) => { + return { + overallAnnotations, + annotations: annotationsData, + influencers: filteredTopInfluencers as any, + loading: false, + anomalyChartsDataLoading: false, + }; + }) + ) ) ); }; }; -export const useExplorerData = (): [Partial | undefined, (d: any) => void] => { - const uiSettings = useUiSettings(); +export const useExplorerData = (): [ + Partial | undefined, + (d: LoadExplorerDataConfig) => void +] => { const timefilter = useTimefilter(); const mlApi = useMlApi(); - const mlJobService = useMlJobService(); const { anomalyExplorerChartsService } = useAnomalyExplorerContext(); const loadExplorerData = useMemo(() => { const mlResultsService = mlResultsServiceProvider(mlApi); return loadExplorerDataProvider( - uiSettings, mlApi, - mlJobService, mlResultsService, anomalyExplorerChartsService, timefilter diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx index cb29924d2373c..2a559163ebe68 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_explorer_context.tsx @@ -20,6 +20,8 @@ import { AnomalyExplorerChartsService } from '../services/anomaly_explorer_chart import { useTableSeverity } from '../components/controls/select_severity'; import { AnomalyDetectionAlertsStateService } from './alerts'; import { useMlJobService } from '../services/job_service'; +import { useTableInterval } from '../components/controls/select_interval'; +import { AnomalyTableStateService } from './anomaly_table_state_service'; export interface AnomalyExplorerContextValue { anomalyExplorerChartsService: AnomalyExplorerChartsService; @@ -28,6 +30,7 @@ export interface AnomalyExplorerContextValue { anomalyTimelineStateService: AnomalyTimelineStateService; chartsStateService: AnomalyChartsStateService; anomalyDetectionAlertsStateService: AnomalyDetectionAlertsStateService; + anomalyTableService: AnomalyTableStateService; } /** @@ -68,7 +71,8 @@ export const AnomalyExplorerContextProvider: FC> = ({ } = useMlKibana(); const mlJobService = useMlJobService(); - const [, , tableSeverityState] = useTableSeverity(); + const [, , tableSeverityUrlStateService] = useTableSeverity(); + const [, , tableIntervalUrlStateService] = useTableInterval(); // eslint-disable-next-line react-hooks/exhaustive-deps const mlResultsService = useMemo(() => mlResultsServiceProvider(mlApi), []); @@ -113,7 +117,7 @@ export const AnomalyExplorerContextProvider: FC> = ({ anomalyTimelineStateService, anomalyExplorerChartsService, anomalyExplorerUrlStateService, - tableSeverityState + tableSeverityUrlStateService ); const anomalyDetectionAlertsStateService = new AnomalyDetectionAlertsStateService( @@ -122,6 +126,17 @@ export const AnomalyExplorerContextProvider: FC> = ({ timefilter ); + const anomalyTableService = new AnomalyTableStateService( + mlApi, + mlJobService, + uiSettings, + timefilter, + anomalyExplorerCommonStateService, + anomalyTimelineStateService, + tableSeverityUrlStateService, + tableIntervalUrlStateService + ); + setAnomalyExplorerContextValue({ anomalyExplorerChartsService, anomalyExplorerCommonStateService, @@ -129,6 +144,7 @@ export const AnomalyExplorerContextProvider: FC> = ({ anomalyTimelineStateService, chartsStateService, anomalyDetectionAlertsStateService, + anomalyTableService, }); return () => { @@ -138,6 +154,7 @@ export const AnomalyExplorerContextProvider: FC> = ({ anomalyTimelineStateService.destroy(); chartsStateService.destroy(); anomalyDetectionAlertsStateService.destroy(); + anomalyTableService.destroy(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_table_state_service.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_table_state_service.ts new file mode 100644 index 0000000000000..b981cc8e6d741 --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/anomaly_table_state_service.ts @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IUiSettingsClient } from '@kbn/core/public'; +import type { TimefilterContract } from '@kbn/data-plugin/public'; +import type { Observable } from 'rxjs'; +import { + combineLatest, + distinctUntilChanged, + switchMap, + BehaviorSubject, + of, + map, + catchError, + Subscription, + startWith, + skipWhile, + tap, +} from 'rxjs'; +import { get, isEqual } from 'lodash'; +import type { InfluencersFilterQuery, MlAnomaliesTableRecordExtended } from '@kbn/ml-anomaly-utils'; +import { ML_JOB_AGGREGATION, getEntityFieldList } from '@kbn/ml-anomaly-utils'; +import type { UrlStateService } from '@kbn/ml-url-state'; +import { mlTimefilterRefresh$ } from '@kbn/ml-date-picker'; +import type { TimeRangeBounds } from '@kbn/data-plugin/common'; +import type { MlJobService } from '../services/job_service'; +import type { MlApi } from '../services/ml_api_service'; +import type { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; +import type { TableSeverity } from '../components/controls/select_severity/select_severity'; +import type { TableInterval } from '../components/controls/select_interval/select_interval'; +import type { AnomalyTimelineStateService } from './anomaly_timeline_state_service'; +import type { AnomaliesTableData, AppStateSelectedCells, ExplorerJob } from './explorer_utils'; +import { + getDateFormatTz, + getSelectionInfluencers, + getSelectionJobIds, + getSelectionTimeRange, +} from './explorer_utils'; +import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; +import { MAX_CATEGORY_EXAMPLES } from './explorer_constants'; +import { + isModelPlotChartableForDetector, + isModelPlotEnabled, + isSourceDataChartableForDetector, +} from '../../../common/util/job_utils'; +import { StateService } from '../services/state_service'; +import type { Refresh } from '../routing/use_refresh'; + +export class AnomalyTableStateService extends StateService { + private _tableData$ = new BehaviorSubject(null); + private _tableDataLoading$ = new BehaviorSubject(true); + private _timeBounds$: Observable; + private _refreshSubject$: Observable; + + constructor( + private readonly mlApi: MlApi, + private readonly mlJobService: MlJobService, + private readonly uiSettings: IUiSettingsClient, + private readonly timefilter: TimefilterContract, + private readonly anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService, + private readonly anomalyTimelineStateService: AnomalyTimelineStateService, + private readonly tableSeverityUrlStateService: UrlStateService, + private readonly tableIntervalUrlStateService: UrlStateService + ) { + super(); + + this._timeBounds$ = this.timefilter.getTimeUpdate$().pipe( + startWith(null), + map(() => this.timefilter.getBounds()) + ); + this._refreshSubject$ = mlTimefilterRefresh$.pipe(startWith({ lastRefresh: 0 })); + + this._init(); + } + + public readonly tableData$ = this._tableData$.asObservable(); + + public get tableData(): AnomaliesTableData | null { + return this._tableData$.getValue(); + } + + public readonly tableDataLoading$ = this._tableDataLoading$.asObservable(); + + public get tableDataLoading(): boolean { + return this._tableDataLoading$.getValue(); + } + + protected _initSubscriptions(): Subscription { + const subscriptions = new Subscription(); + + // Add the main subscription that updates tableData$ + subscriptions.add( + combineLatest([ + this.anomalyTimelineStateService.getSelectedCells$(), + this.anomalyExplorerCommonStateService.selectedJobs$, + this.anomalyTimelineStateService.getViewBySwimlaneFieldName$(), + this.tableIntervalUrlStateService.getUrlState$(), + this.tableSeverityUrlStateService.getUrlState$(), + this.anomalyExplorerCommonStateService.influencerFilterQuery$, + this._refreshSubject$, + this._timeBounds$, + ]) + .pipe( + distinctUntilChanged((prev, curr) => isEqual(prev, curr)), + skipWhile( + ([selectedCells, selectedJobs, viewBySwimlaneFieldName]) => + selectedCells === undefined || + !selectedJobs || + selectedJobs.length === 0 || + viewBySwimlaneFieldName === undefined + ), + tap(() => this._tableDataLoading$.next(true)), + switchMap( + ([ + selectedCells, + selectedJobs, + viewBySwimlaneFieldName, + tableInterval, + tableSeverity, + influencersFilterQuery, + ]) => { + return this.loadAnomaliesTableData( + selectedCells, + selectedJobs, + // viewBySwimlaneFieldName is guaranteed to be defined by the skipWhile + viewBySwimlaneFieldName!, + tableInterval.val, + tableSeverity.val, + influencersFilterQuery + ).pipe( + map((tableData) => ({ + tableData, + tableDataLoading: false, + })), + catchError((error) => { + return of({ tableData: null }); + }) + ); + } + ) + ) + .subscribe((result) => { + // Update the BehaviorSubject with new data + this._tableData$.next(result.tableData); + this._tableDataLoading$.next(false); + }) + ); + + return subscriptions; + } + + private loadAnomaliesTableData( + selectedCells: AppStateSelectedCells | undefined | null, + selectedJobs: ExplorerJob[], + fieldName: string, + tableInterval: string, + tableSeverity: number, + influencersFilterQuery?: InfluencersFilterQuery + ): Observable { + const jobIds = getSelectionJobIds(selectedCells, selectedJobs); + const influencers = getSelectionInfluencers(selectedCells, fieldName); + const bounds = this.timefilter.getBounds(); + const timeRange = getSelectionTimeRange(selectedCells, bounds); + const dateFormatTz = getDateFormatTz(this.uiSettings); + + return this.mlApi.results + .getAnomaliesTableData( + jobIds, + [], + influencers, + tableInterval, + tableSeverity, + timeRange.earliestMs, + timeRange.latestMs, + dateFormatTz, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, + MAX_CATEGORY_EXAMPLES, + influencersFilterQuery + ) + .pipe( + map((resp) => { + const detectorsByJob = this.mlJobService.detectorsByJob; + + const anomalies = resp.anomalies.map((anomaly) => { + const jobId = anomaly.jobId; + const detector = get(detectorsByJob, [jobId, anomaly.detectorIndex]); + + const extendedAnomaly = { ...anomaly } as MlAnomaliesTableRecordExtended; + + extendedAnomaly.detector = get( + detector, + ['detector_description'], + anomaly.source.function_description + ); + + if (detector !== undefined && detector.custom_rules !== undefined) { + extendedAnomaly.rulesLength = detector.custom_rules.length; + } + + const job = this.mlJobService.getJob(jobId); + let isChartable = isSourceDataChartableForDetector(job, anomaly.detectorIndex); + if ( + isChartable === false && + isModelPlotChartableForDetector(job, anomaly.detectorIndex) + ) { + const entityFields = getEntityFieldList(anomaly.source); + isChartable = isModelPlotEnabled(job, anomaly.detectorIndex, entityFields); + } + + extendedAnomaly.isTimeSeriesViewRecord = isChartable; + + extendedAnomaly.isGeoRecord = + detector !== undefined && detector.function === ML_JOB_AGGREGATION.LAT_LONG; + + if (this.mlJobService.customUrlsByJob[jobId] !== undefined) { + extendedAnomaly.customUrls = this.mlJobService.customUrlsByJob[jobId]; + } + + return extendedAnomaly; + }); + + return { + anomalies, + interval: resp.interval, + examplesByJobId: resp.examplesByJobId ?? {}, + showViewSeriesLink: true, + jobIds, + }; + }), + catchError((error) => { + return of(null); + }) + ); + } +} diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx index 234683514118c..70fa22665d20e 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer.tsx @@ -277,8 +277,11 @@ export const Explorer: FC = ({ anomalyExplorerCommonStateService, chartsStateService, anomalyDetectionAlertsStateService, + anomalyTableService, } = useAnomalyExplorerContext(); + const tableData = useObservable(anomalyTableService.tableData$, anomalyTableService.tableData); + const htmlIdGen = useMemo(() => htmlIdGenerator(), []); const [language, updateLanguage] = useState(DEFAULT_QUERY_LANG); @@ -365,8 +368,7 @@ export const Explorer: FC = ({ const mlIndexUtils = useMlIndexUtils(); const mlLocator = useMlLocator(); - const { annotations, filterPlaceHolder, indexPattern, influencers, loading, tableData } = - explorerState; + const { annotations, filterPlaceHolder, indexPattern, influencers, loading } = explorerState; const chartsData = useObservable( chartsStateService.getChartsData$(), @@ -436,7 +438,7 @@ export const Explorer: FC = ({ !!overallSwimlaneData?.points && overallSwimlaneData.points.length > 0; const hasResultsWithAnomalies = (hasResults && overallSwimlaneData!.points.some((v) => v.value > 0)) || - tableData.anomalies?.length > 0; + (tableData && tableData.anomalies?.length > 0); const hasActiveFilter = isDefined(swimLaneSeverity); @@ -518,7 +520,7 @@ export const Explorer: FC = ({ )} - {loading === false && tableData.anomalies?.length ? ( + {loading === false && tableData && tableData.anomalies?.length ? ( ) : null} {annotationsCnt > 0 && ( @@ -559,66 +561,67 @@ export const Explorer: FC = ({ )} - {loading === false && ( - - - - -

- -

-
-
- - - -
+ + + + +

+ +

+
+
- - - - + + + + + + + + + + + + + {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( - + - {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( - - - - )} - - - + )} +
- {showCharts ? ( - // @ts-ignore inferred js types are incorrect - - ) : null} + + + {showCharts ? ( + // @ts-ignore inferred js types are incorrect + + ) : null} - + + {tableData ? ( = ({ sourceIndicesWithGeoFields={sourceIndicesWithGeoFields} selectedJobs={selectedJobs} /> -
- )} + ) : null} +
); diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_data.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_data.ts index 7e40dd07268d2..351f84c28d8b1 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_data.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_data.ts @@ -65,7 +65,7 @@ export function getExplorerDefaultState(): ExplorerState { selectedJobs: null, tableData: { anomalies: [], - examplesByJobId: [''], + examplesByJobId: {}, interval: 0, jobIds: [], showViewSeriesLink: false, diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts index ce68528040b0d..3359b76d2a821 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/explorer_utils.ts @@ -24,7 +24,7 @@ import { type MlRecordForInfluencer, ML_JOB_AGGREGATION, } from '@kbn/ml-anomaly-utils'; -import type { InfluencersFilterQuery } from '@kbn/ml-anomaly-utils'; +import type { InfluencersFilterQuery, MlAnomaliesTableRecordExtended } from '@kbn/ml-anomaly-utils'; import type { TimeRangeBounds } from '@kbn/ml-time-buckets'; import type { IUiSettingsClient } from '@kbn/core/public'; import { parseInterval } from '@kbn/ml-parse-interval'; @@ -108,8 +108,8 @@ interface SelectionTimeRange { export interface AnomaliesTableData { anomalies: any[]; - interval: number; - examplesByJobId: string[]; + interval: number | string; + examplesByJobId: Record>; showViewSeriesLink: boolean; jobIds: string[]; } @@ -513,16 +513,20 @@ export async function loadAnomaliesTableData( ) .toPromise() .then((resp) => { - const anomalies = resp.anomalies; + if (!resp) return null; + const detectorsByJob = mlJobService.detectorsByJob; - // @ts-ignore - anomalies.forEach((anomaly) => { + + const anomalies = resp.anomalies.map((anomaly) => { // Add a detector property to each anomaly. // Default to functionDescription if no description available. // TODO - when job_service is moved server_side, move this to server endpoint. const jobId = anomaly.jobId; const detector = get(detectorsByJob, [jobId, anomaly.detectorIndex]); - anomaly.detector = get( + + const extendedAnomaly = { ...anomaly } as MlAnomaliesTableRecordExtended; + + extendedAnomaly.detector = get( detector, ['detector_description'], anomaly.source.function_description @@ -530,7 +534,7 @@ export async function loadAnomaliesTableData( // For detectors with rules, add a property with the rule count. if (detector !== undefined && detector.custom_rules !== undefined) { - anomaly.rulesLength = detector.custom_rules.length; + extendedAnomaly.rulesLength = detector.custom_rules.length; } // Add properties used for building the links menu. @@ -548,19 +552,22 @@ export async function loadAnomaliesTableData( isChartable = isModelPlotEnabled(job, anomaly.detectorIndex, entityFields); } - anomaly.isTimeSeriesViewRecord = isChartable; - anomaly.isGeoRecord = + extendedAnomaly.isTimeSeriesViewRecord = isChartable; + + extendedAnomaly.isGeoRecord = detector !== undefined && detector.function === ML_JOB_AGGREGATION.LAT_LONG; if (mlJobService.customUrlsByJob[jobId] !== undefined) { - anomaly.customUrls = mlJobService.customUrlsByJob[jobId]; + extendedAnomaly.customUrls = mlJobService.customUrlsByJob[jobId]; } + + return extendedAnomaly; }); resolve({ anomalies, interval: resp.interval, - examplesByJobId: resp.examplesByJobId, + examplesByJobId: resp.examplesByJobId ?? {}, showViewSeriesLink: true, jobIds, }); diff --git a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx index a077afae69b3b..39b1c342fdc3c 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/routing/routes/explorer/state_manager.tsx @@ -22,7 +22,6 @@ import { useRefresh } from '../../use_refresh'; import { Explorer } from '../../../explorer'; import { useExplorerData } from '../../../explorer/actions'; import { useJobSelection } from '../../../components/job_selector/use_job_selection'; -import { useTableInterval } from '../../../components/controls/select_interval'; import { useTableSeverity } from '../../../components/controls/select_severity'; import { MlPageHeader } from '../../../components/page_header'; import { PageTitle } from '../../../components/page_title'; @@ -33,6 +32,7 @@ import { getInfluencers } from '../../../explorer/explorer_utils'; import { useMlJobService } from '../../../services/job_service'; import type { ExplorerState } from '../../../explorer/explorer_data'; import { getExplorerDefaultState } from '../../../explorer/explorer_data'; +import type { LoadExplorerDataConfig } from '../../../explorer/actions/load_explorer_data'; export interface ExplorerUrlStateManagerProps { jobsWithTimeRange: MlJobWithTimeRange[]; @@ -100,7 +100,6 @@ export const ExplorerUrlStateManager: FC = ({ } }, [explorerData]); - const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); const showCharts = useObservable( @@ -127,15 +126,13 @@ export const ExplorerUrlStateManager: FC = ({ ); const loadExplorerDataConfig = useMemo( - () => ({ + (): LoadExplorerDataConfig => ({ lastRefresh, - influencersFilterQuery, + influencersFilterQuery: influencersFilterQuery!, noInfluencersConfigured, selectedCells, selectedJobs, - tableInterval: tableInterval.val, - tableSeverity: tableSeverity.val, - viewBySwimlaneFieldName: viewByFieldName, + viewBySwimlaneFieldName: viewByFieldName!, }), [ lastRefresh, @@ -143,8 +140,6 @@ export const ExplorerUrlStateManager: FC = ({ noInfluencersConfigured, selectedCells, selectedJobs, - tableInterval, - tableSeverity, viewByFieldName, ] ); diff --git a/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/results.ts b/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/results.ts index 23616fcdf2a98..d4564353b5827 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/services/ml_api_service/results.ts @@ -16,6 +16,7 @@ import { ML_INTERNAL_BASE_PATH } from '../../../../common/constants/app'; import type { GetStoppedPartitionResult, GetDatafeedResultsChartDataResult, + GetAnomaliesTableDataResult, } from '../../../../common/types/results'; import type { JobId } from '../../../../common/types/anomaly_detection_jobs'; import type { PartitionFieldsConfig } from '../../../../common/types/storage'; @@ -63,7 +64,7 @@ export const resultsApiProvider = (httpService: HttpService) => ({ functionDescription, }); - return httpService.http$({ + return httpService.http$({ path: `${ML_INTERNAL_BASE_PATH}/results/anomalies_table_data`, method: 'POST', body, diff --git a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index dd5375a96ea76..4ac0ff5aef8ab 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/platform/plugins/shared/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1310,20 +1310,23 @@ export class TimeSeriesExplorer extends React.Component { )} - {arePartitioningFieldsProvided && jobs.length > 0 && hasResults === true && ( - - )} + {arePartitioningFieldsProvided && + jobs.length > 0 && + hasResults === true && + tableData?.anomalies && ( + + )} ); } diff --git a/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_charts/anomaly_charts_react_container.tsx b/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_charts/anomaly_charts_react_container.tsx index 8f64ec88411c9..193ebf7792235 100644 --- a/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_charts/anomaly_charts_react_container.tsx +++ b/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_charts/anomaly_charts_react_container.tsx @@ -69,7 +69,7 @@ const AnomalyChartsContainer: FC = ({ const [tableData, setTableData] = useState({ anomalies: [], - examplesByJobId: [''], + examplesByJobId: {}, interval: 0, jobIds: [], showViewSeriesLink: false,