[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:
Quynh Nguyen 2021-04-08 12:22:52 -05:00 committed by GitHub
parent 955c46ba5e
commit d904f8d1bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1026 additions and 704 deletions

View file

@ -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';

View file

@ -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;

View file

@ -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']) &&

View file

@ -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.

View file

@ -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;

View file

@ -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,

View file

@ -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
);
}, []);

View file

@ -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>
);
};

View file

@ -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}
/>
)}
</>
);
};

View file

@ -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) {

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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"

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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.

View file

@ -10,4 +10,5 @@ export const createAnomalyExplorerChartsServiceMock = () => ({
getAnomalyData: jest.fn(),
setTimeRange: jest.fn(),
getTimeBounds: jest.fn(),
loadDataForCharts$: jest.fn(),
});

View file

@ -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,

View file

@ -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

View file

@ -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;
})
);
},
};
}

View file

@ -55,7 +55,6 @@ export function resultsServiceProvider(
influencersFilterQuery: InfluencersFilterQuery
): Promise<any>;
getRecordInfluencers(): Promise<any>;
getRecordsForInfluencer(): Promise<RecordForInfluencer[]>;
getRecordsForDetector(): Promise<any>;
getRecords(): Promise<any>;
getEventRateData(

View file

@ -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.

View file

@ -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>

View file

@ -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();

View file

@ -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,

View file

@ -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": "通常",

View file

@ -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": "典型",