[ML] Add Anomaly charts embeddables to Dashboard from Anomaly Explorer page (#95623)
Co-authored-by: Robert Oskamp <robert.oskamp@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
955c46ba5e
commit
d904f8d1bb
|
@ -10,6 +10,7 @@ export { ChartData } from './types/field_histograms';
|
|||
export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from './constants/anomalies';
|
||||
export { getSeverityColor, getSeverityType } from './util/anomaly_utils';
|
||||
export { isPopulatedObject } from './util/object_utils';
|
||||
export { isRuntimeMappings } from './util/runtime_field_utils';
|
||||
export { composeValidators, patternValidator } from './util/validators';
|
||||
export { isRuntimeMappings, isRuntimeField } from './util/runtime_field_utils';
|
||||
export { extractErrorMessage } from './util/errors';
|
||||
export type { RuntimeMappings } from './types/fields';
|
||||
|
|
|
@ -28,7 +28,7 @@ export interface Field {
|
|||
aggregatable?: boolean;
|
||||
aggIds?: AggId[];
|
||||
aggs?: Aggregation[];
|
||||
runtimeField?: RuntimeField;
|
||||
runtimeField?: estypes.RuntimeField;
|
||||
}
|
||||
|
||||
export interface Aggregation {
|
||||
|
@ -108,17 +108,4 @@ export interface AggCardinality {
|
|||
|
||||
export type RollupFields = Record<FieldId, [Record<'agg', ES_AGGREGATION>]>;
|
||||
|
||||
// Replace this with import once #88995 is merged
|
||||
export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;
|
||||
export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
|
||||
|
||||
export interface RuntimeField {
|
||||
type: RuntimeType;
|
||||
script?:
|
||||
| string
|
||||
| {
|
||||
source: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type RuntimeMappings = estypes.RuntimeFields;
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { estypes } from '@elastic/elasticsearch';
|
||||
import { isPopulatedObject } from './object_utils';
|
||||
import { RUNTIME_FIELD_TYPES } from '../../../../../src/plugins/data/common';
|
||||
import type { RuntimeField, RuntimeMappings } from '../types/fields';
|
||||
import type { RuntimeMappings } from '../types/fields';
|
||||
|
||||
type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
|
||||
|
||||
export function isRuntimeField(arg: unknown): arg is RuntimeField {
|
||||
export function isRuntimeField(arg: unknown): arg is estypes.RuntimeField {
|
||||
return (
|
||||
((isPopulatedObject(arg, ['type']) && Object.keys(arg).length === 1) ||
|
||||
(isPopulatedObject(arg, ['type', 'script']) &&
|
||||
|
|
|
@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n';
|
|||
|
||||
import { CoreSetup } from 'src/core/public';
|
||||
|
||||
import type { estypes } from '@elastic/elasticsearch';
|
||||
import {
|
||||
IndexPattern,
|
||||
IFieldType,
|
||||
|
@ -49,7 +50,7 @@ import { getNestedProperty } from '../../util/object_utils';
|
|||
import { mlFieldFormatService } from '../../services/field_format_service';
|
||||
|
||||
import { DataGridItem, IndexPagination, RenderCellValue } from './types';
|
||||
import { RuntimeMappings, RuntimeField } from '../../../../common/types/fields';
|
||||
import { RuntimeMappings } from '../../../../common/types/fields';
|
||||
import { isRuntimeMappings } from '../../../../common/util/runtime_field_utils';
|
||||
|
||||
export const INIT_MAX_COLUMNS = 10;
|
||||
|
@ -179,7 +180,7 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
|
|||
export const NON_AGGREGATABLE = 'non-aggregatable';
|
||||
|
||||
export const getDataGridSchemaFromESFieldType = (
|
||||
fieldType: ES_FIELD_TYPES | undefined | RuntimeField['type']
|
||||
fieldType: ES_FIELD_TYPES | undefined | estypes.RuntimeField['type']
|
||||
): string | undefined => {
|
||||
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
|
||||
// To fall back to the default string schema it needs to be undefined.
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { estypes } from '@elastic/elasticsearch';
|
||||
import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public';
|
||||
import { RuntimeType } from '../../../../../../../../../../src/plugins/data/common';
|
||||
import { EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields';
|
||||
import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics';
|
||||
import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state';
|
||||
|
@ -18,7 +18,7 @@ export const CATEGORICAL_TYPES = new Set(['ip', 'keyword']);
|
|||
// Regression supports numeric fields. Classification supports categorical, numeric, and boolean.
|
||||
export const shouldAddAsDepVarOption = (
|
||||
fieldId: string,
|
||||
fieldType: ES_FIELD_TYPES | RuntimeType,
|
||||
fieldType: ES_FIELD_TYPES | estypes.RuntimeField['type'],
|
||||
jobType: AnalyticsJobType
|
||||
) => {
|
||||
if (fieldId === EVENT_RATE_FIELD_ID) return false;
|
||||
|
|
|
@ -13,7 +13,7 @@ import { CoreSetup } from 'src/core/public';
|
|||
|
||||
import { IndexPattern } from '../../../../../../../../../src/plugins/data/public';
|
||||
import { isRuntimeMappings } from '../../../../../../common/util/runtime_field_utils';
|
||||
import { RuntimeMappings, RuntimeField } from '../../../../../../common/types/fields';
|
||||
import { RuntimeMappings } from '../../../../../../common/types/fields';
|
||||
import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../../common/constants/field_histograms';
|
||||
|
||||
import { DataLoader } from '../../../../datavisualizer/index_based/data_loader';
|
||||
|
@ -44,7 +44,7 @@ interface MLEuiDataGridColumn extends EuiDataGridColumn {
|
|||
function getRuntimeFieldColumns(runtimeMappings: RuntimeMappings) {
|
||||
return Object.keys(runtimeMappings).map((id) => {
|
||||
const field = runtimeMappings[id];
|
||||
const schema = getDataGridSchemaFromESFieldType(field.type as RuntimeField['type']);
|
||||
const schema = getDataGridSchemaFromESFieldType(field.type as estypes.RuntimeField['type']);
|
||||
return { id, schema, isExpandable: schema !== 'boolean', isRuntimeFieldColumn: true };
|
||||
});
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ export const useIndexData = (
|
|||
const field = indexPattern.fields.getByName(id);
|
||||
const isRuntimeFieldColumn = field?.runtimeField !== undefined;
|
||||
const schema = isRuntimeFieldColumn
|
||||
? getDataGridSchemaFromESFieldType(field?.type as RuntimeField['type'])
|
||||
? getDataGridSchemaFromESFieldType(field?.type as estypes.RuntimeField['type'])
|
||||
: getDataGridSchemaFromKibanaFieldType(field);
|
||||
return {
|
||||
id,
|
||||
|
|
|
@ -10,7 +10,7 @@ import { isEqual } from 'lodash';
|
|||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
import { forkJoin, of, Observable, Subject } from 'rxjs';
|
||||
import { mergeMap, switchMap, tap } from 'rxjs/operators';
|
||||
import { mergeMap, switchMap, tap, map } from 'rxjs/operators';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { explorerService } from '../explorer_dashboard_service';
|
||||
|
@ -21,7 +21,6 @@ import {
|
|||
getSelectionTimeRange,
|
||||
loadAnnotationsTableData,
|
||||
loadAnomaliesTableData,
|
||||
loadDataForCharts,
|
||||
loadFilteredTopInfluencers,
|
||||
loadTopInfluencers,
|
||||
AppStateSelectedCells,
|
||||
|
@ -36,8 +35,9 @@ import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants';
|
|||
import { TimefilterContract } from '../../../../../../../src/plugins/data/public';
|
||||
import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service';
|
||||
import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs';
|
||||
import { mlJobService } from '../../services/job_service';
|
||||
import { InfluencersFilterQuery } from '../../../../common/types/es_client';
|
||||
import { ExplorerChartsData } from '../explorer_charts/explorer_charts_container_service';
|
||||
import { mlJobService } from '../../services/job_service';
|
||||
|
||||
// Memoize the data fetching methods.
|
||||
// wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument
|
||||
|
@ -58,7 +58,6 @@ const memoize = <T extends (...a: any[]) => any>(func: T, context?: any) => {
|
|||
const memoizedLoadAnnotationsTableData = memoize<typeof loadAnnotationsTableData>(
|
||||
loadAnnotationsTableData
|
||||
);
|
||||
const memoizedLoadDataForCharts = memoize<typeof loadDataForCharts>(loadDataForCharts);
|
||||
const memoizedLoadFilteredTopInfluencers = memoize<typeof loadFilteredTopInfluencers>(
|
||||
loadFilteredTopInfluencers
|
||||
);
|
||||
|
@ -96,7 +95,7 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi
|
|||
const loadExplorerDataProvider = (
|
||||
mlResultsService: MlResultsService,
|
||||
anomalyTimelineService: AnomalyTimelineService,
|
||||
anomalyExplorerService: AnomalyExplorerChartsService,
|
||||
anomalyExplorerChartsService: AnomalyExplorerChartsService,
|
||||
timefilter: TimefilterContract
|
||||
) => {
|
||||
const memoizedLoadOverallData = memoize(
|
||||
|
@ -108,8 +107,8 @@ const loadExplorerDataProvider = (
|
|||
anomalyTimelineService
|
||||
);
|
||||
const memoizedAnomalyDataChange = memoize(
|
||||
anomalyExplorerService.getAnomalyData,
|
||||
anomalyExplorerService
|
||||
anomalyExplorerChartsService.getAnomalyData,
|
||||
anomalyExplorerChartsService
|
||||
);
|
||||
|
||||
return (config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> => {
|
||||
|
@ -160,9 +159,7 @@ const loadExplorerDataProvider = (
|
|||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds
|
||||
),
|
||||
anomalyChartRecords: memoizedLoadDataForCharts(
|
||||
lastRefresh,
|
||||
mlResultsService,
|
||||
anomalyChartRecords: anomalyExplorerChartsService.loadDataForCharts$(
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
|
@ -214,42 +211,30 @@ const loadExplorerDataProvider = (
|
|||
// show the view-by loading indicator
|
||||
// and pass on the data we already fetched.
|
||||
tap(explorerService.setViewBySwimlaneLoading),
|
||||
// Trigger a side-effect to update the charts.
|
||||
tap(({ anomalyChartRecords, topFieldValues }) => {
|
||||
if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) {
|
||||
memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
explorerService,
|
||||
combinedJobRecords,
|
||||
swimlaneContainerWidth,
|
||||
anomalyChartRecords,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
timefilter,
|
||||
tableSeverity
|
||||
);
|
||||
} else {
|
||||
memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
explorerService,
|
||||
combinedJobRecords,
|
||||
swimlaneContainerWidth,
|
||||
[],
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
timefilter,
|
||||
tableSeverity
|
||||
);
|
||||
}
|
||||
}),
|
||||
// Load view-by swimlane data and filtered top influencers.
|
||||
// mergeMap is used to have access to the already fetched data and act on it in arg #1.
|
||||
// In arg #2 of mergeMap we combine the data and pass it on in the action format
|
||||
// which can be consumed by explorerReducer() later on.
|
||||
tap(explorerService.setChartsDataLoading),
|
||||
mergeMap(
|
||||
({ anomalyChartRecords, influencers, overallState, topFieldValues }) =>
|
||||
({
|
||||
anomalyChartRecords,
|
||||
influencers,
|
||||
overallState,
|
||||
topFieldValues,
|
||||
annotationsData,
|
||||
tableData,
|
||||
}) =>
|
||||
forkJoin({
|
||||
influencers:
|
||||
anomalyChartsData: memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
combinedJobRecords,
|
||||
swimlaneContainerWidth,
|
||||
selectedCells !== undefined && Array.isArray(anomalyChartRecords)
|
||||
? anomalyChartRecords
|
||||
: [],
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
timefilter,
|
||||
tableSeverity
|
||||
),
|
||||
filteredTopInfluencers:
|
||||
(selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) &&
|
||||
anomalyChartRecords !== undefined &&
|
||||
anomalyChartRecords.length > 0
|
||||
|
@ -280,24 +265,26 @@ const loadExplorerDataProvider = (
|
|||
swimlaneContainerWidth,
|
||||
influencersFilterQuery
|
||||
),
|
||||
}),
|
||||
(
|
||||
{ annotationsData, overallState, tableData },
|
||||
{ influencers, viewBySwimlaneState }
|
||||
): Partial<ExplorerState> => {
|
||||
return {
|
||||
annotations: annotationsData,
|
||||
influencers: influencers as any,
|
||||
loading: false,
|
||||
viewBySwimlaneDataLoading: false,
|
||||
overallSwimlaneData: overallState,
|
||||
viewBySwimlaneData: viewBySwimlaneState as any,
|
||||
tableData,
|
||||
swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState)
|
||||
? viewBySwimlaneState.cardinality
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}).pipe(
|
||||
tap(({ anomalyChartsData }) => {
|
||||
explorerService.setCharts(anomalyChartsData as ExplorerChartsData);
|
||||
}),
|
||||
map(({ viewBySwimlaneState, filteredTopInfluencers }) => {
|
||||
return {
|
||||
annotations: annotationsData,
|
||||
influencers: filteredTopInfluencers as any,
|
||||
loading: false,
|
||||
viewBySwimlaneDataLoading: false,
|
||||
anomalyChartsDataLoading: false,
|
||||
overallSwimlaneData: overallState,
|
||||
viewBySwimlaneData: viewBySwimlaneState as any,
|
||||
tableData,
|
||||
swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState)
|
||||
? viewBySwimlaneState.cardinality
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
@ -319,7 +306,7 @@ export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any)
|
|||
uiSettings,
|
||||
mlResultsService
|
||||
);
|
||||
const anomalyExplorerService = new AnomalyExplorerChartsService(
|
||||
const anomalyExplorerChartsService = new AnomalyExplorerChartsService(
|
||||
timefilter,
|
||||
mlApiServices,
|
||||
mlResultsService
|
||||
|
@ -327,7 +314,7 @@ export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any)
|
|||
return loadExplorerDataProvider(
|
||||
mlResultsService,
|
||||
anomalyTimelineService,
|
||||
anomalyExplorerService,
|
||||
anomalyExplorerChartsService,
|
||||
timefilter
|
||||
);
|
||||
}, []);
|
||||
|
|
|
@ -1,312 +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.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import {
|
||||
EuiFormRow,
|
||||
EuiCheckboxGroup,
|
||||
EuiInMemoryTableProps,
|
||||
EuiModal,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiSpacer,
|
||||
EuiButtonEmpty,
|
||||
EuiButton,
|
||||
EuiModalFooter,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiModalBody } from '@elastic/eui';
|
||||
import { EuiInMemoryTable } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useMlKibana } from '../contexts/kibana';
|
||||
import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public';
|
||||
import { getDefaultSwimlanePanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
|
||||
import { useDashboardService } from '../services/dashboard_service';
|
||||
import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants';
|
||||
import { JobId } from '../../../common/types/anomaly_detection_jobs';
|
||||
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../../embeddables';
|
||||
|
||||
export interface DashboardItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | undefined;
|
||||
attributes: DashboardSavedObject;
|
||||
}
|
||||
|
||||
export type EuiTableProps = EuiInMemoryTableProps<DashboardItem>;
|
||||
|
||||
function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) {
|
||||
return {
|
||||
type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
|
||||
title: getDefaultSwimlanePanelTitle(jobIds),
|
||||
};
|
||||
}
|
||||
|
||||
interface AddToDashboardControlProps {
|
||||
jobIds: JobId[];
|
||||
viewBy: string;
|
||||
onClose: (callback?: () => Promise<any>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for attaching anomaly swim lane embeddable to dashboards.
|
||||
*/
|
||||
export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
|
||||
onClose,
|
||||
jobIds,
|
||||
viewBy,
|
||||
}) => {
|
||||
const {
|
||||
notifications: { toasts },
|
||||
services: {
|
||||
application: { navigateToUrl },
|
||||
},
|
||||
} = useMlKibana();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboards();
|
||||
|
||||
return () => {
|
||||
fetchDashboards.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dashboardService = useDashboardService();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({
|
||||
[SWIMLANE_TYPE.OVERALL]: true,
|
||||
[SWIMLANE_TYPE.VIEW_BY]: false,
|
||||
});
|
||||
const [dashboardItems, setDashboardItems] = useState<DashboardItem[]>([]);
|
||||
const [selectedItems, setSelectedItems] = useState<DashboardItem[]>([]);
|
||||
|
||||
const fetchDashboards = useCallback(
|
||||
debounce(async (query?: string) => {
|
||||
try {
|
||||
const response = await dashboardService.fetchDashboards(query);
|
||||
const items: DashboardItem[] = response.savedObjects.map((savedObject) => {
|
||||
return {
|
||||
id: savedObject.id,
|
||||
title: savedObject.attributes.title,
|
||||
description: savedObject.attributes.description,
|
||||
attributes: savedObject.attributes,
|
||||
};
|
||||
});
|
||||
setDashboardItems(items);
|
||||
} catch (e) {
|
||||
toasts.danger({
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
const search: EuiTableProps['search'] = useMemo(() => {
|
||||
return {
|
||||
onChange: ({ queryText }) => {
|
||||
setIsLoading(true);
|
||||
fetchDashboards(queryText);
|
||||
},
|
||||
box: {
|
||||
incremental: true,
|
||||
'data-test-subj': 'mlDashboardsSearchBox',
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addSwimlaneToDashboardCallback = useCallback(async () => {
|
||||
const swimlanes = Object.entries(selectedSwimlanes)
|
||||
.filter(([, isSelected]) => isSelected)
|
||||
.map(([swimlaneType]) => swimlaneType);
|
||||
|
||||
for (const selectedDashboard of selectedItems) {
|
||||
const panelsData = swimlanes.map((swimlaneType) => {
|
||||
const config = getDefaultEmbeddablePanelConfig(jobIds);
|
||||
if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) {
|
||||
return {
|
||||
...config,
|
||||
embeddableConfig: {
|
||||
jobIds,
|
||||
swimlaneType,
|
||||
viewBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
embeddableConfig: {
|
||||
jobIds,
|
||||
swimlaneType,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await dashboardService.attachPanels(
|
||||
selectedDashboard.id,
|
||||
selectedDashboard.attributes,
|
||||
panelsData
|
||||
);
|
||||
toasts.success({
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle"
|
||||
defaultMessage='Dashboard "{dashboardTitle}" updated successfully'
|
||||
values={{ dashboardTitle: selectedDashboard.title }}
|
||||
/>
|
||||
),
|
||||
toastLifeTimeMs: 3000,
|
||||
});
|
||||
} catch (e) {
|
||||
toasts.danger({
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedSwimlanes, selectedItems]);
|
||||
|
||||
const columns: EuiTableProps['columns'] = [
|
||||
{
|
||||
field: 'title',
|
||||
name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', {
|
||||
defaultMessage: 'Title',
|
||||
}),
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', {
|
||||
defaultMessage: 'Description',
|
||||
}),
|
||||
truncateText: true,
|
||||
},
|
||||
];
|
||||
|
||||
const swimlaneTypeOptions = [
|
||||
{
|
||||
id: SWIMLANE_TYPE.OVERALL,
|
||||
label: i18n.translate('xpack.ml.explorer.overallLabel', {
|
||||
defaultMessage: 'Overall',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: SWIMLANE_TYPE.VIEW_BY,
|
||||
label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', {
|
||||
defaultMessage: 'View by {viewByField}',
|
||||
values: { viewByField: viewBy },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const selection: EuiTableProps['selection'] = {
|
||||
onSelectionChange: setSelectedItems,
|
||||
};
|
||||
|
||||
const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected);
|
||||
|
||||
return (
|
||||
<EuiModal onClose={onClose.bind(null, undefined)} data-test-subj="mlAddToDashboardModal">
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTitle"
|
||||
defaultMessage="Add swim lanes to dashboards"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.selectSwimlanesLabel"
|
||||
defaultMessage="Select swim lane view:"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiCheckboxGroup
|
||||
options={swimlaneTypeOptions}
|
||||
idToSelectedMap={selectedSwimlanes}
|
||||
onChange={(optionId) => {
|
||||
const newSelection = {
|
||||
...selectedSwimlanes,
|
||||
[optionId]: !selectedSwimlanes[optionId as SwimlaneType],
|
||||
};
|
||||
setSelectedSwimlanes(newSelection);
|
||||
}}
|
||||
data-test-subj="mlAddToDashboardSwimlaneTypeSelector"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.selectDashboardsLabel"
|
||||
defaultMessage="Select dashboards:"
|
||||
/>
|
||||
}
|
||||
data-test-subj="mlDashboardSelectionContainer"
|
||||
>
|
||||
<EuiInMemoryTable
|
||||
itemId="id"
|
||||
isSelectable={true}
|
||||
selection={selection}
|
||||
items={dashboardItems}
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
search={search}
|
||||
pagination={true}
|
||||
sorting={true}
|
||||
data-test-subj="mlDashboardSelectionTable"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onClose.bind(null, undefined)}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
<EuiButton
|
||||
disabled={noSwimlaneSelected || selectedItems.length !== 1}
|
||||
onClick={async () => {
|
||||
onClose(async () => {
|
||||
const selectedDashboardId = selectedItems[0].id;
|
||||
await addSwimlaneToDashboardCallback();
|
||||
await navigateToUrl(await dashboardService.getDashboardEditUrl(selectedDashboardId));
|
||||
});
|
||||
}}
|
||||
data-test-subj="mlAddAndEditDashboardButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTable.addAndEditDashboardLabel"
|
||||
defaultMessage="Add and edit dashboard"
|
||||
/>
|
||||
</EuiButton>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={onClose.bind(null, addSwimlaneToDashboardCallback)}
|
||||
disabled={noSwimlaneSelected || selectedItems.length === 0}
|
||||
data-test-subj="mlAddToDashboardsButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTable.addToDashboardLabel"
|
||||
defaultMessage="Add to dashboards"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* 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 React, { useMemo, useState, FC } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanel,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useMlKibana } from '../contexts/kibana';
|
||||
import type { AppStateSelectedCells, ExplorerJob } from './explorer_utils';
|
||||
import { TimeRangeBounds } from '../util/time_buckets';
|
||||
import { AddAnomalyChartsToDashboardControl } from './dashboard_controls/add_anomaly_charts_to_dashboard_controls';
|
||||
|
||||
interface AnomalyContextMenuProps {
|
||||
selectedJobs: ExplorerJob[];
|
||||
selectedCells?: AppStateSelectedCells;
|
||||
bounds?: TimeRangeBounds;
|
||||
interval?: number;
|
||||
chartsCount: number;
|
||||
}
|
||||
export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
|
||||
selectedJobs,
|
||||
selectedCells,
|
||||
bounds,
|
||||
interval,
|
||||
chartsCount,
|
||||
}) => {
|
||||
const {
|
||||
services: {
|
||||
application: { capabilities },
|
||||
},
|
||||
} = useMlKibana();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false);
|
||||
|
||||
const canEditDashboards = capabilities.dashboard?.createNew ?? false;
|
||||
const menuItems = useMemo(() => {
|
||||
const items = [];
|
||||
if (canEditDashboards) {
|
||||
items.push(
|
||||
<EuiContextMenuItem
|
||||
key="addToDashboard"
|
||||
onClick={setIsAddDashboardActive.bind(null, true)}
|
||||
data-test-subj="mlAnomalyAddChartsToDashboardButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.anomalies.addToDashboardLabel"
|
||||
defaultMessage="Add anomaly charts to dashboard"
|
||||
/>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}, [canEditDashboards]);
|
||||
|
||||
const jobIds = selectedJobs.map(({ id }) => id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuItems.length > 0 && (
|
||||
<EuiFlexItem grow={false} style={{ marginLeft: 'auto', alignSelf: 'baseline' }}>
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
size="s"
|
||||
aria-label={i18n.translate('xpack.ml.explorer.anomalies.actionsAriaLabel', {
|
||||
defaultMessage: 'Actions',
|
||||
})}
|
||||
color="subdued"
|
||||
iconType="boxesHorizontal"
|
||||
onClick={setIsMenuOpen.bind(null, !isMenuOpen)}
|
||||
data-test-subj="mlExplorerAnomalyPanelMenu"
|
||||
disabled={chartsCount < 1}
|
||||
/>
|
||||
}
|
||||
isOpen={isMenuOpen}
|
||||
closePopover={setIsMenuOpen.bind(null, false)}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiContextMenuPanel items={menuItems} />
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{isAddDashboardsActive && selectedJobs && (
|
||||
<AddAnomalyChartsToDashboardControl
|
||||
onClose={async (callback) => {
|
||||
setIsAddDashboardActive(false);
|
||||
if (callback) {
|
||||
await callback();
|
||||
}
|
||||
}}
|
||||
selectedCells={selectedCells}
|
||||
bounds={bounds}
|
||||
interval={interval}
|
||||
jobIds={jobIds}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -24,7 +24,7 @@ import {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { OVERALL_LABEL, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants';
|
||||
import { AddToDashboardControl } from './add_to_dashboard_control';
|
||||
import { AddSwimlaneToDashboardControl } from './dashboard_controls/add_swimlane_to_dashboard_controls';
|
||||
import { useMlKibana } from '../contexts/kibana';
|
||||
import { TimeBuckets } from '../util/time_buckets';
|
||||
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
|
||||
|
@ -294,7 +294,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
|
|||
)}
|
||||
</EuiPanel>
|
||||
{isAddDashboardsActive && selectedJobs && (
|
||||
<AddToDashboardControl
|
||||
<AddSwimlaneToDashboardControl
|
||||
onClose={async (callback) => {
|
||||
setIsAddDashboardActive(false);
|
||||
if (callback) {
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* 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 React, { FC, useCallback, useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiFieldNumber, EuiFormRow, formatDate } from '@elastic/eui';
|
||||
import { useDashboardTable } from './use_dashboards_table';
|
||||
import { AddToDashboardControl } from './add_to_dashboard_controls';
|
||||
import { useAddToDashboardActions } from './use_add_to_dashboard_actions';
|
||||
import { AppStateSelectedCells, getSelectionTimeRange } from '../explorer_utils';
|
||||
import { TimeRange } from '../../../../../../../src/plugins/data/common/query';
|
||||
import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../services/anomaly_explorer_charts_service';
|
||||
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
|
||||
import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../../embeddables';
|
||||
import { getDefaultExplorerChartsPanelTitle } from '../../../embeddables/anomaly_charts/anomaly_charts_embeddable';
|
||||
import { TimeRangeBounds } from '../../util/time_buckets';
|
||||
import { useTableSeverity } from '../../components/controls/select_severity';
|
||||
import { MAX_ANOMALY_CHARTS_ALLOWED } from '../../../embeddables/anomaly_charts/anomaly_charts_initializer';
|
||||
|
||||
function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) {
|
||||
return {
|
||||
type: ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE,
|
||||
title: getDefaultExplorerChartsPanelTitle(jobIds),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddToDashboardControlProps {
|
||||
jobIds: string[];
|
||||
selectedCells?: AppStateSelectedCells;
|
||||
bounds?: TimeRangeBounds;
|
||||
interval?: number;
|
||||
onClose: (callback?: () => Promise<void>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for attaching anomaly swim lane embeddable to dashboards.
|
||||
*/
|
||||
export const AddAnomalyChartsToDashboardControl: FC<AddToDashboardControlProps> = ({
|
||||
onClose,
|
||||
jobIds,
|
||||
selectedCells,
|
||||
bounds,
|
||||
interval,
|
||||
}) => {
|
||||
const [severity] = useTableSeverity();
|
||||
const [maxSeriesToPlot, setMaxSeriesToPlot] = useState(DEFAULT_MAX_SERIES_TO_PLOT);
|
||||
|
||||
const getPanelsData = useCallback(async () => {
|
||||
let timeRange: TimeRange | undefined;
|
||||
if (selectedCells !== undefined && interval !== undefined && bounds !== undefined) {
|
||||
const { earliestMs, latestMs } = getSelectionTimeRange(selectedCells, interval, bounds);
|
||||
timeRange = {
|
||||
from: formatDate(earliestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
|
||||
to: formatDate(latestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
|
||||
mode: 'absolute',
|
||||
};
|
||||
}
|
||||
|
||||
const config = getDefaultEmbeddablePanelConfig(jobIds);
|
||||
return [
|
||||
{
|
||||
...config,
|
||||
embeddableConfig: {
|
||||
jobIds,
|
||||
maxSeriesToPlot: maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT,
|
||||
severityThreshold: severity.val,
|
||||
...(timeRange ?? {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [selectedCells, interval, bounds, jobIds, maxSeriesToPlot, severity]);
|
||||
|
||||
const { selectedItems, selection, dashboardItems, isLoading, search } = useDashboardTable();
|
||||
const { addToDashboardAndEditCallback, addToDashboardCallback } = useAddToDashboardActions({
|
||||
onClose,
|
||||
getPanelsData,
|
||||
selectedDashboards: selectedItems,
|
||||
});
|
||||
const title = (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.anomalyCharts.dashboardsTitle"
|
||||
defaultMessage="Add anomaly charts to dashboards"
|
||||
/>
|
||||
);
|
||||
|
||||
const disabled = selectedItems.length < 1 && !Array.isArray(jobIds === undefined);
|
||||
|
||||
const extraControls = (
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.anomalyCharts.maxSeriesToPlotLabel"
|
||||
defaultMessage="Maximum number of series to plot"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
data-test-subj="mlAnomalyChartsInitializerMaxSeries"
|
||||
id="selectMaxSeriesToPlot"
|
||||
name="selectMaxSeriesToPlot"
|
||||
value={maxSeriesToPlot}
|
||||
onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))}
|
||||
min={0}
|
||||
max={MAX_ANOMALY_CHARTS_ALLOWED}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
||||
return (
|
||||
<AddToDashboardControl
|
||||
onClose={onClose}
|
||||
selectedItems={selectedItems}
|
||||
selection={selection}
|
||||
dashboardItems={dashboardItems}
|
||||
isLoading={isLoading}
|
||||
search={search}
|
||||
addToDashboardAndEditCallback={addToDashboardAndEditCallback}
|
||||
addToDashboardCallback={addToDashboardCallback}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
>
|
||||
{extraControls}
|
||||
</AddToDashboardControl>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* 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 React, { FC, useCallback, useState } from 'react';
|
||||
import { EuiFormRow, EuiCheckboxGroup, EuiInMemoryTableProps, EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DashboardSavedObject } from '../../../../../../../src/plugins/dashboard/public';
|
||||
import { getDefaultSwimlanePanelTitle } from '../../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
|
||||
import { SWIMLANE_TYPE, SwimlaneType } from '../explorer_constants';
|
||||
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
|
||||
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../../../embeddables';
|
||||
import { useDashboardTable } from './use_dashboards_table';
|
||||
import { AddToDashboardControl } from './add_to_dashboard_controls';
|
||||
import { useAddToDashboardActions } from './use_add_to_dashboard_actions';
|
||||
|
||||
export interface DashboardItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | undefined;
|
||||
attributes: DashboardSavedObject;
|
||||
}
|
||||
|
||||
export type EuiTableProps = EuiInMemoryTableProps<DashboardItem>;
|
||||
|
||||
function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) {
|
||||
return {
|
||||
type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
|
||||
title: getDefaultSwimlanePanelTitle(jobIds),
|
||||
};
|
||||
}
|
||||
|
||||
interface AddToDashboardControlProps {
|
||||
jobIds: JobId[];
|
||||
viewBy: string;
|
||||
onClose: (callback?: () => Promise<void>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for attaching anomaly swim lane embeddable to dashboards.
|
||||
*/
|
||||
export const AddSwimlaneToDashboardControl: FC<AddToDashboardControlProps> = ({
|
||||
onClose,
|
||||
jobIds,
|
||||
viewBy,
|
||||
}) => {
|
||||
const { selectedItems, selection, dashboardItems, isLoading, search } = useDashboardTable();
|
||||
|
||||
const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({
|
||||
[SWIMLANE_TYPE.OVERALL]: true,
|
||||
[SWIMLANE_TYPE.VIEW_BY]: false,
|
||||
});
|
||||
|
||||
const getPanelsData = useCallback(async () => {
|
||||
const swimlanes = Object.entries(selectedSwimlanes)
|
||||
.filter(([, isSelected]) => isSelected)
|
||||
.map(([swimlaneType]) => swimlaneType);
|
||||
|
||||
return swimlanes.map((swimlaneType) => {
|
||||
const config = getDefaultEmbeddablePanelConfig(jobIds);
|
||||
if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) {
|
||||
return {
|
||||
...config,
|
||||
embeddableConfig: {
|
||||
jobIds,
|
||||
swimlaneType,
|
||||
viewBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
embeddableConfig: {
|
||||
jobIds,
|
||||
swimlaneType,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [selectedSwimlanes, selectedItems]);
|
||||
const { addToDashboardAndEditCallback, addToDashboardCallback } = useAddToDashboardActions({
|
||||
onClose,
|
||||
getPanelsData,
|
||||
selectedDashboards: selectedItems,
|
||||
});
|
||||
|
||||
const swimlaneTypeOptions = [
|
||||
{
|
||||
id: SWIMLANE_TYPE.OVERALL,
|
||||
label: i18n.translate('xpack.ml.explorer.overallLabel', {
|
||||
defaultMessage: 'Overall',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: SWIMLANE_TYPE.VIEW_BY,
|
||||
label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', {
|
||||
defaultMessage: 'View by {viewByField}',
|
||||
values: { viewByField: viewBy },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected);
|
||||
|
||||
const extraControls = (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel"
|
||||
defaultMessage="Select swim lane view:"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiCheckboxGroup
|
||||
options={swimlaneTypeOptions}
|
||||
idToSelectedMap={selectedSwimlanes}
|
||||
onChange={(optionId) => {
|
||||
const newSelection = {
|
||||
...selectedSwimlanes,
|
||||
[optionId]: !selectedSwimlanes[optionId as SwimlaneType],
|
||||
};
|
||||
setSelectedSwimlanes(newSelection);
|
||||
}}
|
||||
data-test-subj="mlAddToDashboardSwimlaneTypeSelector"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
|
||||
const title = (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle"
|
||||
defaultMessage="Add swim lanes to dashboards"
|
||||
/>
|
||||
);
|
||||
|
||||
const disabled = noSwimlaneSelected || selectedItems.length === 0;
|
||||
return (
|
||||
<AddToDashboardControl
|
||||
onClose={onClose}
|
||||
selectedItems={selectedItems}
|
||||
selection={selection}
|
||||
dashboardItems={dashboardItems}
|
||||
isLoading={isLoading}
|
||||
search={search}
|
||||
addToDashboardAndEditCallback={addToDashboardAndEditCallback}
|
||||
addToDashboardCallback={addToDashboardCallback}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
>
|
||||
{extraControls}
|
||||
</AddToDashboardControl>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 React, { FC } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFormRow,
|
||||
EuiInMemoryTable,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiTableProps, useDashboardTable } from './use_dashboards_table';
|
||||
|
||||
export const columns: EuiTableProps['columns'] = [
|
||||
{
|
||||
field: 'title',
|
||||
name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', {
|
||||
defaultMessage: 'Title',
|
||||
}),
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', {
|
||||
defaultMessage: 'Description',
|
||||
}),
|
||||
truncateText: true,
|
||||
},
|
||||
];
|
||||
|
||||
interface AddToDashboardControlProps extends ReturnType<typeof useDashboardTable> {
|
||||
onClose: (callback?: () => Promise<void>) => void;
|
||||
addToDashboardAndEditCallback: () => Promise<void>;
|
||||
addToDashboardCallback: () => Promise<void>;
|
||||
title: React.ReactNode;
|
||||
disabled: boolean;
|
||||
children?: React.ReactElement;
|
||||
}
|
||||
export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
|
||||
onClose,
|
||||
selection,
|
||||
dashboardItems,
|
||||
isLoading,
|
||||
search,
|
||||
addToDashboardAndEditCallback,
|
||||
addToDashboardCallback,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<EuiModal onClose={onClose.bind(null, undefined)} data-test-subj="mlAddToDashboardModal">
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
{children}
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.selectDashboardsLabel"
|
||||
defaultMessage="Select dashboards:"
|
||||
/>
|
||||
}
|
||||
data-test-subj="mlDashboardSelectionContainer"
|
||||
>
|
||||
<EuiInMemoryTable
|
||||
itemId="id"
|
||||
isSelectable={true}
|
||||
selection={selection}
|
||||
items={dashboardItems}
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
search={search}
|
||||
pagination={true}
|
||||
sorting={true}
|
||||
data-test-subj="mlDashboardSelectionTable"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onClose.bind(null, undefined)}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.addToDashboard.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
<EuiButton
|
||||
disabled={disabled}
|
||||
onClick={onClose.bind(null, addToDashboardAndEditCallback)}
|
||||
data-test-subj="mlAddAndEditDashboardButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTable.addAndEditDashboardLabel"
|
||||
defaultMessage="Add and edit dashboard"
|
||||
/>
|
||||
</EuiButton>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={onClose.bind(null, addToDashboardCallback)}
|
||||
disabled={disabled}
|
||||
data-test-subj="mlAddToDashboardsButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTable.addToDashboardLabel"
|
||||
defaultMessage="Add to dashboards"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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 React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { DashboardItem } from './use_dashboards_table';
|
||||
import { SavedDashboardPanel } from '../../../../../../../src/plugins/dashboard/common/types';
|
||||
import { useMlKibana } from '../../contexts/kibana';
|
||||
import { useDashboardService } from '../../services/dashboard_service';
|
||||
|
||||
export const useAddToDashboardActions = ({
|
||||
onClose,
|
||||
getPanelsData,
|
||||
selectedDashboards,
|
||||
}: {
|
||||
onClose: (callback?: () => Promise<void>) => void;
|
||||
getPanelsData: (
|
||||
selectedDashboards: DashboardItem[]
|
||||
) => Promise<Array<Pick<SavedDashboardPanel, 'title' | 'type' | 'embeddableConfig'>>>;
|
||||
selectedDashboards: DashboardItem[];
|
||||
}) => {
|
||||
const {
|
||||
notifications: { toasts },
|
||||
services: {
|
||||
application: { navigateToUrl },
|
||||
},
|
||||
} = useMlKibana();
|
||||
const dashboardService = useDashboardService();
|
||||
|
||||
const addToDashboardCallback = useCallback(async () => {
|
||||
const panelsData = await getPanelsData(selectedDashboards);
|
||||
for (const selectedDashboard of selectedDashboards) {
|
||||
try {
|
||||
await dashboardService.attachPanels(
|
||||
selectedDashboard.id,
|
||||
selectedDashboard.attributes,
|
||||
panelsData
|
||||
);
|
||||
toasts.success({
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle"
|
||||
defaultMessage='Dashboard "{dashboardTitle}" updated successfully'
|
||||
values={{ dashboardTitle: selectedDashboard.title }}
|
||||
/>
|
||||
),
|
||||
toastLifeTimeMs: 3000,
|
||||
});
|
||||
} catch (e) {
|
||||
toasts.danger({
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedDashboards, getPanelsData]);
|
||||
|
||||
const addToDashboardAndEditCallback = useCallback(async () => {
|
||||
onClose(async () => {
|
||||
await addToDashboardCallback();
|
||||
const selectedDashboardId = selectedDashboards[0].id;
|
||||
await navigateToUrl(await dashboardService.getDashboardEditUrl(selectedDashboardId));
|
||||
});
|
||||
}, [addToDashboardCallback, selectedDashboards, navigateToUrl]);
|
||||
|
||||
return { addToDashboardCallback, addToDashboardAndEditCallback };
|
||||
};
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* 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 { EuiInMemoryTableProps } from '@elastic/eui';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import type { DashboardSavedObject } from '../../../../../../../src/plugins/dashboard/public';
|
||||
import { useDashboardService } from '../../services/dashboard_service';
|
||||
import { useMlKibana } from '../../contexts/kibana';
|
||||
|
||||
export interface DashboardItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | undefined;
|
||||
attributes: DashboardSavedObject;
|
||||
}
|
||||
|
||||
export type EuiTableProps = EuiInMemoryTableProps<DashboardItem>;
|
||||
|
||||
export const useDashboardTable = () => {
|
||||
const {
|
||||
notifications: { toasts },
|
||||
} = useMlKibana();
|
||||
|
||||
const dashboardService = useDashboardService();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboards();
|
||||
|
||||
return () => {
|
||||
fetchDashboards.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const search: EuiTableProps['search'] = useMemo(() => {
|
||||
return {
|
||||
onChange: ({ queryText }) => {
|
||||
setIsLoading(true);
|
||||
fetchDashboards(queryText);
|
||||
},
|
||||
box: {
|
||||
incremental: true,
|
||||
'data-test-subj': 'mlDashboardsSearchBox',
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [dashboardItems, setDashboardItems] = useState<DashboardItem[]>([]);
|
||||
const [selectedItems, setSelectedItems] = useState<DashboardItem[]>([]);
|
||||
|
||||
const fetchDashboards = useCallback(
|
||||
debounce(async (query?: string) => {
|
||||
try {
|
||||
const response = await dashboardService.fetchDashboards(query);
|
||||
const items: DashboardItem[] = response.savedObjects.map((savedObject) => {
|
||||
return {
|
||||
id: savedObject.id,
|
||||
title: savedObject.attributes.title,
|
||||
description: savedObject.attributes.description,
|
||||
attributes: savedObject.attributes,
|
||||
};
|
||||
});
|
||||
setDashboardItems(items);
|
||||
} catch (e) {
|
||||
toasts.danger({
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
const selection: EuiTableProps['selection'] = {
|
||||
onSelectionChange: setSelectedItems,
|
||||
};
|
||||
return { dashboardItems, selectedItems, selection, search, isLoading };
|
||||
};
|
|
@ -72,6 +72,7 @@ import { getToastNotifications } from '../util/dependency_cache';
|
|||
import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings';
|
||||
import { withKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator';
|
||||
import { AnomalyContextMenu } from './anomaly_context_menu';
|
||||
|
||||
const ExplorerPage = ({
|
||||
children,
|
||||
|
@ -431,14 +432,32 @@ export class ExplorerUI extends React.Component {
|
|||
)}
|
||||
{loading === false && (
|
||||
<EuiPanel>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.anomaliesTitle"
|
||||
defaultMessage="Anomalies"
|
||||
<EuiFlexGroup direction="row" gutterSize="m" responsive={false} alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.anomaliesTitle"
|
||||
defaultMessage="Anomalies"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false} style={{ marginLeft: 'auto', alignSelf: 'baseline' }}>
|
||||
<AnomalyContextMenu
|
||||
selectedJobs={selectedJobs}
|
||||
selectedCells={selectedCells}
|
||||
bounds={bounds}
|
||||
interval={
|
||||
this.props.explorerState.swimlaneBucketInterval
|
||||
? this.props.explorerState.swimlaneBucketInterval.asSeconds()
|
||||
: undefined
|
||||
}
|
||||
chartsCount={chartsData.seriesToPlot.length}
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
|
|
|
@ -18,10 +18,12 @@ export const DRAG_SELECT_ACTION = {
|
|||
} as const;
|
||||
|
||||
export const EXPLORER_ACTION = {
|
||||
CLEAR_EXPLORER_DATA: 'clearExplorerData',
|
||||
CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings',
|
||||
CLEAR_JOBS: 'clearJobs',
|
||||
JOB_SELECTION_CHANGE: 'jobSelectionChange',
|
||||
SET_CHARTS: 'setCharts',
|
||||
SET_CHARTS_DATA_LOADING: 'setChartsDataLoading',
|
||||
SET_EXPLORER_DATA: 'setExplorerData',
|
||||
SET_FILTER_DATA: 'setFilterData',
|
||||
SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings',
|
||||
|
@ -77,6 +79,6 @@ export const OVERALL_LABEL = i18n.translate('xpack.ml.explorer.overallLabel', {
|
|||
export const ANOMALY_SWIM_LANE_HARD_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* Default page size fot the anomaly swim lane.
|
||||
* Default page size for the anomaly swim lane.
|
||||
*/
|
||||
export const SWIM_LANE_DEFAULT_PAGE_SIZE = 10;
|
||||
|
|
|
@ -107,6 +107,9 @@ const setFilterDataActionCreator = (
|
|||
export const explorerService = {
|
||||
appState$: explorerAppState$,
|
||||
state$: explorerState$,
|
||||
clearExplorerData: () => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_EXPLORER_DATA });
|
||||
},
|
||||
clearInfluencerFilterSettings: () => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS });
|
||||
},
|
||||
|
@ -137,6 +140,9 @@ export const explorerService = {
|
|||
setFilterData: (payload: Partial<Exclude<ExplorerAppState['mlExplorerFilter'], undefined>>) => {
|
||||
explorerAction$.next(setFilterDataActionCreator(payload));
|
||||
},
|
||||
setChartsDataLoading: () => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING });
|
||||
},
|
||||
setSwimlaneContainerWidth: (payload: number) => {
|
||||
explorerAction$.next({
|
||||
type: EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH,
|
||||
|
|
|
@ -12,6 +12,7 @@ import { TimeRangeBounds } from '../util/time_buckets';
|
|||
import { RecordForInfluencer } from '../services/results_service/results_service';
|
||||
import { InfluencersFilterQuery } from '../../../common/types/es_client';
|
||||
import { MlResultsService } from '../services/results_service';
|
||||
import { EntityField } from '../../../common/util/anomaly_utils';
|
||||
|
||||
interface ClearedSelectedAnomaliesState {
|
||||
selectedCells: undefined;
|
||||
|
@ -60,7 +61,7 @@ export declare const getSelectionJobIds: (
|
|||
export declare const getSelectionInfluencers: (
|
||||
selectedCells: AppStateSelectedCells | undefined,
|
||||
fieldName: string
|
||||
) => string[];
|
||||
) => EntityField[];
|
||||
|
||||
interface SelectionTimeRange {
|
||||
earliestMs: number;
|
||||
|
@ -149,6 +150,7 @@ export declare const loadDataForCharts: (
|
|||
) => Promise<ChartRecord[] | undefined>;
|
||||
|
||||
export declare const loadFilteredTopInfluencers: (
|
||||
mlResultsService: MlResultsService,
|
||||
jobIds: string[],
|
||||
earliestMs: number,
|
||||
latestMs: number,
|
||||
|
|
|
@ -536,65 +536,6 @@ export async function loadAnomaliesTableData(
|
|||
});
|
||||
}
|
||||
|
||||
// track the request to be able to ignore out of date requests
|
||||
// and avoid race conditions ending up with the wrong charts.
|
||||
let requestCount = 0;
|
||||
export async function loadDataForCharts(
|
||||
mlResultsService,
|
||||
jobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
influencers = [],
|
||||
selectedCells,
|
||||
influencersFilterQuery,
|
||||
// choose whether or not to keep track of the request that could be out of date
|
||||
// in Anomaly Explorer this is being used to ignore any request that are out of date
|
||||
// but in embeddables, we might have multiple requests coming from multiple different panels
|
||||
takeLatestOnly = true
|
||||
) {
|
||||
return new Promise((resolve) => {
|
||||
// Just skip doing the request when this function
|
||||
// is called without the minimum required data.
|
||||
if (
|
||||
selectedCells === undefined &&
|
||||
influencers.length === 0 &&
|
||||
influencersFilterQuery === undefined
|
||||
) {
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
const newRequestCount = ++requestCount;
|
||||
requestCount = newRequestCount;
|
||||
|
||||
// Load the top anomalies (by record_score) which will be displayed in the charts.
|
||||
mlResultsService
|
||||
.getRecordsForInfluencer(
|
||||
jobIds,
|
||||
influencers,
|
||||
0,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
500,
|
||||
influencersFilterQuery
|
||||
)
|
||||
.then((resp) => {
|
||||
// Ignore this response if it's returned by an out of date promise
|
||||
if (takeLatestOnly && newRequestCount < requestCount) {
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
if (
|
||||
(selectedCells !== undefined && Object.keys(selectedCells).length > 0) ||
|
||||
influencersFilterQuery !== undefined
|
||||
) {
|
||||
resolve(resp.records);
|
||||
}
|
||||
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTopInfluencers(
|
||||
mlResultsService,
|
||||
selectedJobIds,
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
import { checkSelectedCells } from './check_selected_cells';
|
||||
import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings';
|
||||
import { jobSelectionChange } from './job_selection_change';
|
||||
import { ExplorerState } from './state';
|
||||
import { ExplorerState, getExplorerDefaultState } from './state';
|
||||
import { setInfluencerFilterSettings } from './set_influencer_filter_settings';
|
||||
import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder';
|
||||
import { getTimeBoundsFromSelection } from '../../hooks/use_selected_cells';
|
||||
|
@ -31,6 +31,10 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
let nextState: ExplorerState;
|
||||
|
||||
switch (type) {
|
||||
case EXPLORER_ACTION.CLEAR_EXPLORER_DATA:
|
||||
nextState = getExplorerDefaultState();
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS:
|
||||
nextState = clearInfluencerFilterSettings(state);
|
||||
break;
|
||||
|
@ -49,6 +53,14 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
nextState = jobSelectionChange(state, payload);
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_CHARTS_DATA_LOADING:
|
||||
nextState = {
|
||||
...state,
|
||||
anomalyChartsDataLoading: true,
|
||||
chartsData: getDefaultChartsData(),
|
||||
};
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_CHARTS:
|
||||
nextState = {
|
||||
...state,
|
||||
|
|
|
@ -28,6 +28,7 @@ import { InfluencersFilterQuery } from '../../../../../common/types/es_client';
|
|||
|
||||
export interface ExplorerState {
|
||||
annotations: AnnotationsTable;
|
||||
anomalyChartsDataLoading: boolean;
|
||||
chartsData: ExplorerChartsData;
|
||||
fieldFormatsLoading: boolean;
|
||||
filterActive: boolean;
|
||||
|
@ -69,6 +70,7 @@ export function getExplorerDefaultState(): ExplorerState {
|
|||
annotationsData: [],
|
||||
aggregations: {},
|
||||
},
|
||||
anomalyChartsDataLoading: true,
|
||||
chartsData: getDefaultChartsData(),
|
||||
fieldFormatsLoading: false,
|
||||
filterActive: false,
|
||||
|
|
|
@ -159,6 +159,14 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
|
|||
}
|
||||
}, [JSON.stringify(jobIds)]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// upon component unmounting
|
||||
// clear any data to prevent next page from rendering old charts
|
||||
explorerService.clearExplorerData();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* TODO get rid of the intermediate state in explorerService.
|
||||
* URL state should be the only source of truth for related props.
|
||||
|
|
|
@ -10,4 +10,5 @@ export const createAnomalyExplorerChartsServiceMock = () => ({
|
|||
getAnomalyData: jest.fn(),
|
||||
setTimeRange: jest.fn(),
|
||||
getTimeBounds: jest.fn(),
|
||||
loadDataForCharts$: jest.fn(),
|
||||
});
|
||||
|
|
|
@ -13,7 +13,6 @@ import { of } from 'rxjs';
|
|||
import { cloneDeep } from 'lodash';
|
||||
import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
|
||||
import type { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service';
|
||||
import type { ExplorerService } from '../explorer/explorer_dashboard_service';
|
||||
import type { MlApiServices } from './ml_api_service';
|
||||
import type { MlResultsService } from './results_service';
|
||||
import { getDefaultChartsData } from '../explorer/explorer_charts/explorer_charts_container_service';
|
||||
|
@ -89,9 +88,6 @@ describe('AnomalyExplorerChartsService', () => {
|
|||
(mlApiServicesMock as unknown) as MlApiServices,
|
||||
(mlResultsServiceMock as unknown) as MlResultsService
|
||||
);
|
||||
const explorerService = {
|
||||
setCharts: jest.fn(),
|
||||
};
|
||||
|
||||
const timeRange = {
|
||||
earliestMs: 1486656000000,
|
||||
|
@ -104,13 +100,8 @@ describe('AnomalyExplorerChartsService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
explorerService.setCharts.mockClear();
|
||||
});
|
||||
|
||||
test('should return anomaly data without explorer service', async () => {
|
||||
const anomalyData = (await anomalyExplorerService.getAnomalyData(
|
||||
undefined,
|
||||
(combinedJobRecords as unknown) as Record<string, CombinedJob>,
|
||||
1000,
|
||||
mockAnomalyChartRecords,
|
||||
|
@ -123,27 +114,8 @@ describe('AnomalyExplorerChartsService', () => {
|
|||
assertAnomalyDataResult(anomalyData);
|
||||
});
|
||||
|
||||
test('should set anomaly data with explorer service side effects', async () => {
|
||||
await anomalyExplorerService.getAnomalyData(
|
||||
(explorerService as unknown) as ExplorerService,
|
||||
(combinedJobRecords as unknown) as Record<string, CombinedJob>,
|
||||
1000,
|
||||
mockAnomalyChartRecords,
|
||||
timeRange.earliestMs,
|
||||
timeRange.latestMs,
|
||||
timefilterMock,
|
||||
0,
|
||||
12
|
||||
);
|
||||
|
||||
expect(explorerService.setCharts.mock.calls.length).toBe(2);
|
||||
assertAnomalyDataResult(explorerService.setCharts.mock.calls[0][0]);
|
||||
assertAnomalyDataResult(explorerService.setCharts.mock.calls[1][0]);
|
||||
});
|
||||
|
||||
test('call anomalyChangeListener with empty series config', async () => {
|
||||
const anomalyData = (await anomalyExplorerService.getAnomalyData(
|
||||
undefined,
|
||||
// @ts-ignore
|
||||
(combinedJobRecords as unknown) as Record<string, CombinedJob>,
|
||||
1000,
|
||||
|
@ -165,7 +137,6 @@ describe('AnomalyExplorerChartsService', () => {
|
|||
mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.';
|
||||
|
||||
const anomalyData = (await anomalyExplorerService.getAnomalyData(
|
||||
undefined,
|
||||
(combinedJobRecords as unknown) as Record<string, CombinedJob>,
|
||||
1000,
|
||||
mockAnomalyChartRecordsClone,
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { each, find, get, map, reduce, sortBy } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map as mapObservable } from 'rxjs/operators';
|
||||
import { RecordForInfluencer } from './results_service/results_service';
|
||||
import {
|
||||
isMappableJob,
|
||||
|
@ -29,7 +31,6 @@ import { CHART_TYPE, ChartType } from '../explorer/explorer_constants';
|
|||
import type { ChartRecord } from '../explorer/explorer_utils';
|
||||
import { RecordsForCriteria, ScheduledEventsByBucket } from './results_service/result_service_rx';
|
||||
import { isPopulatedObject } from '../../../common/util/object_utils';
|
||||
import type { ExplorerService } from '../explorer/explorer_dashboard_service';
|
||||
import { AnomalyRecordDoc } from '../../../common/types/anomalies';
|
||||
import {
|
||||
ExplorerChartsData,
|
||||
|
@ -37,6 +38,8 @@ import {
|
|||
} from '../explorer/explorer_charts/explorer_charts_container_service';
|
||||
import { TimeRangeBounds } from '../util/time_buckets';
|
||||
import { isDefined } from '../../../common/types/guards';
|
||||
import { AppStateSelectedCells } from '../explorer/explorer_utils';
|
||||
import { InfluencersFilterQuery } from '../../../common/types/es_client';
|
||||
const CHART_MAX_POINTS = 500;
|
||||
const ANOMALIES_MAX_RESULTS = 500;
|
||||
const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
|
||||
|
@ -370,15 +373,53 @@ export class AnomalyExplorerChartsService {
|
|||
// Getting only necessary job config and datafeed config without the stats
|
||||
jobIds.map((jobId) => this.mlApiServices.jobs.jobForCloning(jobId))
|
||||
);
|
||||
const combinedJobs = combinedResults
|
||||
return combinedResults
|
||||
.filter(isDefined)
|
||||
.filter((r) => r.job !== undefined && r.datafeed !== undefined)
|
||||
.map(({ job, datafeed }) => ({ ...job, datafeed_config: datafeed } as CombinedJob));
|
||||
return combinedJobs;
|
||||
}
|
||||
|
||||
public loadDataForCharts$(
|
||||
jobIds: string[],
|
||||
earliestMs: number,
|
||||
latestMs: number,
|
||||
influencers: EntityField[] = [],
|
||||
selectedCells: AppStateSelectedCells | undefined,
|
||||
influencersFilterQuery: InfluencersFilterQuery
|
||||
): Observable<RecordForInfluencer[]> {
|
||||
if (
|
||||
selectedCells === undefined &&
|
||||
influencers.length === 0 &&
|
||||
influencersFilterQuery === undefined
|
||||
) {
|
||||
of([]);
|
||||
}
|
||||
|
||||
return this.mlResultsService
|
||||
.getRecordsForInfluencer$(
|
||||
jobIds,
|
||||
influencers,
|
||||
0,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
500,
|
||||
influencersFilterQuery
|
||||
)
|
||||
.pipe(
|
||||
mapObservable((resp): RecordForInfluencer[] => {
|
||||
if (
|
||||
(selectedCells !== undefined && Object.keys(selectedCells).length > 0) ||
|
||||
influencersFilterQuery !== undefined
|
||||
) {
|
||||
return resp.records;
|
||||
}
|
||||
|
||||
return [] as RecordForInfluencer[];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async getAnomalyData(
|
||||
explorerService: ExplorerService | undefined,
|
||||
combinedJobRecords: Record<string, CombinedJob>,
|
||||
chartsContainerWidth: number,
|
||||
anomalyRecords: ChartRecord[] | undefined,
|
||||
|
@ -486,9 +527,6 @@ export class AnomalyExplorerChartsService {
|
|||
data.errorMessages = errorMessages;
|
||||
}
|
||||
|
||||
if (explorerService) {
|
||||
explorerService.setCharts({ ...data });
|
||||
}
|
||||
if (seriesConfigs.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
@ -848,9 +886,6 @@ export class AnomalyExplorerChartsService {
|
|||
// push map data in if it's available
|
||||
data.seriesToPlot.push(...mapData);
|
||||
}
|
||||
if (explorerService) {
|
||||
explorerService.setCharts({ ...data });
|
||||
}
|
||||
return Promise.resolve(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -860,7 +895,7 @@ export class AnomalyExplorerChartsService {
|
|||
}
|
||||
|
||||
public processRecordsForDisplay(
|
||||
jobRecords: Record<string, CombinedJob>,
|
||||
combinedJobRecords: Record<string, CombinedJob>,
|
||||
anomalyRecords: RecordForInfluencer[]
|
||||
): { records: ChartRecord[]; errors: Record<string, Set<string>> | undefined } {
|
||||
// Aggregate the anomaly data by detector, and entity (by/over/partition).
|
||||
|
@ -875,7 +910,7 @@ export class AnomalyExplorerChartsService {
|
|||
// Check if we can plot a chart for this record, depending on whether the source data
|
||||
// is chartable, and if model plot is enabled for the job.
|
||||
|
||||
const job = jobRecords[record.job_id];
|
||||
const job = combinedJobRecords[record.job_id];
|
||||
|
||||
// if we already know this job has datafeed aggregations we cannot support
|
||||
// no need to do more checks
|
||||
|
|
|
@ -22,9 +22,11 @@ import { MlApiServices } from '../ml_api_service';
|
|||
import { CriteriaField } from './index';
|
||||
import { findAggField } from '../../../../common/util/validation_utils';
|
||||
import { getDatafeedAggregations } from '../../../../common/util/datafeed_utils';
|
||||
import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils';
|
||||
import { aggregationTypeTransform, EntityField } from '../../../../common/util/anomaly_utils';
|
||||
import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types';
|
||||
import { isPopulatedObject } from '../../../../common/util/object_utils';
|
||||
import { InfluencersFilterQuery } from '../../../../common/types/es_client';
|
||||
import { RecordForInfluencer } from './results_service';
|
||||
|
||||
interface ResultResponse {
|
||||
success: boolean;
|
||||
|
@ -633,5 +635,135 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) {
|
|||
latestMs
|
||||
);
|
||||
},
|
||||
|
||||
// Queries Elasticsearch to obtain the record level results containing the specified influencer(s),
|
||||
// for the specified job(s), time range, and record score threshold.
|
||||
// influencers parameter must be an array, with each object in the array having 'fieldName'
|
||||
// 'fieldValue' properties. The influencer array uses 'should' for the nested bool query,
|
||||
// so this returns record level results which have at least one of the influencers.
|
||||
// Pass an empty array or ['*'] to search over all job IDs.
|
||||
getRecordsForInfluencer$(
|
||||
jobIds: string[],
|
||||
influencers: EntityField[],
|
||||
threshold: number,
|
||||
earliestMs: number,
|
||||
latestMs: number,
|
||||
maxResults: number,
|
||||
influencersFilterQuery: InfluencersFilterQuery
|
||||
): Observable<{ records: RecordForInfluencer[]; success: boolean }> {
|
||||
const obj = { success: true, records: [] as RecordForInfluencer[] };
|
||||
|
||||
// Build the criteria to use in the bool filter part of the request.
|
||||
// Add criteria for the time range, record score, plus any specified job IDs.
|
||||
const boolCriteria: any[] = [
|
||||
{
|
||||
range: {
|
||||
timestamp: {
|
||||
gte: earliestMs,
|
||||
lte: latestMs,
|
||||
format: 'epoch_millis',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
range: {
|
||||
record_score: {
|
||||
gte: threshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) {
|
||||
let jobIdFilterStr = '';
|
||||
each(jobIds, (jobId, i) => {
|
||||
if (i > 0) {
|
||||
jobIdFilterStr += ' OR ';
|
||||
}
|
||||
jobIdFilterStr += 'job_id:';
|
||||
jobIdFilterStr += jobId;
|
||||
});
|
||||
boolCriteria.push({
|
||||
query_string: {
|
||||
analyze_wildcard: false,
|
||||
query: jobIdFilterStr,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (influencersFilterQuery !== undefined) {
|
||||
boolCriteria.push(influencersFilterQuery);
|
||||
}
|
||||
|
||||
// Add a nested query to filter for each of the specified influencers.
|
||||
if (influencers.length > 0) {
|
||||
boolCriteria.push({
|
||||
bool: {
|
||||
should: influencers.map((influencer) => {
|
||||
return {
|
||||
nested: {
|
||||
path: 'influencers',
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
match: {
|
||||
'influencers.influencer_field_name': influencer.fieldName,
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
'influencers.influencer_field_values': influencer.fieldValue,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return mlApiServices.results
|
||||
.anomalySearch$(
|
||||
{
|
||||
size: maxResults !== undefined ? maxResults : 100,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
query_string: {
|
||||
query: 'result_type:record',
|
||||
analyze_wildcard: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
must: boolCriteria,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [{ record_score: { order: 'desc' } }],
|
||||
},
|
||||
},
|
||||
jobIds
|
||||
)
|
||||
.pipe(
|
||||
map((resp) => {
|
||||
if (resp.hits.total.value > 0) {
|
||||
each(resp.hits.hits, (hit) => {
|
||||
obj.records.push(hit._source);
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -55,7 +55,6 @@ export function resultsServiceProvider(
|
|||
influencersFilterQuery: InfluencersFilterQuery
|
||||
): Promise<any>;
|
||||
getRecordInfluencers(): Promise<any>;
|
||||
getRecordsForInfluencer(): Promise<RecordForInfluencer[]>;
|
||||
getRecordsForDetector(): Promise<any>;
|
||||
getRecords(): Promise<any>;
|
||||
getEventRateData(
|
||||
|
|
|
@ -779,139 +779,6 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
});
|
||||
},
|
||||
|
||||
// Queries Elasticsearch to obtain the record level results containing the specified influencer(s),
|
||||
// for the specified job(s), time range, and record score threshold.
|
||||
// influencers parameter must be an array, with each object in the array having 'fieldName'
|
||||
// 'fieldValue' properties. The influencer array uses 'should' for the nested bool query,
|
||||
// so this returns record level results which have at least one of the influencers.
|
||||
// Pass an empty array or ['*'] to search over all job IDs.
|
||||
getRecordsForInfluencer(
|
||||
jobIds,
|
||||
influencers,
|
||||
threshold,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
maxResults,
|
||||
influencersFilterQuery
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const obj = { success: true, records: [] };
|
||||
|
||||
// Build the criteria to use in the bool filter part of the request.
|
||||
// Add criteria for the time range, record score, plus any specified job IDs.
|
||||
const boolCriteria = [
|
||||
{
|
||||
range: {
|
||||
timestamp: {
|
||||
gte: earliestMs,
|
||||
lte: latestMs,
|
||||
format: 'epoch_millis',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
range: {
|
||||
record_score: {
|
||||
gte: threshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) {
|
||||
let jobIdFilterStr = '';
|
||||
each(jobIds, (jobId, i) => {
|
||||
if (i > 0) {
|
||||
jobIdFilterStr += ' OR ';
|
||||
}
|
||||
jobIdFilterStr += 'job_id:';
|
||||
jobIdFilterStr += jobId;
|
||||
});
|
||||
boolCriteria.push({
|
||||
query_string: {
|
||||
analyze_wildcard: false,
|
||||
query: jobIdFilterStr,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (influencersFilterQuery !== undefined) {
|
||||
boolCriteria.push(influencersFilterQuery);
|
||||
}
|
||||
|
||||
// Add a nested query to filter for each of the specified influencers.
|
||||
if (influencers.length > 0) {
|
||||
boolCriteria.push({
|
||||
bool: {
|
||||
should: influencers.map((influencer) => {
|
||||
return {
|
||||
nested: {
|
||||
path: 'influencers',
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
match: {
|
||||
'influencers.influencer_field_name': influencer.fieldName,
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
'influencers.influencer_field_values': influencer.fieldValue,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
mlApiServices.results
|
||||
.anomalySearch(
|
||||
{
|
||||
size: maxResults !== undefined ? maxResults : 100,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
query_string: {
|
||||
query: 'result_type:record',
|
||||
analyze_wildcard: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
must: boolCriteria,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [{ record_score: { order: 'desc' } }],
|
||||
},
|
||||
},
|
||||
jobIds
|
||||
)
|
||||
.then((resp) => {
|
||||
if (resp.hits.total.value > 0) {
|
||||
each(resp.hits.hits, (hit) => {
|
||||
obj.records.push(hit._source);
|
||||
});
|
||||
}
|
||||
resolve(obj);
|
||||
})
|
||||
.catch((resp) => {
|
||||
reject(resp);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Queries Elasticsearch to obtain the record level results for the specified job and detector,
|
||||
// time range, record score threshold, and whether to only return results containing influencers.
|
||||
// An additional, optional influencer field name and value may also be provided.
|
||||
|
@ -1039,14 +906,6 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
});
|
||||
},
|
||||
|
||||
// Queries Elasticsearch to obtain all the record level results for the specified job(s), time range,
|
||||
// and record score threshold.
|
||||
// Pass an empty array or ['*'] to search over all job IDs.
|
||||
// Returned response contains a records property, which is an array of the matching results.
|
||||
getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) {
|
||||
return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults);
|
||||
},
|
||||
|
||||
// Queries Elasticsearch to obtain event rate data i.e. the count
|
||||
// of documents over time.
|
||||
// index can be a String, or String[], of index names to search.
|
||||
|
|
|
@ -23,7 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
|||
import { AnomalyChartsEmbeddableInput } from '..';
|
||||
import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../application/services/anomaly_explorer_charts_service';
|
||||
|
||||
const MAX_SERIES_ALLOWED = 48;
|
||||
export const MAX_ANOMALY_CHARTS_ALLOWED = 48;
|
||||
export interface AnomalyChartsInitializerProps {
|
||||
defaultTitle: string;
|
||||
initialInput?: Partial<Pick<AnomalyChartsEmbeddableInput, 'jobIds' | 'maxSeriesToPlot'>>;
|
||||
|
@ -98,7 +98,7 @@ export const AnomalyChartsInitializer: FC<AnomalyChartsInitializerProps> = ({
|
|||
value={maxSeriesToPlot}
|
||||
onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))}
|
||||
min={0}
|
||||
max={MAX_SERIES_ALLOWED}
|
||||
max={MAX_ANOMALY_CHARTS_ALLOWED}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
|
|
|
@ -29,41 +29,6 @@ jest.mock('../../application/explorer/explorer_utils', () => ({
|
|||
}),
|
||||
getSelectionJobIds: jest.fn(() => ['test-job']),
|
||||
getSelectionTimeRange: jest.fn(() => ({ earliestMs: 1521309543000, latestMs: 1616003942999 })),
|
||||
loadDataForCharts: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
job_id: 'cw_multi_1',
|
||||
result_type: 'record',
|
||||
probability: 6.057139142746412e-13,
|
||||
multi_bucket_impact: -5,
|
||||
record_score: 89.71961,
|
||||
initial_record_score: 98.36826274948001,
|
||||
bucket_span: 900,
|
||||
detector_index: 0,
|
||||
is_interim: false,
|
||||
timestamp: 1572892200000,
|
||||
partition_field_name: 'instance',
|
||||
partition_field_value: 'i-d17dcd4c',
|
||||
function: 'mean',
|
||||
function_description: 'mean',
|
||||
typical: [1.6177685422858146],
|
||||
actual: [7.235333333333333],
|
||||
field_name: 'CPUUtilization',
|
||||
influencers: [
|
||||
{
|
||||
influencer_field_name: 'region',
|
||||
influencer_field_values: ['sa-east-1'],
|
||||
},
|
||||
{
|
||||
influencer_field_name: 'instance',
|
||||
influencer_field_values: ['i-d17dcd4c'],
|
||||
},
|
||||
],
|
||||
instance: ['i-d17dcd4c'],
|
||||
region: ['sa-east-1'],
|
||||
},
|
||||
])
|
||||
),
|
||||
}));
|
||||
|
||||
describe('useAnomalyChartsInputResolver', () => {
|
||||
|
@ -115,6 +80,42 @@ describe('useAnomalyChartsInputResolver', () => {
|
|||
})
|
||||
);
|
||||
|
||||
anomalyExplorerChartsServiceMock.loadDataForCharts$.mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
job_id: 'cw_multi_1',
|
||||
result_type: 'record',
|
||||
probability: 6.057139142746412e-13,
|
||||
multi_bucket_impact: -5,
|
||||
record_score: 89.71961,
|
||||
initial_record_score: 98.36826274948001,
|
||||
bucket_span: 900,
|
||||
detector_index: 0,
|
||||
is_interim: false,
|
||||
timestamp: 1572892200000,
|
||||
partition_field_name: 'instance',
|
||||
partition_field_value: 'i-d17dcd4c',
|
||||
function: 'mean',
|
||||
function_description: 'mean',
|
||||
typical: [1.6177685422858146],
|
||||
actual: [7.235333333333333],
|
||||
field_name: 'CPUUtilization',
|
||||
influencers: [
|
||||
{
|
||||
influencer_field_name: 'region',
|
||||
influencer_field_values: ['sa-east-1'],
|
||||
},
|
||||
{
|
||||
influencer_field_name: 'instance',
|
||||
influencer_field_values: ['i-d17dcd4c'],
|
||||
},
|
||||
],
|
||||
instance: ['i-d17dcd4c'],
|
||||
region: ['sa-east-1'],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const coreStartMock = createCoreStartMock();
|
||||
const mlStartMock = createMlStartDepsMock();
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
getSelectionInfluencers,
|
||||
getSelectionJobIds,
|
||||
getSelectionTimeRange,
|
||||
loadDataForCharts,
|
||||
} from '../../application/explorer/explorer_utils';
|
||||
import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
|
||||
import { parseInterval } from '../../../common/util/parse_interval';
|
||||
|
@ -46,7 +45,7 @@ export function useAnomalyChartsInputResolver(
|
|||
const [
|
||||
{ uiSettings },
|
||||
{ data: dataServices },
|
||||
{ anomalyDetectorService, anomalyExplorerService, mlResultsService },
|
||||
{ anomalyDetectorService, anomalyExplorerService },
|
||||
] = services;
|
||||
const { timefilter } = dataServices.query.timefilter;
|
||||
|
||||
|
@ -125,15 +124,13 @@ export function useAnomalyChartsInputResolver(
|
|||
const timeRange = getSelectionTimeRange(selections, bucketInterval.asSeconds(), bounds);
|
||||
return forkJoin({
|
||||
combinedJobs: anomalyExplorerService.getCombinedJobs(jobIds),
|
||||
anomalyChartRecords: loadDataForCharts(
|
||||
mlResultsService,
|
||||
anomalyChartRecords: anomalyExplorerService.loadDataForCharts$(
|
||||
jobIds,
|
||||
timeRange.earliestMs,
|
||||
timeRange.latestMs,
|
||||
selectionInfluencers,
|
||||
selections,
|
||||
influencersFilterQuery,
|
||||
false
|
||||
influencersFilterQuery
|
||||
),
|
||||
}).pipe(
|
||||
switchMap(({ combinedJobs, anomalyChartRecords }) => {
|
||||
|
@ -147,7 +144,6 @@ export function useAnomalyChartsInputResolver(
|
|||
return forkJoin({
|
||||
chartsData: from(
|
||||
anomalyExplorerService.getAnomalyData(
|
||||
undefined,
|
||||
combinedJobRecords,
|
||||
embeddableContainerWidth,
|
||||
anomalyChartRecords,
|
||||
|
|
|
@ -13725,7 +13725,8 @@
|
|||
"xpack.ml.editModelSnapshotFlyout.useDefaultButton": "削除",
|
||||
"xpack.ml.explorer.addToDashboard.cancelButtonLabel": "キャンセル",
|
||||
"xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "ダッシュボードを選択:",
|
||||
"xpack.ml.explorer.addToDashboard.selectSwimlanesLabel": "スイムレーンビューを選択:",
|
||||
"xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "スイムレーンをダッシュボードに追加",
|
||||
"xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "スイムレーンビューを選択:",
|
||||
"xpack.ml.explorer.addToDashboardLabel": "ダッシュボードに追加",
|
||||
"xpack.ml.explorer.annotationsErrorCallOutTitle": "注釈の読み込み中にエラーが発生しました。",
|
||||
"xpack.ml.explorer.annotationsErrorTitle": "注釈",
|
||||
|
@ -13750,7 +13751,6 @@
|
|||
"xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "説明",
|
||||
"xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle": "ダッシュボード「{dashboardTitle}」は正常に更新されました",
|
||||
"xpack.ml.explorer.dashboardsTable.titleColumnHeader": "タイトル",
|
||||
"xpack.ml.explorer.dashboardsTitle": "スイムレーンをダッシュボードに追加",
|
||||
"xpack.ml.explorer.distributionChart.anomalyScoreLabel": "異常スコア",
|
||||
"xpack.ml.explorer.distributionChart.entityLabel": "エンティティ",
|
||||
"xpack.ml.explorer.distributionChart.typicalLabel": "通常",
|
||||
|
|
|
@ -13905,7 +13905,8 @@
|
|||
"xpack.ml.editModelSnapshotFlyout.useDefaultButton": "删除",
|
||||
"xpack.ml.explorer.addToDashboard.cancelButtonLabel": "取消",
|
||||
"xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "选择仪表板:",
|
||||
"xpack.ml.explorer.addToDashboard.selectSwimlanesLabel": "选择泳道视图:",
|
||||
"xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "将泳道添加到仪表板",
|
||||
"xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "选择泳道视图:",
|
||||
"xpack.ml.explorer.addToDashboardLabel": "添加到仪表板",
|
||||
"xpack.ml.explorer.annotationsErrorCallOutTitle": "加载注释时发生错误:",
|
||||
"xpack.ml.explorer.annotationsErrorTitle": "标注",
|
||||
|
@ -13930,7 +13931,6 @@
|
|||
"xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "描述",
|
||||
"xpack.ml.explorer.dashboardsTable.savedSuccessfullyTitle": "仪表板“{dashboardTitle}”已成功更新",
|
||||
"xpack.ml.explorer.dashboardsTable.titleColumnHeader": "标题",
|
||||
"xpack.ml.explorer.dashboardsTitle": "将泳道添加到仪表板",
|
||||
"xpack.ml.explorer.distributionChart.anomalyScoreLabel": "异常分数",
|
||||
"xpack.ml.explorer.distributionChart.entityLabel": "实体",
|
||||
"xpack.ml.explorer.distributionChart.typicalLabel": "典型",
|
||||
|
|
Loading…
Reference in a new issue