[ML] Anomaly Detection: Add single metric viewer embeddable for dashboards (#175857)

## Summary

Related issue to [add ability to insert "Single Metric Viewer" into a
dashboard](https://github.com/elastic/kibana/issues/173555)

This PR adds the single metric viewer as an embeddable that can be added
to dashboards.

### NOTE FOR TESTING:

This PR relies on the SMV fix for 'metric' jobs
https://github.com/elastic/kibana/pull/176354
If that fix has not been merged, you will need to find
`getAnomalyRecordsSchema` definition and add `functionDescription:
schema.maybe(schema.nullable(schema.string())),` to it for local
testing.

### Screenshots of feature

<img width="698" alt="image"
src="425e701a-3c9d-4a82-bf2e-1db5b3689165">

<img width="1193" alt="image"
src="e941ec1c-14f6-4723-b80c-71124f617dc9">

<img width="1209" alt="image"
src="dddd1dde-844c-47ae-ba94-61de5301746f">

<img width="1214" alt="image"
src="39439b4f-d296-4f3d-bdc9-4922553af6fa">


### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Melissa Alvarez 2024-02-08 12:32:25 -07:00 committed by GitHub
parent fa98e5871c
commit ee34012cd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3341 additions and 136 deletions

View file

@ -59,6 +59,10 @@ export interface MlEntityField {
* Optional entity field operation
*/
operation?: MlEntityFieldOperation;
/**
* Optional cardinality of field
*/
cardinality?: number;
}
// List of function descriptions for which actual values from record level results should be displayed.

View file

@ -8,16 +8,20 @@
import { mlFunctionToESAggregation } from '../../../common/util/job_utils';
import { getDataViewById, getDataViewIdFromName } from '../util/index_utils';
import { mlJobService } from './job_service';
import type { MlIndexUtils } from '../util/index_service';
import type { MlApiServices } from './ml_api_service';
type FormatsByJobId = Record<string, any>;
type IndexPatternIdsByJob = Record<string, any>;
// Service for accessing FieldFormat objects configured for a Kibana data view
// for use in formatting the actual and typical values from anomalies.
class FieldFormatService {
export class FieldFormatService {
indexPatternIdsByJob: IndexPatternIdsByJob = {};
formatsByJob: FormatsByJobId = {};
constructor(private mlApiServices?: MlApiServices, private mlIndexUtils?: MlIndexUtils) {}
// Populate the service with the FieldFormats for the list of jobs with the
// specified IDs. List of Kibana data views is passed, with a title
// attribute set in each pattern which will be compared to the indices
@ -32,10 +36,17 @@ class FieldFormatService {
(
await Promise.all(
jobIds.map(async (jobId) => {
const jobObj = mlJobService.getJob(jobId);
const getDataViewId = this.mlIndexUtils?.getDataViewIdFromName ?? getDataViewIdFromName;
let jobObj;
if (this.mlApiServices) {
const { jobs } = await this.mlApiServices.getJobs({ jobId });
jobObj = jobs[0];
} else {
jobObj = mlJobService.getJob(jobId);
}
return {
jobId,
dataViewId: await getDataViewIdFromName(jobObj.datafeed_config.indices.join(',')),
dataViewId: await getDataViewId(jobObj.datafeed_config!.indices.join(',')),
};
})
)
@ -68,41 +79,40 @@ class FieldFormatService {
}
}
getFormatsForJob(jobId: string): Promise<any[]> {
return new Promise((resolve, reject) => {
const jobObj = mlJobService.getJob(jobId);
const detectors = jobObj.analysis_config.detectors || [];
const formatsByDetector: any[] = [];
async getFormatsForJob(jobId: string): Promise<any[]> {
let jobObj;
const getDataView = this.mlIndexUtils?.getDataViewById ?? getDataViewById;
if (this.mlApiServices) {
const { jobs } = await this.mlApiServices.getJobs({ jobId });
jobObj = jobs[0];
} else {
jobObj = mlJobService.getJob(jobId);
}
const detectors = jobObj.analysis_config.detectors || [];
const formatsByDetector: any[] = [];
const dataViewId = this.indexPatternIdsByJob[jobId];
if (dataViewId !== undefined) {
// Load the full data view configuration to obtain the formats of each field.
getDataViewById(dataViewId)
.then((dataView) => {
// Store the FieldFormat for each job by detector_index.
const fieldList = dataView.fields;
detectors.forEach((dtr) => {
const esAgg = mlFunctionToESAggregation(dtr.function);
// distinct_count detectors should fall back to the default
// formatter as the values are just counts.
if (dtr.field_name !== undefined && esAgg !== 'cardinality') {
const field = fieldList.getByName(dtr.field_name);
if (field !== undefined) {
formatsByDetector[dtr.detector_index!] = dataView.getFormatterForField(field);
}
}
});
const dataViewId = this.indexPatternIdsByJob[jobId];
if (dataViewId !== undefined) {
// Load the full data view configuration to obtain the formats of each field.
const dataView = await getDataView(dataViewId);
// Store the FieldFormat for each job by detector_index.
const fieldList = dataView.fields;
detectors.forEach((dtr) => {
const esAgg = mlFunctionToESAggregation(dtr.function);
// distinct_count detectors should fall back to the default
// formatter as the values are just counts.
if (dtr.field_name !== undefined && esAgg !== 'cardinality') {
const field = fieldList.getByName(dtr.field_name);
if (field !== undefined) {
formatsByDetector[dtr.detector_index!] = dataView.getFormatterForField(field);
}
}
});
}
resolve(formatsByDetector);
})
.catch((err) => {
reject(err);
});
} else {
resolve(formatsByDetector);
}
});
return formatsByDetector;
}
}
export const mlFieldFormatService = new FieldFormatService();
export type MlFieldFormatService = typeof mlFieldFormatService;

View file

@ -0,0 +1,17 @@
/*
* 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 MlFieldFormatService, FieldFormatService } from './field_format_service';
import type { MlIndexUtils } from '../util/index_service';
import type { MlApiServices } from './ml_api_service';
export function fieldFormatServiceFactory(
mlApiServices: MlApiServices,
mlIndexUtils: MlIndexUtils
): MlFieldFormatService {
return new FieldFormatService(mlApiServices, mlIndexUtils);
}

View file

@ -32,3 +32,5 @@ export const mlForecastService: {
getForecastDateRange: (job: Job, forecastId: string) => Promise<ForecastDateRange>;
};
export type MlForecastService = typeof mlForecastService;

View file

@ -0,0 +1,395 @@
/*
* 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.
*/
// Service for carrying out requests to run ML forecasts and to obtain
// data on forecasts that have been performed.
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { get, find, each } from 'lodash';
import { map } from 'rxjs/operators';
import type { MlApiServices } from './ml_api_service';
import type { Job } from '../../../common/types/anomaly_detection_jobs';
export interface AggType {
avg: string;
max: string;
min: string;
}
// TODO Consolidate with legacy code in
// `x-pack/plugins/ml/public/application/services/forecast_service.js` and
// `x-pack/plugins/ml/public/application/services/forecast_service.d.ts`.
export function forecastServiceProvider(mlApiServices: MlApiServices) {
return {
// Gets a basic summary of the most recently run forecasts for the specified
// job, with results at or later than the supplied timestamp.
// Extra query object can be supplied, or pass null if no additional query.
// Returned response contains a forecasts property, which is an array of objects
// containing id, earliest and latest keys.
getForecastsSummary(job: Job, query: any, earliestMs: number, maxResults: any) {
return new Promise((resolve, reject) => {
const obj: { success: boolean; forecasts: Record<string, any> } = {
success: true,
forecasts: [],
};
// Build the criteria to use in the bool filter part of the request.
// Add criteria for the job ID, result type and earliest time, plus
// the additional query if supplied.
const filterCriteria = [
{
term: { result_type: 'model_forecast_request_stats' },
},
{
term: { job_id: job.job_id },
},
{
range: {
timestamp: {
gte: earliestMs,
format: 'epoch_millis',
},
},
},
];
if (query) {
filterCriteria.push(query);
}
mlApiServices.results
.anomalySearch(
{
// @ts-expect-error SearchRequest type has not been updated to include size
size: maxResults,
body: {
query: {
bool: {
filter: filterCriteria,
},
},
sort: [{ forecast_create_timestamp: { order: 'desc' } }],
},
},
[job.job_id]
)
.then((resp) => {
if (resp.hits.total.value > 0) {
obj.forecasts = resp.hits.hits.map((hit) => hit._source);
}
resolve(obj);
})
.catch((resp) => {
reject(resp);
});
});
},
// Obtains the earliest and latest timestamps for the forecast data from
// the forecast with the specified ID.
// Returned response contains earliest and latest properties which are the
// timestamps of the first and last model_forecast results.
getForecastDateRange(job: Job, forecastId: string) {
return new Promise((resolve, reject) => {
const obj = {
success: true,
earliest: null,
latest: null,
};
// Build the criteria to use in the bool filter part of the request.
// Add criteria for the job ID, forecast ID, result type and time range.
const filterCriteria = [
{
query_string: {
query: 'result_type:model_forecast',
analyze_wildcard: true,
},
},
{
term: { job_id: job.job_id },
},
{
term: { forecast_id: forecastId },
},
];
// TODO - add in criteria for detector index and entity fields (by, over, partition)
// once forecasting with these parameters is supported.
mlApiServices.results
.anomalySearch(
{
// @ts-expect-error SearchRequest type has not been updated to include size
size: 0,
body: {
query: {
bool: {
filter: filterCriteria,
},
},
aggs: {
earliest: {
min: {
field: 'timestamp',
},
},
latest: {
max: {
field: 'timestamp',
},
},
},
},
},
[job.job_id]
)
.then((resp) => {
obj.earliest = get(resp, 'aggregations.earliest.value', null);
obj.latest = get(resp, 'aggregations.latest.value', null);
if (obj.earliest === null || obj.latest === null) {
reject(resp);
} else {
resolve(obj);
}
})
.catch((resp) => {
reject(resp);
});
});
},
// Obtains the requested forecast model data for the forecast with the specified ID.
getForecastData(
job: Job,
detectorIndex: number,
forecastId: string,
entityFields: any,
earliestMs: number,
latestMs: number,
intervalMs: number,
aggType?: AggType
) {
// Extract the partition, by, over fields on which to filter.
const criteriaFields = [];
const detector = job.analysis_config.detectors[detectorIndex];
if (detector.partition_field_name !== undefined) {
const partitionEntity = find(entityFields, { fieldName: detector.partition_field_name });
if (partitionEntity !== undefined) {
criteriaFields.push(
{ fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName },
{ fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue }
);
}
}
if (detector.over_field_name !== undefined) {
const overEntity = find(entityFields, { fieldName: detector.over_field_name });
if (overEntity !== undefined) {
criteriaFields.push(
{ fieldName: 'over_field_name', fieldValue: overEntity.fieldName },
{ fieldName: 'over_field_value', fieldValue: overEntity.fieldValue }
);
}
}
if (detector.by_field_name !== undefined) {
const byEntity = find(entityFields, { fieldName: detector.by_field_name });
if (byEntity !== undefined) {
criteriaFields.push(
{ fieldName: 'by_field_name', fieldValue: byEntity.fieldName },
{ fieldName: 'by_field_value', fieldValue: byEntity.fieldValue }
);
}
}
const obj: { success: boolean; results: Record<number, any> } = {
success: true,
results: {},
};
// Build the criteria to use in the bool filter part of the request.
// Add criteria for the job ID, forecast ID, detector index, result type and time range.
const filterCriteria: estypes.QueryDslQueryContainer[] = [
{
query_string: {
query: 'result_type:model_forecast',
analyze_wildcard: true,
},
},
{
term: { job_id: job.job_id },
},
{
term: { forecast_id: forecastId },
},
{
term: { detector_index: detectorIndex },
},
{
range: {
timestamp: {
gte: earliestMs,
lte: latestMs,
format: 'epoch_millis',
},
},
},
];
// Add in term queries for each of the specified criteria.
each(criteriaFields, (criteria) => {
filterCriteria.push({
term: {
[criteria.fieldName]: criteria.fieldValue,
},
});
});
// If an aggType object has been passed in, use it.
// Otherwise default to avg, min and max aggs for the
// forecast prediction, upper and lower
const forecastAggs =
aggType === undefined
? { avg: 'avg', max: 'max', min: 'min' }
: {
avg: aggType.avg,
max: aggType.max,
min: aggType.min,
};
return mlApiServices.results
.anomalySearch$(
{
// @ts-expect-error SearchRequest type has not been updated to include size
size: 0,
body: {
query: {
bool: {
filter: filterCriteria,
},
},
aggs: {
times: {
date_histogram: {
field: 'timestamp',
fixed_interval: `${intervalMs}ms`,
min_doc_count: 1,
},
aggs: {
prediction: {
[forecastAggs.avg]: {
field: 'forecast_prediction',
},
},
forecastUpper: {
[forecastAggs.max]: {
field: 'forecast_upper',
},
},
forecastLower: {
[forecastAggs.min]: {
field: 'forecast_lower',
},
},
},
},
},
},
},
[job.job_id]
)
.pipe(
map((resp) => {
const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []);
each(aggregationsByTime, (dataForTime) => {
const time = dataForTime.key;
obj.results[time] = {
prediction: get(dataForTime, ['prediction', 'value']),
forecastUpper: get(dataForTime, ['forecastUpper', 'value']),
forecastLower: get(dataForTime, ['forecastLower', 'value']),
};
});
return obj;
})
);
},
// Runs a forecast
runForecast(jobId: string, duration?: string) {
// eslint-disable-next-line no-console
console.log('ML forecast service run forecast with duration:', duration);
return new Promise((resolve, reject) => {
mlApiServices
.forecast({
jobId,
duration,
})
.then((resp) => {
resolve(resp);
})
.catch((err) => {
reject(err);
});
});
},
// Gets stats for a forecast that has been run on the specified job.
// Returned response contains a stats property, including
// forecast_progress (a value from 0 to 1),
// and forecast_status ('finished' when complete) properties.
getForecastRequestStats(job: Job, forecastId: string) {
return new Promise((resolve, reject) => {
const obj = {
success: true,
stats: {},
};
// Build the criteria to use in the bool filter part of the request.
// Add criteria for the job ID, result type and earliest time.
const filterCriteria = [
{
query_string: {
query: 'result_type:model_forecast_request_stats',
analyze_wildcard: true,
},
},
{
term: { job_id: job.job_id },
},
{
term: { forecast_id: forecastId },
},
];
mlApiServices.results
.anomalySearch(
{
// @ts-expect-error SearchRequest type has not been updated to include size
size: 1,
body: {
query: {
bool: {
filter: filterCriteria,
},
},
},
},
[job.job_id]
)
.then((resp) => {
if (resp.hits.total.value > 0) {
obj.stats = resp.hits.hits[0]._source;
}
resolve(obj);
})
.catch((resp) => {
reject(resp);
});
});
},
};
}
export type MlForecastService = ReturnType<typeof forecastServiceProvider>;

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import { useMemo } from 'react';
import { resultsServiceRxProvider } from './result_service_rx';
import { resultsServiceProvider } from './results_service';
import { ml, MlApiServices } from '../ml_api_service';
import { useMlKibana } from '../../contexts/kibana';
export type MlResultsService = typeof mlResultsService;
@ -29,3 +31,14 @@ export function mlResultsServiceProvider(mlApiServices: MlApiServices) {
...resultsServiceRxProvider(mlApiServices),
};
}
export function useMlResultsService(): MlResultsService {
const {
services: {
mlServices: { mlApiServices },
},
} = useMlKibana();
const resultsService = useMemo(() => mlResultsServiceProvider(mlApiServices), [mlApiServices]);
return resultsService;
}

View file

@ -9,9 +9,11 @@ import React, { useCallback, useEffect } from 'react';
import { EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils';
import { MlJob } from '@elastic/elasticsearch/lib/api/types';
import { mlJobService } from '../../../services/job_service';
import { getFunctionDescription, isMetricDetector } from '../../get_function_description';
import { useToastNotificationService } from '../../../services/toast_notification_service';
import { useMlResultsService } from '../../../services/results_service';
import type { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs';
const plotByFunctionOptions = [
@ -36,6 +38,7 @@ const plotByFunctionOptions = [
];
export const PlotByFunctionControls = ({
functionDescription,
job,
setFunctionDescription,
selectedDetectorIndex,
selectedJobId,
@ -43,6 +46,7 @@ export const PlotByFunctionControls = ({
entityControlsCount,
}: {
functionDescription: undefined | string;
job?: CombinedJob | MlJob;
setFunctionDescription: (func: string) => void;
selectedDetectorIndex: number;
selectedJobId: string;
@ -50,6 +54,7 @@ export const PlotByFunctionControls = ({
entityControlsCount: number;
}) => {
const toastNotificationService = useToastNotificationService();
const mlResultsService = useMlResultsService();
const getFunctionDescriptionToPlot = useCallback(
async (
@ -65,18 +70,19 @@ export const PlotByFunctionControls = ({
selectedJobId: _selectedJobId,
selectedJob: _selectedJob,
},
toastNotificationService
toastNotificationService,
mlResultsService
);
setFunctionDescription(functionToPlot);
},
[setFunctionDescription, toastNotificationService]
[setFunctionDescription, toastNotificationService, mlResultsService]
);
useEffect(() => {
if (functionDescription !== undefined) {
return;
}
const selectedJob = mlJobService.getJob(selectedJobId);
const selectedJob = (job ?? mlJobService.getJob(selectedJobId)) as CombinedJob;
// if no controls, it's okay to fetch
// if there are series controls, only fetch if user has selected something
const validEntities =

View file

@ -12,9 +12,10 @@ import { debounce } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { useStorage } from '@kbn/ml-local-storage';
import type { MlEntityFieldType } from '@kbn/ml-anomaly-utils';
import { MlJob } from '@elastic/elasticsearch/lib/api/types';
import { EntityControl } from '../entity_control';
import { mlJobService } from '../../../services/job_service';
import { Detector, JobId } from '../../../../../common/types/anomaly_detection_jobs';
import { CombinedJob, Detector, JobId } from '../../../../../common/types/anomaly_detection_jobs';
import { useMlKibana } from '../../../contexts/kibana';
import { APP_STATE_ACTION } from '../../timeseriesexplorer_constants';
import {
@ -67,12 +68,13 @@ const getDefaultFieldConfig = (
};
interface SeriesControlsProps {
selectedDetectorIndex: number;
selectedJobId: JobId;
bounds: any;
appStateHandler: Function;
bounds: any;
functionDescription?: string;
job?: CombinedJob | MlJob;
selectedDetectorIndex: number;
selectedEntities: Record<string, any>;
functionDescription: string;
selectedJobId: JobId;
setFunctionDescription: (func: string) => void;
}
@ -80,13 +82,14 @@ interface SeriesControlsProps {
* Component for handling the detector and entities controls.
*/
export const SeriesControls: FC<SeriesControlsProps> = ({
bounds,
selectedDetectorIndex,
selectedJobId,
appStateHandler,
bounds,
children,
selectedEntities,
functionDescription,
job,
selectedDetectorIndex,
selectedEntities,
selectedJobId,
setFunctionDescription,
}) => {
const {
@ -97,7 +100,11 @@ export const SeriesControls: FC<SeriesControlsProps> = ({
},
} = useMlKibana();
const selectedJob = useMemo(() => mlJobService.getJob(selectedJobId), [selectedJobId]);
const selectedJob: CombinedJob | MlJob = useMemo(
() => job ?? mlJobService.getJob(selectedJobId),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedJobId]
);
const isModelPlotEnabled = !!selectedJob.model_plot_config?.enabled;
@ -108,11 +115,17 @@ export const SeriesControls: FC<SeriesControlsProps> = ({
index: number;
detector_description: Detector['detector_description'];
}> = useMemo(() => {
return getViewableDetectors(selectedJob);
return getViewableDetectors(selectedJob as CombinedJob);
}, [selectedJob]);
const entityControls = useMemo(() => {
return getControlsForDetector(selectedDetectorIndex, selectedEntities, selectedJobId);
return getControlsForDetector(
selectedDetectorIndex,
selectedEntities,
selectedJobId,
selectedJob as CombinedJob
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDetectorIndex, selectedEntities, selectedJobId]);
const [storageFieldsConfig, setStorageFieldsConfig] = useStorage<
@ -318,6 +331,7 @@ export const SeriesControls: FC<SeriesControlsProps> = ({
);
})}
<PlotByFunctionControls
job={job}
selectedJobId={selectedJobId}
selectedDetectorIndex={selectedDetectorIndex}
selectedEntities={selectedEntities}

View file

@ -36,7 +36,7 @@ import {
showMultiBucketAnomalyTooltip,
getMultiBucketImpactTooltipValue,
} from '../../../util/chart_utils';
import { getTimeBucketsFromCache } from '../../../util/time_buckets';
import { timeBucketsServiceFactory } from '../../../util/time_buckets_service';
import { mlTableService } from '../../../services/table_service';
import { ContextChartMask } from '../context_chart_mask';
import { findChartPointForAnomalyTime } from '../../timeseriesexplorer_utils';
@ -53,6 +53,7 @@ import {
ANNOTATION_MIN_WIDTH,
} from './timeseries_chart_annotations';
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
import { context } from '@kbn/kibana-react-plugin/public';
import { LinksMenuUI } from '../../../components/anomalies_table/links_menu';
import { RuleEditorFlyout } from '../../../components/rule_editor';
@ -113,6 +114,7 @@ class TimeseriesChartIntl extends Component {
contextForecastData: PropTypes.array,
contextChartSelected: PropTypes.func.isRequired,
detectorIndex: PropTypes.number,
embeddableMode: PropTypes.bool,
focusAggregationInterval: PropTypes.object,
focusAnnotationData: PropTypes.array,
focusChartData: PropTypes.array,
@ -133,6 +135,9 @@ class TimeseriesChartIntl extends Component {
sourceIndicesWithGeoFields: PropTypes.object.isRequired,
};
static contextType = context;
getTimeBuckets;
rowMouseenterSubscriber = null;
rowMouseleaveSubscriber = null;
@ -154,6 +159,10 @@ class TimeseriesChartIntl extends Component {
}
componentDidMount() {
this.getTimeBuckets = timeBucketsServiceFactory(
this.context.services.uiSettings
).getTimeBuckets;
const { svgWidth } = this.props;
this.vizWidth = svgWidth - margin.left - margin.right;
@ -295,7 +304,12 @@ class TimeseriesChartIntl extends Component {
chartElement.selectAll('*').remove();
if (typeof selectedJob !== 'undefined') {
this.fieldFormat = mlFieldFormatService.getFieldFormat(selectedJob.job_id, detectorIndex);
this.fieldFormat = this.context?.services?.mlServices?.mlFieldFormatService
? this.context.services.mlServices.mlFieldFormatService.getFieldFormat(
selectedJob.job_id,
detectorIndex
)
: mlFieldFormatService.getFieldFormat(selectedJob.job_id, detectorIndex);
} else {
return;
}
@ -367,7 +381,7 @@ class TimeseriesChartIntl extends Component {
);
})
.remove();
d3.select('.temp-axis-label').remove();
chartElement.select('.temp-axis-label').remove();
margin.left = Math.max(maxYAxisLabelWidth, 40);
this.vizWidth = Math.max(svgWidth - margin.left - margin.right, 0);
@ -586,6 +600,7 @@ class TimeseriesChartIntl extends Component {
renderFocusChart() {
const {
embeddableMode,
focusAggregationInterval,
focusAnnotationData: focusAnnotationDataOriginalPropValue,
focusChartData,
@ -614,12 +629,12 @@ class TimeseriesChartIntl extends Component {
const showFocusChartTooltip = this.showFocusChartTooltip.bind(this);
const hideFocusChartTooltip = this.props.tooltipService.hide.bind(this.props.tooltipService);
const focusChart = d3.select('.focus-chart');
const chartElement = d3.select(this.rootNode);
const focusChart = chartElement.select('.focus-chart');
// Update the plot interval labels.
const focusAggInt = focusAggregationInterval.expression;
const bucketSpan = selectedJob.analysis_config.bucket_span;
const chartElement = d3.select(this.rootNode);
chartElement.select('.zoom-aggregation-interval').text(
i18n.translate('xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomAggregationIntervalLabel', {
defaultMessage: '(aggregation interval: {focusAggInt}, bucket span: {bucketSpan})',
@ -726,7 +741,7 @@ class TimeseriesChartIntl extends Component {
}
// Get the scaled date format to use for x axis tick labels.
const timeBuckets = getTimeBucketsFromCache();
const timeBuckets = this.getTimeBuckets();
timeBuckets.setInterval('auto');
timeBuckets.setBounds(bounds);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
@ -761,8 +776,10 @@ class TimeseriesChartIntl extends Component {
this.props.annotationUpdatesService
);
// disable brushing (creation of annotations) when annotations aren't shown
focusChart.select('.mlAnnotationBrush').style('display', showAnnotations ? null : 'none');
// disable brushing (creation of annotations) when annotations aren't shown or when in embeddable mode
focusChart
.select('.mlAnnotationBrush')
.style('display', !showAnnotations || embeddableMode ? 'none' : null);
focusChart.select('.values-line').attr('d', this.focusValuesLine(data));
drawLineChartDots(data, focusChart, this.focusValuesLine);
@ -771,7 +788,7 @@ class TimeseriesChartIntl extends Component {
// These are used for displaying tooltips on mouseover.
// Don't render dots where value=null (data gaps, with no anomalies)
// or for multi-bucket anomalies.
const dots = d3
const dots = chartElement
.select('.focus-chart-markers')
.selectAll('.metric-value')
.data(
@ -822,7 +839,7 @@ class TimeseriesChartIntl extends Component {
});
// Render cross symbols for any multi-bucket anomalies.
const multiBucketMarkers = d3
const multiBucketMarkers = chartElement
.select('.focus-chart-markers')
.selectAll('.multi-bucket')
.data(
@ -857,7 +874,7 @@ class TimeseriesChartIntl extends Component {
.attr('class', (d) => `anomaly-marker multi-bucket ${getSeverityWithLow(d.anomalyScore).id}`);
// Add rectangular markers for any scheduled events.
const scheduledEventMarkers = d3
const scheduledEventMarkers = chartElement
.select('.focus-chart-markers')
.selectAll('.scheduled-event-marker')
.data(data.filter((d) => d.scheduledEvents !== undefined));
@ -898,7 +915,7 @@ class TimeseriesChartIntl extends Component {
.attr('d', this.focusValuesLine(focusForecastData))
.classed('hidden', !showForecast);
const forecastDots = d3
const forecastDots = chartElement
.select('.focus-chart-markers.forecast')
.selectAll('.metric-value')
.data(focusForecastData);
@ -1007,7 +1024,7 @@ class TimeseriesChartIntl extends Component {
const chartElement = d3.select(this.rootNode);
chartElement.selectAll('.focus-zoom a').on('click', function () {
d3.event.preventDefault();
setZoomInterval(d3.select(this).attr('data-ms'));
setZoomInterval(this.getAttribute('data-ms'));
});
}
@ -1129,7 +1146,7 @@ class TimeseriesChartIntl extends Component {
.attr('y2', brushChartHeight);
// Add x axis.
const timeBuckets = getTimeBucketsFromCache();
const timeBuckets = this.getTimeBuckets();
timeBuckets.setInterval('auto');
timeBuckets.setBounds(bounds);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
@ -1328,6 +1345,7 @@ class TimeseriesChartIntl extends Component {
</svg>
</div>`);
const that = this;
function brushing() {
const brushExtent = brush.extent();
mask.reveal(brushExtent);
@ -1345,11 +1363,11 @@ class TimeseriesChartIntl extends Component {
topBorder.attr('width', topBorderWidth);
const isEmpty = brush.empty();
d3.selectAll('.brush-handle').style('visibility', isEmpty ? 'hidden' : 'visible');
const chartElement = d3.select(that.rootNode);
chartElement.selectAll('.brush-handle').style('visibility', isEmpty ? 'hidden' : 'visible');
}
brushing();
const that = this;
function brushed() {
const isEmpty = brush.empty();
const selectedBounds = isEmpty ? contextXScale.domain() : brush.extent();
@ -1478,18 +1496,19 @@ class TimeseriesChartIntl extends Component {
// Sets the extent of the brush on the context chart to the
// supplied from and to Date objects.
setContextBrushExtent = (from, to) => {
const chartElement = d3.select(this.rootNode);
const brush = this.brush;
const brushExtent = brush.extent();
const newExtent = [from, to];
brush.extent(newExtent);
brush(d3.select('.brush'));
brush(chartElement.select('.brush'));
if (
newExtent[0].getTime() !== brushExtent[0].getTime() ||
newExtent[1].getTime() !== brushExtent[1].getTime()
) {
brush.event(d3.select('.brush'));
brush.event(chartElement.select('.brush'));
}
};
@ -1867,12 +1886,13 @@ class TimeseriesChartIntl extends Component {
anomalyTime,
focusAggregationInterval
);
const chartElement = d3.select(this.rootNode);
// Render an additional highlighted anomaly marker on the focus chart.
// TODO - plot anomaly markers for cases where there is an anomaly due
// to the absence of data and model plot is enabled.
if (markerToSelect !== undefined) {
const selectedMarker = d3
const selectedMarker = chartElement
.select('.focus-chart-markers')
.selectAll('.focus-chart-highlighted-marker')
.data([markerToSelect]);
@ -1905,7 +1925,6 @@ class TimeseriesChartIntl extends Component {
// Display the chart tooltip for this marker.
// Note the values of the record and marker may differ depending on the levels of aggregation.
const chartElement = d3.select(this.rootNode);
const anomalyMarker = chartElement.selectAll(
'.focus-chart-markers .anomaly-marker.highlighted'
);
@ -1916,7 +1935,8 @@ class TimeseriesChartIntl extends Component {
}
unhighlightFocusChartAnomaly() {
d3.select('.focus-chart-markers').selectAll('.anomaly-marker.highlighted').remove();
const chartElement = d3.select(this.rootNode);
chartElement.select('.focus-chart-markers').selectAll('.anomaly-marker.highlighted').remove();
this.props.tooltipService.hide();
}

View file

@ -15,7 +15,7 @@ import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'
import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../../common/constants/search';
import { Annotation } from '../../../../../common/types/annotations';
import { useMlKibana, useNotifications } from '../../../contexts/kibana';
import { getBoundsRoundedToInterval } from '../../../util/time_buckets';
import { useTimeBucketsService } from '../../../util/time_buckets_service';
import { getControlsForDetector } from '../../get_controls_for_detector';
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
import { SourceIndicesWithGeoFields } from '../../../explorer/explorer_utils';
@ -23,6 +23,7 @@ import { SourceIndicesWithGeoFields } from '../../../explorer/explorer_utils';
interface TimeSeriesChartWithTooltipsProps {
bounds: any;
detectorIndex: number;
embeddableMode?: boolean;
renderFocusChartOnly: boolean;
selectedJob: CombinedJob;
selectedEntities: Record<string, any>;
@ -41,6 +42,7 @@ interface TimeSeriesChartWithTooltipsProps {
export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> = ({
bounds,
detectorIndex,
embeddableMode,
renderFocusChartOnly,
selectedJob,
selectedEntities,
@ -80,13 +82,19 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mlTimeBucketsService = useTimeBucketsService();
useEffect(() => {
let unmounted = false;
const entities = getControlsForDetector(detectorIndex, selectedEntities, selectedJob.job_id);
const nonBlankEntities = Array.isArray(entities)
? entities.filter((entity) => entity.fieldValue !== null)
: undefined;
const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, false);
const searchBounds = mlTimeBucketsService.getBoundsRoundedToInterval(
bounds,
contextAggregationInterval,
false
);
/**
* Loads the full list of annotations for job without any aggs or time boundaries
@ -138,6 +146,7 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
annotationData={annotationData}
bounds={bounds}
detectorIndex={detectorIndex}
embeddableMode={embeddableMode}
renderFocusChartOnly={renderFocusChartOnly}
selectedJob={selectedJob}
showAnnotations={showAnnotations}

View file

@ -7,7 +7,7 @@
import { mlJobService } from '../services/job_service';
import { Entity } from './components/entity_control/entity_control';
import { JobId } from '../../../common/types/anomaly_detection_jobs';
import type { JobId, CombinedJob } from '../../../common/types/anomaly_detection_jobs';
/**
* Extracts entities from the detector configuration
@ -15,9 +15,10 @@ import { JobId } from '../../../common/types/anomaly_detection_jobs';
export function getControlsForDetector(
selectedDetectorIndex: number,
selectedEntities: Record<string, any>,
selectedJobId: JobId
selectedJobId: JobId,
job?: CombinedJob
): Entity[] {
const selectedJob = mlJobService.getJob(selectedJobId);
const selectedJob = job ?? mlJobService.getJob(selectedJobId);
const entities: Entity[] = [];

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import { lastValueFrom } from 'rxjs';
import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils';
import { mlResultsService } from '../services/results_service';
import { type MlResultsService } from '../services/results_service';
import { ToastNotificationService } from '../services/toast_notification_service';
import { getControlsForDetector } from './get_controls_for_detector';
import { getCriteriaFields } from './get_criteria_fields';
@ -41,7 +41,8 @@ export const getFunctionDescription = async (
selectedJobId: string;
selectedJob: CombinedJob;
},
toastNotificationService: ToastNotificationService
toastNotificationService: ToastNotificationService,
mlResultsService: MlResultsService
) => {
// if the detector's function is metric, fetch the highest scoring anomaly record
// and set to plot the function_description (avg/min/max) of that record by default

View file

@ -77,6 +77,7 @@ import {
processMetricPlotResults,
processRecordScoreResults,
getFocusData,
getTimeseriesexplorerDefaultState,
} from './timeseriesexplorer_utils';
import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings';
import { getControlsForDetector } from './get_controls_for_detector';
@ -96,46 +97,6 @@ const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionV
defaultMessage: 'all',
});
function getTimeseriesexplorerDefaultState() {
return {
chartDetails: undefined,
contextAggregationInterval: undefined,
contextChartData: undefined,
contextForecastData: undefined,
// Not chartable if e.g. model plot with terms for a varp detector
dataNotChartable: false,
entitiesLoading: false,
entityValues: {},
focusAnnotationData: [],
focusAggregationInterval: {},
focusChartData: undefined,
focusForecastData: undefined,
fullRefresh: true,
hasResults: false,
// Counter to keep track of what data sets have been loaded.
loadCounter: 0,
loading: false,
modelPlotEnabled: false,
// Toggles display of annotations in the focus chart
showAnnotations: true,
showAnnotationsCheckbox: true,
// Toggles display of forecast data in the focus chart
showForecast: true,
showForecastCheckbox: false,
// Toggles display of model bounds in the focus chart
showModelBounds: true,
showModelBoundsCheckbox: false,
svgWidth: 0,
tableData: undefined,
zoomFrom: undefined,
zoomTo: undefined,
zoomFromFocusLoaded: undefined,
zoomToFocusLoaded: undefined,
chartDataError: undefined,
sourceIndicesWithGeoFields: {},
};
}
const containerPadding = 34;
export class TimeSeriesExplorer extends React.Component {
@ -265,7 +226,7 @@ export class TimeSeriesExplorer extends React.Component {
}
/**
* Gets focus data for the current component state/
* Gets focus data for the current component state
*/
getFocusData(selection) {
const { selectedJobId, selectedForecastId, selectedDetectorIndex, functionDescription } =
@ -745,7 +706,6 @@ export class TimeSeriesExplorer extends React.Component {
);
}
}
// Required to redraw the time series chart when the container is resized.
this.resizeChecker = new ResizeChecker(this.resizeRef.current);
this.resizeChecker.on('resize', () => {
@ -1091,7 +1051,6 @@ export class TimeSeriesExplorer extends React.Component {
entities={entityControls}
/>
)}
{arePartitioningFieldsProvided &&
jobs.length > 0 &&
(fullRefresh === false || loading === false) &&

View file

@ -17,7 +17,9 @@ export const APP_STATE_ACTION = {
SET_ZOOM: 'SET_ZOOM',
UNSET_ZOOM: 'UNSET_ZOOM',
SET_FUNCTION_DESCRIPTION: 'SET_FUNCTION_DESCRIPTION',
};
} as const;
export type TimeseriesexplorerActionType = typeof APP_STATE_ACTION[keyof typeof APP_STATE_ACTION];
export const CHARTS_POINT_TARGET = 500;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { TimeSeriesExplorerEmbeddableChart } from './timeseriesexplorer_embeddable_chart';

View file

@ -0,0 +1,25 @@
/*
* 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, useMemo } from 'react';
import { EuiCheckbox, EuiFlexItem, htmlIdGenerator } from '@elastic/eui';
interface Props {
id: string;
label: string;
checked: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const TimeseriesExplorerCheckbox: FC<Props> = ({ id, label, checked, onChange }) => {
const checkboxId = useMemo(() => `id-${htmlIdGenerator()()}`, []);
return (
<EuiFlexItem grow={false}>
<EuiCheckbox id={checkboxId} label={label} checked={checked} onChange={onChange} />
</EuiFlexItem>
);
};

View file

@ -0,0 +1,897 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/*
* React component for rendering Single Metric Viewer.
*/
import { isEqual } from 'lodash';
import moment from 'moment-timezone';
import { Subject, Subscription, forkJoin } from 'rxjs';
import { debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { context } from '@kbn/kibana-react-plugin/public';
import {
EuiCallOut,
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiTextColor,
} from '@elastic/eui';
import { TimeSeriesExplorerHelpPopover } from '../timeseriesexplorer_help_popover';
import {
isModelPlotEnabled,
isModelPlotChartableForDetector,
isSourceDataChartableForDetector,
} from '../../../../common/util/job_utils';
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
import { TimeseriesexplorerNoChartData } from '../components/timeseriesexplorer_no_chart_data';
import {
APP_STATE_ACTION,
CHARTS_POINT_TARGET,
TIME_FIELD_NAME,
} from '../timeseriesexplorer_constants';
import { getControlsForDetector } from '../get_controls_for_detector';
import { TimeSeriesChartWithTooltips } from '../components/timeseries_chart/timeseries_chart_with_tooltip';
import { aggregationTypeTransform } from '@kbn/ml-anomaly-utils';
import { isMetricDetector } from '../get_function_description';
import { TimeseriesexplorerChartDataError } from '../components/timeseriesexplorer_chart_data_error';
import { TimeseriesExplorerCheckbox } from './timeseriesexplorer_checkbox';
import { timeBucketsServiceFactory } from '../../util/time_buckets_service';
import { timeSeriesExplorerServiceFactory } from '../../util/time_series_explorer_service';
import { getTimeseriesexplorerDefaultState } from '../timeseriesexplorer_utils';
// Used to indicate the chart is being plotted across
// all partition field values, where the cardinality of the field cannot be
// obtained as it is not aggregatable e.g. 'all distinct kpi_indicator values'
const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionValuesLabel', {
defaultMessage: 'all',
});
export class TimeSeriesExplorerEmbeddableChart extends React.Component {
static propTypes = {
appStateHandler: PropTypes.func.isRequired,
autoZoomDuration: PropTypes.number.isRequired,
bounds: PropTypes.object.isRequired,
chartWidth: PropTypes.number.isRequired,
lastRefresh: PropTypes.number.isRequired,
previousRefresh: PropTypes.number.isRequired,
selectedJobId: PropTypes.string.isRequired,
selectedDetectorIndex: PropTypes.number,
selectedEntities: PropTypes.object,
selectedForecastId: PropTypes.string,
zoom: PropTypes.object,
toastNotificationService: PropTypes.object,
dataViewsService: PropTypes.object,
};
state = getTimeseriesexplorerDefaultState();
subscriptions = new Subscription();
unmounted = false;
/**
* Subject for listening brush time range selection.
*/
contextChart$ = new Subject();
/**
* Access ML services in react context.
*/
static contextType = context;
getBoundsRoundedToInterval;
mlTimeSeriesExplorer;
/**
* Returns field names that don't have a selection yet.
*/
getFieldNamesWithEmptyValues = () => {
const latestEntityControls = this.getControlsForDetector();
return latestEntityControls
.filter(({ fieldValue }) => fieldValue === null)
.map(({ fieldName }) => fieldName);
};
/**
* Checks if all entity control dropdowns have a selection.
*/
arePartitioningFieldsProvided = () => {
const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues();
return fieldNamesWithEmptyValues.length === 0;
};
toggleShowAnnotationsHandler = () => {
this.setState((prevState) => ({
showAnnotations: !prevState.showAnnotations,
}));
};
toggleShowForecastHandler = () => {
this.setState((prevState) => ({
showForecast: !prevState.showForecast,
}));
};
toggleShowModelBoundsHandler = () => {
this.setState({
showModelBounds: !this.state.showModelBounds,
});
};
setFunctionDescription = (selectedFuction) => {
this.props.appStateHandler(APP_STATE_ACTION.SET_FUNCTION_DESCRIPTION, selectedFuction);
};
previousChartProps = {};
previousShowAnnotations = undefined;
previousShowForecast = undefined;
previousShowModelBounds = undefined;
tableFilter = (field, value, operator) => {
const entities = this.getControlsForDetector();
const entity = entities.find(({ fieldName }) => fieldName === field);
if (entity === undefined) {
return;
}
const { appStateHandler } = this.props;
let resultValue = '';
if (operator === '+' && entity.fieldValue !== value) {
resultValue = value;
} else if (operator === '-' && entity.fieldValue === value) {
resultValue = null;
} else {
return;
}
const resultEntities = {
...entities.reduce((appStateEntities, appStateEntity) => {
appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue;
return appStateEntities;
}, {}),
[entity.fieldName]: resultValue,
};
appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities);
};
contextChartSelectedInitCallDone = false;
getFocusAggregationInterval(selection) {
const { selectedJob } = this.props;
// Calculate the aggregation interval for the focus chart.
const bounds = { min: moment(selection.from), max: moment(selection.to) };
return this.mlTimeSeriesExplorer.calculateAggregationInterval(
bounds,
CHARTS_POINT_TARGET,
selectedJob
);
}
/**
* Gets focus data for the current component state
*/
getFocusData(selection) {
const { selectedForecastId, selectedDetectorIndex, functionDescription, selectedJob } =
this.props;
const { modelPlotEnabled } = this.state;
if (isMetricDetector(selectedJob, selectedDetectorIndex) && functionDescription === undefined) {
return;
}
const entityControls = this.getControlsForDetector();
// Calculate the aggregation interval for the focus chart.
const bounds = { min: moment(selection.from), max: moment(selection.to) };
const focusAggregationInterval = this.getFocusAggregationInterval(selection);
// Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete.
// For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected
// to some extent with all detector functions if not searching complete buckets.
const searchBounds = this.getBoundsRoundedToInterval(bounds, focusAggregationInterval, false);
return this.mlTimeSeriesExplorer.getFocusData(
this.getCriteriaFields(selectedDetectorIndex, entityControls),
selectedDetectorIndex,
focusAggregationInterval,
selectedForecastId,
modelPlotEnabled,
entityControls.filter((entity) => entity.fieldValue !== null),
searchBounds,
selectedJob,
functionDescription,
TIME_FIELD_NAME
);
}
contextChartSelected = (selection) => {
const zoomState = {
from: selection.from.toISOString(),
to: selection.to.toISOString(),
};
if (
isEqual(this.props.zoom, zoomState) &&
this.state.focusChartData !== undefined &&
this.props.previousRefresh === this.props.lastRefresh
) {
return;
}
this.contextChart$.next(selection);
this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState);
};
setForecastId = (forecastId) => {
this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId);
};
displayErrorToastMessages = (error, errorMsg) => {
if (this.props.toastNotificationService) {
this.props.toastNotificationService.displayErrorToast(error, errorMsg, 2000);
}
this.setState({ loading: false, chartDataError: errorMsg });
};
loadSingleMetricData = (fullRefresh = true) => {
const {
autoZoomDuration,
bounds,
selectedDetectorIndex,
zoom,
functionDescription,
selectedJob,
} = this.props;
const { loadCounter: currentLoadCounter } = this.state;
if (selectedJob === undefined) {
return;
}
if (isMetricDetector(selectedJob, selectedDetectorIndex) && functionDescription === undefined) {
return;
}
const functionToPlotByIfMetric = aggregationTypeTransform.toES(functionDescription);
this.contextChartSelectedInitCallDone = false;
// Only when `fullRefresh` is true we'll reset all data
// and show the loading spinner within the page.
const entityControls = this.getControlsForDetector();
this.setState(
{
fullRefresh,
loadCounter: currentLoadCounter + 1,
loading: true,
chartDataError: undefined,
...(fullRefresh
? {
chartDetails: undefined,
contextChartData: undefined,
contextForecastData: undefined,
focusChartData: undefined,
focusForecastData: undefined,
modelPlotEnabled:
isModelPlotChartableForDetector(selectedJob, selectedDetectorIndex) &&
isModelPlotEnabled(selectedJob, selectedDetectorIndex, entityControls),
hasResults: false,
dataNotChartable: false,
}
: {}),
},
() => {
const { loadCounter, modelPlotEnabled } = this.state;
const { selectedJob } = this.props;
const detectorIndex = selectedDetectorIndex;
let awaitingCount = 3;
const stateUpdate = {};
// finish() function, called after each data set has been loaded and processed.
// The last one to call it will trigger the page render.
const finish = (counterVar) => {
awaitingCount--;
if (awaitingCount === 0 && counterVar === loadCounter) {
stateUpdate.hasResults =
(Array.isArray(stateUpdate.contextChartData) &&
stateUpdate.contextChartData.length > 0) ||
(Array.isArray(stateUpdate.contextForecastData) &&
stateUpdate.contextForecastData.length > 0);
stateUpdate.loading = false;
// Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically
// selecting the specified range in the context chart, and so loading that date range in the focus chart.
// Only touch the zoom range if data for the context chart has been loaded and all necessary
// partition fields have a selection.
if (
stateUpdate.contextChartData.length &&
this.arePartitioningFieldsProvided() === true
) {
// Check for a zoom parameter in the appState (URL).
let focusRange = this.mlTimeSeriesExplorer.calculateInitialFocusRange(
zoom,
stateUpdate.contextAggregationInterval,
bounds
);
if (
focusRange === undefined ||
this.previousSelectedForecastId !== this.props.selectedForecastId
) {
focusRange = this.mlTimeSeriesExplorer.calculateDefaultFocusRange(
autoZoomDuration,
stateUpdate.contextAggregationInterval,
stateUpdate.contextChartData,
stateUpdate.contextForecastData
);
this.previousSelectedForecastId = this.props.selectedForecastId;
}
this.contextChartSelected({
from: focusRange[0],
to: focusRange[1],
});
}
this.setState(stateUpdate);
}
};
const nonBlankEntities = entityControls.filter((entity) => {
return entity.fieldValue !== null;
});
if (
modelPlotEnabled === false &&
isSourceDataChartableForDetector(selectedJob, detectorIndex) === false &&
nonBlankEntities.length > 0
) {
// For detectors where model plot has been enabled with a terms filter and the
// selected entity(s) are not in the terms list, indicate that data cannot be viewed.
stateUpdate.hasResults = false;
stateUpdate.loading = false;
stateUpdate.dataNotChartable = true;
this.setState(stateUpdate);
return;
}
// Calculate the aggregation interval for the context chart.
// Context chart swimlane will display bucket anomaly score at the same interval.
stateUpdate.contextAggregationInterval =
this.mlTimeSeriesExplorer.calculateAggregationInterval(
bounds,
CHARTS_POINT_TARGET,
selectedJob
);
// Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete.
// For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected
// to some extent with all detector functions if not searching complete buckets.
const searchBounds = this.getBoundsRoundedToInterval(
bounds,
stateUpdate.contextAggregationInterval,
false
);
// Query 1 - load metric data at low granularity across full time range.
// Pass a counter flag into the finish() function to make sure we only process the results
// for the most recent call to the load the data in cases where the job selection and time filter
// have been altered in quick succession (such as from the job picker with 'Apply time range').
const counter = loadCounter;
this.context.services.mlServices.mlTimeSeriesSearchService
.getMetricData(
selectedJob,
detectorIndex,
nonBlankEntities,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
stateUpdate.contextAggregationInterval.asMilliseconds(),
functionToPlotByIfMetric
)
.toPromise()
.then((resp) => {
const fullRangeChartData = this.mlTimeSeriesExplorer.processMetricPlotResults(
resp.results,
modelPlotEnabled
);
stateUpdate.contextChartData = fullRangeChartData;
finish(counter);
})
.catch((err) => {
const errorMsg = i18n.translate('xpack.ml.timeSeriesExplorer.metricDataErrorMessage', {
defaultMessage: 'Error getting metric data',
});
this.displayErrorToastMessages(err, errorMsg);
});
// Query 2 - load max record score at same granularity as context chart
// across full time range for use in the swimlane.
this.context.services.mlServices.mlResultsService
.getRecordMaxScoreByTime(
selectedJob.job_id,
this.getCriteriaFields(detectorIndex, entityControls),
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
stateUpdate.contextAggregationInterval.asMilliseconds(),
functionToPlotByIfMetric
)
.then((resp) => {
const fullRangeRecordScoreData = this.mlTimeSeriesExplorer.processRecordScoreResults(
resp.results
);
stateUpdate.swimlaneData = fullRangeRecordScoreData;
finish(counter);
})
.catch((err) => {
const errorMsg = i18n.translate(
'xpack.ml.timeSeriesExplorer.bucketAnomalyScoresErrorMessage',
{
defaultMessage: 'Error getting bucket anomaly scores',
}
);
this.displayErrorToastMessages(err, errorMsg);
});
// Query 3 - load details on the chart used in the chart title (charting function and entity(s)).
this.context.services.mlServices.mlTimeSeriesSearchService
.getChartDetails(
selectedJob,
detectorIndex,
entityControls,
searchBounds.min.valueOf(),
searchBounds.max.valueOf()
)
.then((resp) => {
stateUpdate.chartDetails = resp.results;
finish(counter);
})
.catch((err) => {
this.displayErrorToastMessages(
err,
i18n.translate('xpack.ml.timeSeriesExplorer.entityCountsErrorMessage', {
defaultMessage: 'Error getting entity counts',
})
);
});
}
);
};
/**
* Updates local state of detector related controls from the global state.
* @param callback to invoke after a state update.
*/
getControlsForDetector = () => {
const { selectedDetectorIndex, selectedEntities, selectedJobId, selectedJob } = this.props;
return getControlsForDetector(
selectedDetectorIndex,
selectedEntities,
selectedJobId,
selectedJob
);
};
/**
* Updates criteria fields for API calls, e.g. getAnomaliesTableData
* @param detectorIndex
* @param entities
*/
getCriteriaFields(detectorIndex, entities) {
// Only filter on the entity if the field has a value.
const nonBlankEntities = entities.filter((entity) => entity.fieldValue !== null);
return [
{
fieldName: 'detector_index',
fieldValue: detectorIndex,
},
...nonBlankEntities,
];
}
async componentDidMount() {
this.getBoundsRoundedToInterval = timeBucketsServiceFactory(
this.context.services.uiSettings
).getBoundsRoundedToInterval;
this.mlTimeSeriesExplorer = timeSeriesExplorerServiceFactory(
this.context.services.uiSettings,
this.context.services.mlServices.mlApiServices,
this.context.services.mlServices.mlResultsService
);
// Listen for context chart updates.
this.subscriptions.add(
this.contextChart$
.pipe(
tap((selection) => {
this.setState({
zoomFrom: selection.from,
zoomTo: selection.to,
});
}),
debounceTime(500),
tap((selection) => {
const {
contextChartData,
contextForecastData,
focusChartData,
zoomFromFocusLoaded,
zoomToFocusLoaded,
} = this.state;
if (
(contextChartData === undefined || contextChartData.length === 0) &&
(contextForecastData === undefined || contextForecastData.length === 0)
) {
return;
}
if (
(this.contextChartSelectedInitCallDone === false && focusChartData === undefined) ||
zoomFromFocusLoaded.getTime() !== selection.from.getTime() ||
zoomToFocusLoaded.getTime() !== selection.to.getTime()
) {
this.contextChartSelectedInitCallDone = true;
this.setState({
loading: true,
fullRefresh: false,
});
}
}),
switchMap((selection) => {
return forkJoin([this.getFocusData(selection)]);
}),
withLatestFrom(this.contextChart$)
)
.subscribe(([[refreshFocusData, tableData], selection]) => {
const { modelPlotEnabled } = this.state;
// All the data is ready now for a state update.
this.setState({
focusAggregationInterval: this.getFocusAggregationInterval({
from: selection.from,
to: selection.to,
}),
loading: false,
showModelBoundsCheckbox: modelPlotEnabled && refreshFocusData.focusChartData.length > 0,
zoomFromFocusLoaded: selection.from,
zoomToFocusLoaded: selection.to,
...refreshFocusData,
...tableData,
});
})
);
if (this.context && this.props.selectedJob !== undefined) {
// Populate the map of jobs / detectors / field formatters for the selected IDs and refresh.
this.context.services.mlServices.mlFieldFormatService.populateFormats([
this.props.selectedJob.job_id,
]);
}
this.componentDidUpdate();
}
componentDidUpdate(previousProps) {
if (
previousProps === undefined ||
previousProps.selectedForecastId !== this.props.selectedForecastId
) {
if (this.props.selectedForecastId !== undefined) {
// Ensure the forecast data will be shown if hidden previously.
this.setState({ showForecast: true });
// Not best practice but we need the previous value for another comparison
// once all the data was loaded.
if (previousProps !== undefined) {
this.previousSelectedForecastId = previousProps.selectedForecastId;
}
}
}
if (
previousProps === undefined ||
!isEqual(previousProps.bounds, this.props.bounds) ||
(!isEqual(previousProps.lastRefresh, this.props.lastRefresh) &&
previousProps.lastRefresh !== 0) ||
!isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) ||
!isEqual(previousProps.selectedEntities, this.props.selectedEntities) ||
previousProps.selectedForecastId !== this.props.selectedForecastId ||
previousProps.selectedJobId !== this.props.selectedJobId ||
previousProps.functionDescription !== this.props.functionDescription
) {
const fullRefresh =
previousProps === undefined ||
!isEqual(previousProps.bounds, this.props.bounds) ||
!isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) ||
!isEqual(previousProps.selectedEntities, this.props.selectedEntities) ||
previousProps.selectedForecastId !== this.props.selectedForecastId ||
previousProps.selectedJobId !== this.props.selectedJobId ||
previousProps.functionDescription !== this.props.functionDescription;
this.loadSingleMetricData(fullRefresh);
}
if (previousProps === undefined) {
return;
}
}
componentWillUnmount() {
this.subscriptions.unsubscribe();
this.unmounted = true;
}
render() {
const {
autoZoomDuration,
bounds,
chartWidth,
lastRefresh,
selectedDetectorIndex,
selectedJob,
} = this.props;
const {
chartDetails,
contextAggregationInterval,
contextChartData,
contextForecastData,
dataNotChartable,
focusAggregationInterval,
focusAnnotationData,
focusChartData,
focusForecastData,
fullRefresh,
hasResults,
loading,
modelPlotEnabled,
showAnnotations,
showAnnotationsCheckbox,
showForecast,
showForecastCheckbox,
showModelBounds,
showModelBoundsCheckbox,
swimlaneData,
zoomFrom,
zoomTo,
zoomFromFocusLoaded,
zoomToFocusLoaded,
chartDataError,
} = this.state;
const chartProps = {
modelPlotEnabled,
contextChartData,
contextChartSelected: this.contextChartSelected,
contextForecastData,
contextAggregationInterval,
swimlaneData,
focusAnnotationData,
focusChartData,
focusForecastData,
focusAggregationInterval,
svgWidth: chartWidth,
zoomFrom,
zoomTo,
zoomFromFocusLoaded,
zoomToFocusLoaded,
autoZoomDuration,
};
const entityControls = this.getControlsForDetector();
const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues();
const arePartitioningFieldsProvided = this.arePartitioningFieldsProvided();
let renderFocusChartOnly = true;
if (
isEqual(this.previousChartProps.focusForecastData, chartProps.focusForecastData) &&
isEqual(this.previousChartProps.focusChartData, chartProps.focusChartData) &&
isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) &&
this.previousShowForecast === showForecast &&
this.previousShowModelBounds === showModelBounds &&
this.props.previousRefresh === lastRefresh
) {
renderFocusChartOnly = false;
}
this.previousChartProps = chartProps;
this.previousShowForecast = showForecast;
this.previousShowModelBounds = showModelBounds;
return (
<>
{fieldNamesWithEmptyValues.length > 0 && (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.singleMetricRequiredMessage"
defaultMessage="To view a single metric, select {missingValuesCount, plural, one {a value for {fieldName1}} other {values for {fieldName1} and {fieldName2}}}."
values={{
missingValuesCount: fieldNamesWithEmptyValues.length,
fieldName1: fieldNamesWithEmptyValues[0],
fieldName2: fieldNamesWithEmptyValues[1],
}}
/>
}
iconType="help"
size="s"
/>
<EuiSpacer size="m" />
</>
)}
{fullRefresh && loading === true && (
<LoadingIndicator
label={i18n.translate('xpack.ml.timeSeriesExplorer.loadingLabel', {
defaultMessage: 'Loading',
})}
/>
)}
{loading === false && chartDataError !== undefined && (
<TimeseriesexplorerChartDataError errorMsg={chartDataError} />
)}
{arePartitioningFieldsProvided &&
selectedJob &&
(fullRefresh === false || loading === false) &&
hasResults === false &&
chartDataError === undefined && (
<TimeseriesexplorerNoChartData
dataNotChartable={dataNotChartable}
entities={entityControls}
/>
)}
{arePartitioningFieldsProvided &&
selectedJob &&
(fullRefresh === false || loading === false) &&
hasResults === true && (
<div>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size={'xs'}>
<h2>
<span>
{i18n.translate(
'xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle',
{
defaultMessage: 'Single time series analysis of {functionLabel}',
values: { functionLabel: chartDetails.functionLabel },
}
)}
</span>
&nbsp;
{chartDetails.entityData.count === 1 && (
<EuiTextColor color={'success'} size={'s'} component={'span'}>
{chartDetails.entityData.entities.length > 0 && '('}
{chartDetails.entityData.entities
.map((entity) => {
return `${entity.fieldName}: ${entity.fieldValue}`;
})
.join(', ')}
{chartDetails.entityData.entities.length > 0 && ')'}
</EuiTextColor>
)}
{chartDetails.entityData.count !== 1 && (
<EuiTextColor color={'success'} size={'s'} component={'span'}>
{chartDetails.entityData.entities.map((countData, i) => {
return (
<Fragment key={countData.fieldName}>
{i18n.translate(
'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription',
{
defaultMessage:
'{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}',
values: {
openBrace: i === 0 ? '(' : '',
closeBrace:
i === chartDetails.entityData.entities.length - 1
? ')'
: '',
cardinalityValue:
countData.cardinality === 0
? allValuesLabel
: countData.cardinality,
cardinality: countData.cardinality,
fieldName: countData.fieldName,
},
}
)}
{i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''}
</Fragment>
);
})}
</EuiTextColor>
)}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TimeSeriesExplorerHelpPopover embeddableMode />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup style={{ float: 'right' }}>
{showModelBoundsCheckbox && (
<TimeseriesExplorerCheckbox
id="toggleModelBoundsCheckbox"
label={i18n.translate('xpack.ml.timeSeriesExplorer.showModelBoundsLabel', {
defaultMessage: 'show model bounds',
})}
checked={showModelBounds}
onChange={this.toggleShowModelBoundsHandler}
/>
)}
{showAnnotationsCheckbox && (
<TimeseriesExplorerCheckbox
id="toggleAnnotationsCheckbox"
label={i18n.translate('xpack.ml.timeSeriesExplorer.annotationsLabel', {
defaultMessage: 'annotations',
})}
checked={showAnnotations}
onChange={this.toggleShowAnnotationsHandler}
/>
)}
{showForecastCheckbox && (
<EuiFlexItem grow={false}>
<EuiCheckbox
id="toggleShowForecastCheckbox"
label={
<span data-test-subj={'mlForecastCheckbox'}>
{i18n.translate('xpack.ml.timeSeriesExplorer.showForecastLabel', {
defaultMessage: 'show forecast',
})}
</span>
}
checked={showForecast}
onChange={this.toggleShowForecastHandler}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<TimeSeriesChartWithTooltips
chartProps={chartProps}
contextAggregationInterval={contextAggregationInterval}
bounds={bounds}
detectorIndex={selectedDetectorIndex}
embeddableMode
renderFocusChartOnly={renderFocusChartOnly}
selectedJob={selectedJob}
selectedEntities={this.props.selectedEntities}
showAnnotations={showAnnotations}
showForecast={showForecast}
showModelBounds={showModelBounds}
lastRefresh={lastRefresh}
/>
</div>
)}
</>
);
}
}

View file

@ -5,12 +5,14 @@
* 2.0.
*/
import React from 'react';
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { HelpPopover } from '../components/help_popover/help_popover';
export const TimeSeriesExplorerHelpPopover = () => {
export const TimeSeriesExplorerHelpPopover: FC<{ embeddableMode: boolean }> = ({
embeddableMode,
}) => {
return (
<HelpPopover
anchorPosition="upCenter"
@ -36,12 +38,14 @@ export const TimeSeriesExplorerHelpPopover = () => {
defaultMessage="If you create a forecast, predicted data values are added to the chart. A shaded area around these values represents the confidence level; as you forecast further into the future, the confidence level generally decreases."
/>
</p>
<p>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.popoverAnnotationsExplanation"
defaultMessage="You can also optionally annotate your job results by drag-selecting a period of time in the chart and adding a description. Some annotations are generated automatically to indicate noteworthy occurrences."
/>
</p>
{!embeddableMode && (
<p>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.popoverAnnotationsExplanation"
defaultMessage="You can also optionally annotate your job results by drag-selecting a period of time in the chart and adding a description. Some annotations are generated automatically to indicate noteworthy occurrences."
/>
</p>
)}
<p>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.popoverModelPlotExplanation"

View file

@ -9,6 +9,7 @@ import { forkJoin, Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { extractErrorMessage } from '@kbn/ml-error-utils';
import { aggregationTypeTransform } from '@kbn/ml-anomaly-utils';
import { MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils';
import { ml } from '../../services/ml_api_service';
import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../common/constants/search';
import { mlTimeSeriesSearchService } from '../timeseries_search_service';
@ -30,11 +31,18 @@ export interface Interval {
expression: string;
}
export interface ChartDataPoint {
date: Date;
value: number | null;
upper?: number | null;
lower?: number | null;
}
export interface FocusData {
focusChartData: any;
anomalyRecords: any;
focusChartData: ChartDataPoint[];
anomalyRecords: MlAnomalyRecordDoc[];
scheduledEvents: any;
showForecastCheckbox?: any;
showForecastCheckbox?: boolean;
focusAnnotationError?: string;
focusAnnotationData?: any[];
focusForecastData?: any;

View file

@ -0,0 +1,46 @@
/*
* 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.
*/
export function getTimeseriesexplorerDefaultState() {
return {
chartDetails: undefined,
contextAggregationInterval: undefined,
contextChartData: undefined,
contextForecastData: undefined,
// Not chartable if e.g. model plot with terms for a varp detector
dataNotChartable: false,
entitiesLoading: false,
entityValues: {},
focusAnnotationData: [],
focusAggregationInterval: {},
focusChartData: undefined,
focusForecastData: undefined,
fullRefresh: true,
hasResults: false,
// Counter to keep track of what data sets have been loaded.
loadCounter: 0,
loading: false,
modelPlotEnabled: false,
// Toggles display of annotations in the focus chart
showAnnotations: true,
showAnnotationsCheckbox: true,
// Toggles display of forecast data in the focus chart
showForecast: true,
showForecastCheckbox: false,
// Toggles display of model bounds in the focus chart
showModelBounds: true,
showModelBoundsCheckbox: false,
svgWidth: 0,
tableData: undefined,
zoomFrom: undefined,
zoomTo: undefined,
zoomFromFocusLoaded: undefined,
zoomToFocusLoaded: undefined,
chartDataError: undefined,
sourceIndicesWithGeoFields: {},
};
}

View file

@ -8,3 +8,4 @@
export { getFocusData } from './get_focus_data';
export * from './timeseriesexplorer_utils';
export { validateJobSelection } from './validate_job_selection';
export { getTimeseriesexplorerDefaultState } from './get_timeseriesexplorer_default_state';

View file

@ -0,0 +1,187 @@
/*
* 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 { each, find, get, filter } from 'lodash';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import type { MlEntityField } from '@kbn/ml-anomaly-utils';
import type { Job } from '../../../../common/types/anomaly_detection_jobs';
import type { ModelPlotOutput } from '../../services/results_service/result_service_rx';
import type { MlApiServices } from '../../services/ml_api_service';
import type { MlResultsService } from '../../services/results_service';
import { buildConfigFromDetector } from '../../util/chart_config_builder';
import {
isModelPlotChartableForDetector,
isModelPlotEnabled,
} from '../../../../common/util/job_utils';
// TODO Consolidate with legacy code in
// `x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts`
export function timeSeriesSearchServiceFactory(
mlResultsService: MlResultsService,
mlApiServices: MlApiServices
) {
return {
getMetricData(
job: Job,
detectorIndex: number,
entityFields: MlEntityField[],
earliestMs: number,
latestMs: number,
intervalMs: number,
esMetricFunction?: string
): Observable<ModelPlotOutput> {
if (
isModelPlotChartableForDetector(job, detectorIndex) &&
isModelPlotEnabled(job, detectorIndex, entityFields)
) {
// Extract the partition, by, over fields on which to filter.
const criteriaFields = [];
const detector = job.analysis_config.detectors[detectorIndex];
if (detector.partition_field_name !== undefined) {
const partitionEntity: any = find(entityFields, {
fieldName: detector.partition_field_name,
});
if (partitionEntity !== undefined) {
criteriaFields.push(
{ fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName },
{ fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue }
);
}
}
if (detector.over_field_name !== undefined) {
const overEntity: any = find(entityFields, { fieldName: detector.over_field_name });
if (overEntity !== undefined) {
criteriaFields.push(
{ fieldName: 'over_field_name', fieldValue: overEntity.fieldName },
{ fieldName: 'over_field_value', fieldValue: overEntity.fieldValue }
);
}
}
if (detector.by_field_name !== undefined) {
const byEntity: any = find(entityFields, { fieldName: detector.by_field_name });
if (byEntity !== undefined) {
criteriaFields.push(
{ fieldName: 'by_field_name', fieldValue: byEntity.fieldName },
{ fieldName: 'by_field_value', fieldValue: byEntity.fieldValue }
);
}
}
return mlResultsService.getModelPlotOutput(
job.job_id,
detectorIndex,
criteriaFields,
earliestMs,
latestMs,
intervalMs
);
} else {
const obj: ModelPlotOutput = {
success: true,
results: {},
};
const chartConfig = buildConfigFromDetector(job, detectorIndex);
return mlResultsService
.getMetricData(
chartConfig.datafeedConfig.indices.join(','),
entityFields,
chartConfig.datafeedConfig.query,
esMetricFunction ?? chartConfig.metricFunction,
chartConfig.metricFieldName,
chartConfig.summaryCountFieldName,
chartConfig.timeField,
earliestMs,
latestMs,
intervalMs,
chartConfig?.datafeedConfig
)
.pipe(
map((resp) => {
each(resp.results, (value, time) => {
// @ts-ignore
obj.results[time] = {
actual: value,
};
});
return obj;
})
);
}
},
// Builds chart detail information (charting function description and entity counts) used
// in the title area of the time series chart.
// Queries Elasticsearch if necessary to obtain the distinct count of entities
// for which data is being plotted.
getChartDetails(
job: Job,
detectorIndex: number,
entityFields: any[],
earliestMs: number,
latestMs: number
) {
return new Promise((resolve, reject) => {
const obj: any = {
success: true,
results: { functionLabel: '', entityData: { entities: [] } },
};
const chartConfig = buildConfigFromDetector(job, detectorIndex);
let functionLabel: string | null = chartConfig.metricFunction;
if (chartConfig.metricFieldName !== undefined) {
functionLabel += ' ';
functionLabel += chartConfig.metricFieldName;
}
obj.results.functionLabel = functionLabel;
const blankEntityFields = filter(entityFields, (entity) => {
return entity.fieldValue === null;
});
// Look to see if any of the entity fields have defined values
// (i.e. blank input), and if so obtain the cardinality.
if (blankEntityFields.length === 0) {
obj.results.entityData.count = 1;
obj.results.entityData.entities = entityFields;
resolve(obj);
} else {
const entityFieldNames: string[] = blankEntityFields.map((f) => f.fieldName);
mlApiServices
.getCardinalityOfFields({
index: chartConfig.datafeedConfig.indices.join(','),
fieldNames: entityFieldNames,
query: chartConfig.datafeedConfig.query,
timeFieldName: chartConfig.timeField,
earliestMs,
latestMs,
})
.then((results: any) => {
each(blankEntityFields, (field) => {
// results will not contain keys for non-aggregatable fields,
// so store as 0 to indicate over all field values.
obj.results.entityData.entities.push({
fieldName: field.fieldName,
cardinality: get(results, field.fieldName, 0),
});
});
resolve(obj);
})
.catch((resp: any) => {
reject(resp);
});
}
});
},
};
}
export type MlTimeSeriesSeachService = ReturnType<typeof timeSeriesSearchServiceFactory>;

View file

@ -0,0 +1,55 @@
/*
* 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 { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
import type { Job } from '../../../common/types/anomaly_detection_jobs';
// TODO Consolidate with legacy code in `ml/public/application/util/index_utils.ts`.
export function indexServiceFactory(dataViewsService: DataViewsContract) {
return {
/**
* Retrieves the data view ID from the given name.
* If a job is passed in, a temporary data view will be created if the requested data view doesn't exist.
* @param name - The name or index pattern of the data view.
* @param job - Optional job object.
* @returns The data view ID or null if it doesn't exist.
*/
async getDataViewIdFromName(name: string, job?: Job): Promise<string | null> {
if (dataViewsService === null) {
throw new Error('Data views are not initialized!');
}
const dataViews = await dataViewsService.find(name);
const dataView = dataViews.find((dv) => dv.getIndexPattern() === name);
if (!dataView) {
if (job !== undefined) {
const tempDataView = await dataViewsService.create({
id: undefined,
name,
title: name,
timeFieldName: job.data_description.time_field!,
});
return tempDataView.id ?? null;
}
return null;
}
return dataView.id ?? dataView.getIndexPattern();
},
getDataViewById(id: string): Promise<DataView> {
if (dataViewsService === null) {
throw new Error('Data views are not initialized!');
}
if (id) {
return dataViewsService.get(id);
} else {
return dataViewsService.create({});
}
},
};
}
export type MlIndexUtils = ReturnType<typeof indexServiceFactory>;

View file

@ -33,6 +33,7 @@ export declare class TimeBuckets {
public setBounds(bounds: TimeRangeBounds): void;
public getBounds(): { min: any; max: any };
public getInterval(): TimeBucketsInterval;
public getIntervalToNearestMultiple(divisorSecs: any): TimeBucketsInterval;
public getScaledDateFormat(): string;
}

View file

@ -0,0 +1,57 @@
/*
* 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 { useMemo } from 'react';
import type { IUiSettingsClient } from '@kbn/core/public';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import moment from 'moment';
import { type TimeRangeBounds, type TimeBucketsInterval, TimeBuckets } from './time_buckets';
import { useMlKibana } from '../contexts/kibana';
// TODO Consolidate with legacy code in `ml/public/application/util/time_buckets.js`.
export function timeBucketsServiceFactory(uiSettings: IUiSettingsClient) {
function getTimeBuckets(): InstanceType<typeof TimeBuckets> {
return new TimeBuckets({
[UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
[UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}
function getBoundsRoundedToInterval(
bounds: TimeRangeBounds,
interval: TimeBucketsInterval,
inclusiveEnd: boolean = false
): Required<TimeRangeBounds> {
// Returns new bounds, created by flooring the min of the provided bounds to the start of
// the specified interval (a moment duration), and rounded upwards (Math.ceil) to 1ms before
// the start of the next interval (Kibana dashboards search >= bounds min, and <= bounds max,
// so we subtract 1ms off the max to avoid querying start of the new Elasticsearch aggregation bucket).
const intervalMs = interval.asMilliseconds();
const adjustedMinMs = Math.floor(bounds.min!.valueOf() / intervalMs) * intervalMs;
let adjustedMaxMs = Math.ceil(bounds.max!.valueOf() / intervalMs) * intervalMs;
// Don't include the start ms of the next bucket unless specified..
if (inclusiveEnd === false) {
adjustedMaxMs = adjustedMaxMs - 1;
}
return { min: moment(adjustedMinMs), max: moment(adjustedMaxMs) };
}
return { getTimeBuckets, getBoundsRoundedToInterval };
}
export type TimeBucketsService = ReturnType<typeof timeBucketsServiceFactory>;
export function useTimeBucketsService(): TimeBucketsService {
const {
services: { uiSettings },
} = useMlKibana();
const mlTimeBucketsService = useMemo(() => timeBucketsServiceFactory(uiSettings), [uiSettings]);
return mlTimeBucketsService;
}

View file

@ -0,0 +1,648 @@
/*
* 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 { useMemo } from 'react';
import type { IUiSettingsClient } from '@kbn/core/public';
import { aggregationTypeTransform } from '@kbn/ml-anomaly-utils';
import { isMultiBucketAnomaly, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils';
import { extractErrorMessage } from '@kbn/ml-error-utils';
import moment from 'moment';
import { forkJoin, Observable, of } from 'rxjs';
import { each, get } from 'lodash';
import { catchError, map } from 'rxjs/operators';
import { type MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils';
import { parseInterval } from '../../../common/util/parse_interval';
import type { GetAnnotationsResponse } from '../../../common/types/annotations';
import { mlFunctionToESAggregation } from '../../../common/util/job_utils';
import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search';
import { CHARTS_POINT_TARGET } from '../timeseriesexplorer/timeseriesexplorer_constants';
import { timeBucketsServiceFactory } from './time_buckets_service';
import type { TimeRangeBounds } from './time_buckets';
import type { Job } from '../../../common/types/anomaly_detection_jobs';
import type { TimeBucketsInterval } from './time_buckets';
import type {
ChartDataPoint,
FocusData,
Interval,
} from '../timeseriesexplorer/timeseriesexplorer_utils/get_focus_data';
import type { CriteriaField } from '../services/results_service';
import {
MAX_SCHEDULED_EVENTS,
TIME_FIELD_NAME,
} from '../timeseriesexplorer/timeseriesexplorer_constants';
import type { MlApiServices } from '../services/ml_api_service';
import { mlResultsServiceProvider, type MlResultsService } from '../services/results_service';
import { forecastServiceProvider } from '../services/forecast_service_provider';
import { timeSeriesSearchServiceFactory } from '../timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service';
import { useMlKibana } from '../contexts/kibana';
// TODO Consolidate with legacy code in
// `ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js`.
export function timeSeriesExplorerServiceFactory(
uiSettings: IUiSettingsClient,
mlApiServices: MlApiServices,
mlResultsService: MlResultsService
) {
const timeBuckets = timeBucketsServiceFactory(uiSettings);
const mlForecastService = forecastServiceProvider(mlApiServices);
const mlTimeSeriesSearchService = timeSeriesSearchServiceFactory(mlResultsService, mlApiServices);
function getAutoZoomDuration(selectedJob: Job) {
// Calculate the 'auto' zoom duration which shows data at bucket span granularity.
// Get the minimum bucket span of selected jobs.
let autoZoomDuration;
if (selectedJob.analysis_config.bucket_span) {
const bucketSpan = parseInterval(selectedJob.analysis_config.bucket_span);
const bucketSpanSeconds = bucketSpan!.asSeconds();
// In most cases the duration can be obtained by simply multiplying the points target
// Check that this duration returns the bucket span when run back through the
// TimeBucket interval calculation.
autoZoomDuration = bucketSpanSeconds * 1000 * (CHARTS_POINT_TARGET - 1);
// Use a maxBars of 10% greater than the target.
const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET);
const buckets = timeBuckets.getTimeBuckets();
buckets.setInterval('auto');
buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET));
buckets.setMaxBars(maxBars);
// Set bounds from 'now' for testing the auto zoom duration.
const nowMs = new Date().getTime();
const max = moment(nowMs);
const min = moment(nowMs - autoZoomDuration);
buckets.setBounds({ min, max });
const calculatedInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds);
const calculatedIntervalSecs = calculatedInterval.asSeconds();
if (calculatedIntervalSecs !== bucketSpanSeconds) {
// If we haven't got the span back, which may occur depending on the 'auto' ranges
// used in TimeBuckets and the bucket span of the job, then multiply by the ratio
// of the bucket span to the calculated interval.
autoZoomDuration = autoZoomDuration * (bucketSpanSeconds / calculatedIntervalSecs);
}
}
return autoZoomDuration;
}
function calculateAggregationInterval(
bounds: TimeRangeBounds,
bucketsTarget: number | undefined,
selectedJob: Job
) {
// Aggregation interval used in queries should be a function of the time span of the chart
// and the bucket span of the selected job(s).
const barTarget = bucketsTarget !== undefined ? bucketsTarget : 100;
// Use a maxBars of 10% greater than the target.
const maxBars = Math.floor(1.1 * barTarget);
const buckets = timeBuckets.getTimeBuckets();
buckets.setInterval('auto');
buckets.setBounds(bounds);
buckets.setBarTarget(Math.floor(barTarget));
buckets.setMaxBars(maxBars);
let aggInterval;
if (selectedJob.analysis_config.bucket_span) {
// Ensure the aggregation interval is always a multiple of the bucket span to avoid strange
// behaviour such as adjacent chart buckets holding different numbers of job results.
const bucketSpan = parseInterval(selectedJob.analysis_config.bucket_span);
const bucketSpanSeconds = bucketSpan!.asSeconds();
aggInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds);
// Set the interval back to the job bucket span if the auto interval is smaller.
const secs = aggInterval.asSeconds();
if (secs < bucketSpanSeconds) {
buckets.setInterval(bucketSpanSeconds + 's');
aggInterval = buckets.getInterval();
}
}
return aggInterval;
}
function calculateInitialFocusRange(
zoomState: any,
contextAggregationInterval: any,
bounds: TimeRangeBounds
) {
if (zoomState !== undefined) {
// Check that the zoom times are valid.
// zoomFrom must be at or after context chart search bounds earliest,
// zoomTo must be at or before context chart search bounds latest.
const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true);
const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true);
const searchBounds = timeBuckets.getBoundsRoundedToInterval(
bounds,
contextAggregationInterval,
true
);
const earliest = searchBounds.min;
const latest = searchBounds.max;
if (
zoomFrom.isValid() &&
zoomTo.isValid() &&
zoomTo.isAfter(zoomFrom) &&
zoomFrom.isBetween(earliest, latest, null, '[]') &&
zoomTo.isBetween(earliest, latest, null, '[]')
) {
return [zoomFrom.toDate(), zoomTo.toDate()];
}
}
return undefined;
}
function calculateDefaultFocusRange(
autoZoomDuration: any,
contextAggregationInterval: any,
contextChartData: any,
contextForecastData: any
) {
const isForecastData = contextForecastData !== undefined && contextForecastData.length > 0;
const combinedData =
isForecastData === false ? contextChartData : contextChartData.concat(contextForecastData);
const earliestDataDate = combinedData[0].date;
const latestDataDate = combinedData[combinedData.length - 1].date;
let rangeEarliestMs;
let rangeLatestMs;
if (isForecastData === true) {
// Return a range centred on the start of the forecast range, depending
// on the time range of the forecast and data.
const earliestForecastDataDate = contextForecastData[0].date;
const latestForecastDataDate = contextForecastData[contextForecastData.length - 1].date;
rangeLatestMs = Math.min(
earliestForecastDataDate.getTime() + autoZoomDuration / 2,
latestForecastDataDate.getTime()
);
rangeEarliestMs = Math.max(rangeLatestMs - autoZoomDuration, earliestDataDate.getTime());
} else {
// Returns the range that shows the most recent data at bucket span granularity.
rangeLatestMs = latestDataDate.getTime() + contextAggregationInterval.asMilliseconds();
rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - autoZoomDuration);
}
return [new Date(rangeEarliestMs), new Date(rangeLatestMs)];
}
// Return dataset in format used by the swimlane.
// i.e. array of Objects with keys date (JavaScript date) and score.
function processRecordScoreResults(scoreData: any) {
const bucketScoreData: any = [];
each(scoreData, (dataForTime, time) => {
bucketScoreData.push({
date: new Date(+time),
score: dataForTime.score,
});
});
return bucketScoreData;
}
// Return dataset in format used by the single metric chart.
// i.e. array of Objects with keys date (JavaScript date) and value,
// plus lower and upper keys if model plot is enabled for the series.
function processMetricPlotResults(metricPlotData: any, modelPlotEnabled: any) {
const metricPlotChartData: any = [];
if (modelPlotEnabled === true) {
each(metricPlotData, (dataForTime, time) => {
metricPlotChartData.push({
date: new Date(+time),
lower: dataForTime.modelLower,
value: dataForTime.actual,
upper: dataForTime.modelUpper,
});
});
} else {
each(metricPlotData, (dataForTime, time) => {
metricPlotChartData.push({
date: new Date(+time),
value: dataForTime.actual,
});
});
}
return metricPlotChartData;
}
// Returns forecast dataset in format used by the single metric chart.
// i.e. array of Objects with keys date (JavaScript date), isForecast,
// value, lower and upper keys.
function processForecastResults(forecastData: any) {
const forecastPlotChartData: any = [];
each(forecastData, (dataForTime, time) => {
forecastPlotChartData.push({
date: new Date(+time),
isForecast: true,
lower: dataForTime.forecastLower,
value: dataForTime.prediction,
upper: dataForTime.forecastUpper,
});
});
return forecastPlotChartData;
}
// Finds the chart point which corresponds to an anomaly with the
// specified time.
function findChartPointForAnomalyTime(
chartData: any,
anomalyTime: any,
aggregationInterval: any
) {
let chartPoint;
if (chartData === undefined) {
return chartPoint;
}
for (let i = 0; i < chartData.length; i++) {
if (chartData[i].date.getTime() === anomalyTime) {
chartPoint = chartData[i];
break;
}
}
if (chartPoint === undefined) {
// Find the time of the point which falls immediately before the
// time of the anomaly. This is the start of the chart 'bucket'
// which contains the anomalous bucket.
let foundItem;
const intervalMs = aggregationInterval.asMilliseconds();
for (let i = 0; i < chartData.length; i++) {
const itemTime = chartData[i].date.getTime();
if (anomalyTime - itemTime < intervalMs) {
foundItem = chartData[i];
break;
}
}
chartPoint = foundItem;
}
return chartPoint;
}
// Uses data from the list of anomaly records to add anomalyScore,
// function, actual and typical properties, plus causes and multi-bucket
// info if applicable, to the chartData entries for anomalous buckets.
function processDataForFocusAnomalies(
chartData: ChartDataPoint[],
anomalyRecords: MlAnomalyRecordDoc[],
aggregationInterval: Interval,
modelPlotEnabled: boolean,
functionDescription?: string
) {
const timesToAddPointsFor: number[] = [];
// Iterate through the anomaly records making sure we have chart points for each anomaly.
const intervalMs = aggregationInterval.asMilliseconds();
let lastChartDataPointTime: any;
if (chartData !== undefined && chartData.length > 0) {
lastChartDataPointTime = chartData[chartData.length - 1].date.getTime();
}
anomalyRecords.forEach((record: MlAnomalyRecordDoc) => {
const recordTime = record[TIME_FIELD_NAME];
const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval);
if (chartPoint === undefined) {
const timeToAdd = Math.floor(recordTime / intervalMs) * intervalMs;
if (timesToAddPointsFor.indexOf(timeToAdd) === -1 && timeToAdd !== lastChartDataPointTime) {
timesToAddPointsFor.push(timeToAdd);
}
}
});
timesToAddPointsFor.sort((a, b) => a - b);
timesToAddPointsFor.forEach((time) => {
const pointToAdd: ChartDataPoint = {
date: new Date(time),
value: null,
};
if (modelPlotEnabled === true) {
pointToAdd.upper = null;
pointToAdd.lower = null;
}
chartData.push(pointToAdd);
});
// Iterate through the anomaly records adding the
// various properties required for display.
anomalyRecords.forEach((record) => {
// Look for a chart point with the same time as the record.
// If none found, find closest time in chartData set.
const recordTime = record[TIME_FIELD_NAME];
if (
record.function === ML_JOB_AGGREGATION.METRIC &&
record.function_description !== functionDescription
)
return;
const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval);
if (chartPoint !== undefined) {
// If chart aggregation interval > bucket span, there may be more than
// one anomaly record in the interval, so use the properties from
// the record with the highest anomalyScore.
const recordScore = record.record_score;
const pointScore = chartPoint.anomalyScore;
if (pointScore === undefined || pointScore < recordScore) {
chartPoint.anomalyScore = recordScore;
chartPoint.function = record.function;
if (record.actual !== undefined) {
// If cannot match chart point for anomaly time
// substitute the value with the record's actual so it won't plot as null/0
if (chartPoint.value === null || record.function === ML_JOB_AGGREGATION.METRIC) {
chartPoint.value = Array.isArray(record.actual) ? record.actual[0] : record.actual;
}
chartPoint.actual = record.actual;
chartPoint.typical = record.typical;
} else {
const causes = get(record, 'causes', []);
if (causes.length > 0) {
chartPoint.byFieldName = record.by_field_name;
chartPoint.numberOfCauses = causes.length;
if (causes.length === 1) {
// If only a single cause, copy actual and typical values to the top level.
const cause = record.causes![0];
chartPoint.actual = cause.actual;
chartPoint.typical = cause.typical;
// substitute the value with the record's actual so it won't plot as null/0
if (chartPoint.value === null) {
chartPoint.value = cause.actual;
}
}
}
}
if (
record.anomaly_score_explanation !== undefined &&
record.anomaly_score_explanation.multi_bucket_impact !== undefined
) {
chartPoint.multiBucketImpact = record.anomaly_score_explanation.multi_bucket_impact;
}
chartPoint.isMultiBucketAnomaly = isMultiBucketAnomaly(record);
}
}
});
return chartData;
}
function findChartPointForScheduledEvent(chartData: any, eventTime: any) {
let chartPoint;
if (chartData === undefined) {
return chartPoint;
}
for (let i = 0; i < chartData.length; i++) {
if (chartData[i].date.getTime() === eventTime) {
chartPoint = chartData[i];
break;
}
}
return chartPoint;
}
// Adds a scheduledEvents property to any points in the chart data set
// which correspond to times of scheduled events for the job.
function processScheduledEventsForChart(
chartData: ChartDataPoint[],
scheduledEvents: Array<{ events: any; time: number }> | undefined,
aggregationInterval: TimeBucketsInterval
) {
if (scheduledEvents !== undefined) {
const timesToAddPointsFor: number[] = [];
// Iterate through the scheduled events making sure we have a chart point for each event.
const intervalMs = aggregationInterval.asMilliseconds();
let lastChartDataPointTime: number | undefined;
if (chartData !== undefined && chartData.length > 0) {
lastChartDataPointTime = chartData[chartData.length - 1].date.getTime();
}
// In case there's no chart data/sparse data during these scheduled events
// ensure we add chart points at every aggregation interval for these scheduled events.
let sortRequired = false;
each(scheduledEvents, (events, time) => {
const exactChartPoint = findChartPointForScheduledEvent(chartData, +time);
if (exactChartPoint !== undefined) {
exactChartPoint.scheduledEvents = events;
} else {
const timeToAdd: number = Math.floor(time / intervalMs) * intervalMs;
if (
timesToAddPointsFor.indexOf(timeToAdd) === -1 &&
timeToAdd !== lastChartDataPointTime
) {
const pointToAdd = {
date: new Date(timeToAdd),
value: null,
scheduledEvents: events,
};
chartData.push(pointToAdd);
sortRequired = true;
}
}
});
// Sort chart data by time if extra points were added at the end of the array for scheduled events.
if (sortRequired) {
chartData.sort((a, b) => a.date.getTime() - b.date.getTime());
}
}
return chartData;
}
function getFocusData(
criteriaFields: CriteriaField[],
detectorIndex: number,
focusAggregationInterval: TimeBucketsInterval,
forecastId: string,
modelPlotEnabled: boolean,
nonBlankEntities: any[],
searchBounds: any,
selectedJob: Job,
functionDescription?: string | undefined
): Observable<FocusData> {
const esFunctionToPlotIfMetric =
functionDescription !== undefined
? aggregationTypeTransform.toES(functionDescription)
: functionDescription;
return forkJoin([
// Query 1 - load metric data across selected time range.
mlTimeSeriesSearchService.getMetricData(
selectedJob,
detectorIndex,
nonBlankEntities,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.asMilliseconds(),
esFunctionToPlotIfMetric
),
// Query 2 - load all the records across selected time range for the chart anomaly markers.
mlApiServices.results.getAnomalyRecords$(
[selectedJob.job_id],
criteriaFields,
0,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.expression,
functionDescription
),
// Query 3 - load any scheduled events for the selected job.
mlResultsService.getScheduledEventsByBucket(
[selectedJob.job_id],
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.asMilliseconds(),
1,
MAX_SCHEDULED_EVENTS
),
// Query 4 - load any annotations for the selected job.
mlApiServices.annotations
.getAnnotations$({
jobIds: [selectedJob.job_id],
earliestMs: searchBounds.min.valueOf(),
latestMs: searchBounds.max.valueOf(),
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
detectorIndex,
entities: nonBlankEntities,
})
.pipe(
catchError((resp) =>
of({
annotations: {},
totalCount: 0,
error: extractErrorMessage(resp),
success: false,
} as GetAnnotationsResponse)
)
),
// Plus query for forecast data if there is a forecastId stored in the appState.
forecastId !== undefined
? (() => {
let aggType;
const detector = selectedJob.analysis_config.detectors[detectorIndex];
const esAgg = mlFunctionToESAggregation(detector.function);
if (!modelPlotEnabled && (esAgg === 'sum' || esAgg === 'count')) {
aggType = { avg: 'sum', max: 'sum', min: 'sum' };
}
return mlForecastService.getForecastData(
selectedJob,
detectorIndex,
forecastId,
nonBlankEntities,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
focusAggregationInterval.asMilliseconds(),
aggType
);
})()
: of(null),
]).pipe(
map(
([metricData, recordsForCriteria, scheduledEventsByBucket, annotations, forecastData]) => {
// Sort in descending time order before storing in scope.
const anomalyRecords = recordsForCriteria?.records
.sort((a, b) => a[TIME_FIELD_NAME] - b[TIME_FIELD_NAME])
.reverse();
const scheduledEvents = scheduledEventsByBucket?.events[selectedJob.job_id];
let focusChartData = processMetricPlotResults(metricData.results, modelPlotEnabled);
// Tell the results container directives to render the focus chart.
focusChartData = processDataForFocusAnomalies(
focusChartData,
anomalyRecords,
focusAggregationInterval,
modelPlotEnabled,
functionDescription
);
focusChartData = processScheduledEventsForChart(
focusChartData,
scheduledEvents,
focusAggregationInterval
);
const refreshFocusData: FocusData = {
scheduledEvents,
anomalyRecords,
focusChartData,
};
if (annotations) {
if (annotations.error !== undefined) {
refreshFocusData.focusAnnotationError = annotations.error;
refreshFocusData.focusAnnotationData = [];
} else {
refreshFocusData.focusAnnotationData = (
annotations.annotations[selectedJob.job_id] ?? []
)
.sort((a, b) => {
return a.timestamp - b.timestamp;
})
.map((d, i: number) => {
d.key = (i + 1).toString();
return d;
});
}
}
if (forecastData) {
refreshFocusData.focusForecastData = processForecastResults(forecastData.results);
refreshFocusData.showForecastCheckbox = refreshFocusData.focusForecastData.length > 0;
}
return refreshFocusData;
}
)
);
}
return {
getAutoZoomDuration,
calculateAggregationInterval,
calculateInitialFocusRange,
calculateDefaultFocusRange,
processRecordScoreResults,
processMetricPlotResults,
processForecastResults,
findChartPointForAnomalyTime,
processDataForFocusAnomalies,
findChartPointForScheduledEvent,
processScheduledEventsForChart,
getFocusData,
};
}
export function useTimeSeriesExplorerService(): TimeSeriesExplorerService {
const {
services: {
uiSettings,
mlServices: { mlApiServices },
},
} = useMlKibana();
const mlResultsService = mlResultsServiceProvider(mlApiServices);
const mlTimeSeriesExplorer = useMemo(
() => timeSeriesExplorerServiceFactory(uiSettings, mlApiServices, mlResultsService),
[uiSettings, mlApiServices, mlResultsService]
);
return mlTimeSeriesExplorer;
}
export type TimeSeriesExplorerService = ReturnType<typeof timeSeriesExplorerServiceFactory>;

View file

@ -26,7 +26,8 @@ import { JobSelectorFlyout } from './components/job_selector_flyout';
*/
export async function resolveJobSelection(
coreStart: CoreStart,
selectedJobIds?: JobId[]
selectedJobIds?: JobId[],
singleSelection: boolean = false
): Promise<{ jobIds: string[]; groups: Array<{ groupId: string; jobIds: string[] }> }> {
const {
http,
@ -74,7 +75,7 @@ export async function resolveJobSelection(
selectedIds={selectedJobIds}
withTimeRangeSelector={false}
dateFormatTz={dateFormatTz}
singleSelection={false}
singleSelection={singleSelection}
timeseriesOnly={true}
onFlyoutClose={onFlyoutClose}
onSelectionConfirmed={onSelectionConfirmed}

View file

@ -7,6 +7,7 @@
export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane' as const;
export const ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE = 'ml_anomaly_charts' as const;
export const ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE = 'ml_single_metric_viewer' as const;
export type AnomalySwimLaneEmbeddableType = typeof ANOMALY_SWIMLANE_EMBEDDABLE_TYPE;
export type AnomalyExplorerChartsEmbeddableType = typeof ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE;

View file

@ -9,6 +9,7 @@ import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane';
import type { MlCoreSetup } from '../plugin';
import { AnomalyChartsEmbeddableFactory } from './anomaly_charts';
import { SingleMetricViewerEmbeddableFactory } from './single_metric_viewer';
export * from './constants';
export * from './types';
@ -25,6 +26,8 @@ export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSet
);
const anomalyChartsFactory = new AnomalyChartsEmbeddableFactory(core.getStartServices);
embeddable.registerEmbeddableFactory(anomalyChartsFactory.type, anomalyChartsFactory);
const singleMetricViewerFactory = new SingleMetricViewerEmbeddableFactory(core.getStartServices);
embeddable.registerEmbeddableFactory(singleMetricViewerFactory.type, singleMetricViewerFactory);
}

View file

@ -0,0 +1,6 @@
// ML has it's own variables for coloring
@import '../../application/variables';
// Protect the rest of Kibana from ML generic namespacing
@import '../../application/timeseriesexplorer/timeseriesexplorer';
@import '../../application/timeseriesexplorer/timeseriesexplorer_annotations';

View file

@ -0,0 +1,204 @@
/*
* 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, useEffect, useRef, useState } from 'react';
import { EuiResizeObserver } from '@elastic/eui';
import { Observable } from 'rxjs';
import { throttle } from 'lodash';
import { MlJob } from '@elastic/elasticsearch/lib/api/types';
import usePrevious from 'react-use/lib/usePrevious';
import { useToastNotificationService } from '../../application/services/toast_notification_service';
import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context';
import { useSingleMetricViewerInputResolver } from './use_single_metric_viewer_input_resolver';
import type { ISingleMetricViewerEmbeddable } from './single_metric_viewer_embeddable';
import type {
SingleMetricViewerEmbeddableInput,
AnomalyChartsEmbeddableOutput,
SingleMetricViewerEmbeddableServices,
} from '..';
import { ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE } from '..';
import { TimeSeriesExplorerEmbeddableChart } from '../../application/timeseriesexplorer/timeseriesexplorer_embeddable_chart';
import { APP_STATE_ACTION } from '../../application/timeseriesexplorer/timeseriesexplorer_constants';
import { useTimeSeriesExplorerService } from '../../application/util/time_series_explorer_service';
import './_index.scss';
const RESIZE_THROTTLE_TIME_MS = 500;
interface AppStateZoom {
from?: string;
to?: string;
}
export interface EmbeddableSingleMetricViewerContainerProps {
id: string;
embeddableContext: InstanceType<ISingleMetricViewerEmbeddable>;
embeddableInput: Observable<SingleMetricViewerEmbeddableInput>;
services: SingleMetricViewerEmbeddableServices;
refresh: Observable<void>;
onInputChange: (input: Partial<SingleMetricViewerEmbeddableInput>) => void;
onOutputChange: (output: Partial<AnomalyChartsEmbeddableOutput>) => void;
onRenderComplete: () => void;
onLoading: () => void;
onError: (error: Error) => void;
}
export const EmbeddableSingleMetricViewerContainer: FC<
EmbeddableSingleMetricViewerContainerProps
> = ({
id,
embeddableContext,
embeddableInput,
services,
refresh,
onInputChange,
onOutputChange,
onRenderComplete,
onError,
onLoading,
}) => {
useEmbeddableExecutionContext<SingleMetricViewerEmbeddableInput>(
services[0].executionContext,
embeddableInput,
ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE,
id
);
const [chartWidth, setChartWidth] = useState<number>(0);
const [zoom, setZoom] = useState<AppStateZoom | undefined>();
const [selectedForecastId, setSelectedForecastId] = useState<string | undefined>();
const [detectorIndex, setDetectorIndex] = useState<number>(0);
const [selectedJob, setSelectedJob] = useState<MlJob | undefined>();
const [autoZoomDuration, setAutoZoomDuration] = useState<number | undefined>();
const { mlApiServices } = services[2];
const { data, bounds, lastRefresh } = useSingleMetricViewerInputResolver(
embeddableInput,
refresh,
services[1].data.query.timefilter.timefilter,
onRenderComplete
);
const selectedJobId = data?.jobIds[0];
const previousRefresh = usePrevious(lastRefresh ?? 0);
const mlTimeSeriesExplorer = useTimeSeriesExplorerService();
// Holds the container height for previously fetched data
const containerHeightRef = useRef<number>();
const toastNotificationService = useToastNotificationService();
useEffect(
function setUpSelectedJob() {
async function fetchSelectedJob() {
if (mlApiServices && selectedJobId !== undefined) {
const { jobs } = await mlApiServices.getJobs({ jobId: selectedJobId });
const job = jobs[0];
setSelectedJob(job);
}
}
fetchSelectedJob();
},
[selectedJobId, mlApiServices]
);
useEffect(
function setUpAutoZoom() {
let zoomDuration: number | undefined;
if (selectedJobId !== undefined && selectedJob !== undefined) {
zoomDuration = mlTimeSeriesExplorer.getAutoZoomDuration(selectedJob);
setAutoZoomDuration(zoomDuration);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedJobId, selectedJob?.job_id, mlTimeSeriesExplorer]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const resizeHandler = useCallback(
throttle((e: { width: number; height: number }) => {
// Keep previous container height so it doesn't change the page layout
containerHeightRef.current = e.height;
if (Math.abs(chartWidth - e.width) > 20) {
setChartWidth(e.width);
}
}, RESIZE_THROTTLE_TIME_MS),
[chartWidth]
);
const appStateHandler = useCallback(
(action: string, payload?: any) => {
/**
* Empty zoom indicates that chart hasn't been rendered yet,
* hence any updates prior that should replace the URL state.
*/
switch (action) {
case APP_STATE_ACTION.SET_DETECTOR_INDEX:
setDetectorIndex(payload);
break;
case APP_STATE_ACTION.SET_FORECAST_ID:
setSelectedForecastId(payload);
setZoom(undefined);
break;
case APP_STATE_ACTION.SET_ZOOM:
setZoom(payload);
break;
case APP_STATE_ACTION.UNSET_ZOOM:
setZoom(undefined);
break;
}
},
[setZoom, setDetectorIndex, setSelectedForecastId]
);
const containerPadding = 10;
return (
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<div
id={`mlSingleMetricViewerEmbeddableWrapper-${id}`}
style={{
width: '100%',
overflowY: 'auto',
overflowX: 'hidden',
padding: '8px',
}}
data-test-subj={`mlSingleMetricViewer_${embeddableContext.id}`}
ref={resizeRef}
className="ml-time-series-explorer"
>
{data !== undefined && autoZoomDuration !== undefined && (
<TimeSeriesExplorerEmbeddableChart
chartWidth={chartWidth - containerPadding}
dataViewsService={services[1].data.dataViews}
toastNotificationService={toastNotificationService}
appStateHandler={appStateHandler}
autoZoomDuration={autoZoomDuration}
bounds={bounds}
lastRefresh={lastRefresh ?? 0}
previousRefresh={previousRefresh}
selectedJobId={selectedJobId}
selectedDetectorIndex={detectorIndex}
selectedEntities={data.selectedEntities}
selectedForecastId={selectedForecastId}
zoom={zoom}
functionDescription={data.functionDescription}
selectedJob={selectedJob}
/>
)}
</div>
)}
</EuiResizeObserver>
);
};
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default EmbeddableSingleMetricViewerContainer;

View file

@ -0,0 +1,12 @@
/*
* 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 from 'react';
export const EmbeddableSingleMetricViewerContainer = React.lazy(
() => import('./embeddable_single_metric_viewer_container')
);

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { SingleMetricViewerEmbeddableFactory } from './single_metric_viewer_embeddable_factory';

View file

@ -0,0 +1,136 @@
/*
* 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, { Suspense } from 'react';
import ReactDOM from 'react-dom';
import { pick } from 'lodash';
import { Embeddable } from '@kbn/embeddable-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { Subject } from 'rxjs';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { IContainer } from '@kbn/embeddable-plugin/public';
import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { EmbeddableSingleMetricViewerContainer } from './embeddable_single_metric_viewer_container_lazy';
import type { JobId } from '../../../common/types/anomaly_detection_jobs';
import type { MlDependencies } from '../../application/app';
import {
ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE,
SingleMetricViewerEmbeddableInput,
AnomalyChartsEmbeddableOutput,
SingleMetricViewerServices,
} from '..';
import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback';
export const getDefaultSingleMetricViewerPanelTitle = (jobIds: JobId[]) =>
i18n.translate('xpack.ml.singleMetricViewerEmbeddable.title', {
defaultMessage: 'ML single metric viewer chart for {jobIds}',
values: { jobIds: jobIds.join(', ') },
});
export type ISingleMetricViewerEmbeddable = typeof SingleMetricViewerEmbeddable;
export class SingleMetricViewerEmbeddable extends Embeddable<
SingleMetricViewerEmbeddableInput,
AnomalyChartsEmbeddableOutput
> {
private node?: HTMLElement;
private reload$ = new Subject<void>();
public readonly type: string = ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE;
constructor(
initialInput: SingleMetricViewerEmbeddableInput,
public services: [CoreStart, MlDependencies, SingleMetricViewerServices],
parent?: IContainer
) {
super(initialInput, {} as AnomalyChartsEmbeddableOutput, parent);
}
public onLoading() {
this.renderComplete.dispatchInProgress();
this.updateOutput({ loading: true, error: undefined });
}
public onError(error: Error) {
this.renderComplete.dispatchError();
this.updateOutput({ loading: false, error: { name: error.name, message: error.message } });
}
public onRenderComplete() {
this.renderComplete.dispatchComplete();
this.updateOutput({ loading: false, error: undefined });
}
public render(node: HTMLElement) {
super.render(node);
this.node = node;
// required for the export feature to work
this.node.setAttribute('data-shared-item', '');
const I18nContext = this.services[0].i18n.Context;
const theme$ = this.services[0].theme.theme$;
const datePickerDeps: DatePickerDependencies = {
...pick(this.services[0], ['http', 'notifications', 'theme', 'uiSettings', 'i18n']),
data: this.services[1].data,
uiSettingsKeys: UI_SETTINGS,
showFrozenDataTierChoice: false,
};
ReactDOM.render(
<I18nContext>
<KibanaThemeProvider theme$={theme$}>
<KibanaContextProvider
services={{
mlServices: {
...this.services[2],
},
...this.services[0],
}}
>
<DatePickerContextProvider {...datePickerDeps}>
<Suspense fallback={<EmbeddableLoading />}>
<EmbeddableSingleMetricViewerContainer
id={this.input.id}
embeddableContext={this}
embeddableInput={this.getInput$()}
services={this.services}
refresh={this.reload$.asObservable()}
onInputChange={this.updateInput.bind(this)}
onOutputChange={this.updateOutput.bind(this)}
onRenderComplete={this.onRenderComplete.bind(this)}
onLoading={this.onLoading.bind(this)}
onError={this.onError.bind(this)}
/>
</Suspense>
</DatePickerContextProvider>
</KibanaContextProvider>
</KibanaThemeProvider>
</I18nContext>,
node
);
}
public destroy() {
super.destroy();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
public reload() {
this.reload$.next();
}
public supportedTriggers() {
return [];
}
}

View file

@ -0,0 +1,131 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { StartServicesAccessor } from '@kbn/core/public';
import type { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { PLUGIN_ICON, PLUGIN_ID, ML_APP_NAME } from '../../../common/constants/app';
import {
ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE,
SingleMetricViewerEmbeddableInput,
SingleMetricViewerEmbeddableServices,
} from '..';
import type { MlPluginStart, MlStartDependencies } from '../../plugin';
import type { MlDependencies } from '../../application/app';
import { HttpService } from '../../application/services/http_service';
import { AnomalyExplorerChartsService } from '../../application/services/anomaly_explorer_charts_service';
export class SingleMetricViewerEmbeddableFactory
implements EmbeddableFactoryDefinition<SingleMetricViewerEmbeddableInput>
{
public readonly type = ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE;
public readonly grouping = [
{
id: PLUGIN_ID,
getDisplayName: () => ML_APP_NAME,
getIconType: () => PLUGIN_ICON,
},
];
constructor(
private getStartServices: StartServicesAccessor<MlStartDependencies, MlPluginStart>
) {}
public async isEditable() {
return true;
}
public getDisplayName() {
return i18n.translate('xpack.ml.components.mlSingleMetricViewerEmbeddable.displayName', {
defaultMessage: 'Single metric viewer',
});
}
public getDescription() {
return i18n.translate('xpack.ml.components.mlSingleMetricViewerEmbeddable.description', {
defaultMessage: 'View anomaly detection single metric results in a chart.',
});
}
public async getExplicitInput(): Promise<Partial<SingleMetricViewerEmbeddableInput>> {
const [coreStart, pluginStart, singleMetricServices] = await this.getServices();
try {
const { resolveEmbeddableSingleMetricViewerUserInput } = await import(
'./single_metric_viewer_setup_flyout'
);
return await resolveEmbeddableSingleMetricViewerUserInput(
coreStart,
pluginStart,
singleMetricServices
);
} catch (e) {
return Promise.reject();
}
}
private async getServices(): Promise<SingleMetricViewerEmbeddableServices> {
const [
[coreStart, pluginsStart],
{ AnomalyDetectorService },
{ fieldFormatServiceFactory },
{ indexServiceFactory },
{ mlApiServicesProvider },
{ mlResultsServiceProvider },
{ timeSeriesSearchServiceFactory },
] = await Promise.all([
await this.getStartServices(),
await import('../../application/services/anomaly_detector_service'),
await import('../../application/services/field_format_service_factory'),
await import('../../application/util/index_service'),
await import('../../application/services/ml_api_service'),
await import('../../application/services/results_service'),
await import(
'../../application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service'
),
]);
const httpService = new HttpService(coreStart.http);
const anomalyDetectorService = new AnomalyDetectorService(httpService);
const mlApiServices = mlApiServicesProvider(httpService);
const mlResultsService = mlResultsServiceProvider(mlApiServices);
const mlIndexUtils = indexServiceFactory(pluginsStart.data.dataViews);
const mlTimeSeriesSearchService = timeSeriesSearchServiceFactory(
mlResultsService,
mlApiServices
);
const mlFieldFormatService = fieldFormatServiceFactory(mlApiServices, mlIndexUtils);
const anomalyExplorerService = new AnomalyExplorerChartsService(
pluginsStart.data.query.timefilter.timefilter,
mlApiServices,
mlResultsService
);
return [
coreStart,
pluginsStart as MlDependencies,
{
anomalyDetectorService,
anomalyExplorerService,
mlResultsService,
mlApiServices,
mlTimeSeriesSearchService,
mlFieldFormatService,
},
];
}
public async create(initialInput: SingleMetricViewerEmbeddableInput, parent?: IContainer) {
const services = await this.getServices();
const { SingleMetricViewerEmbeddable } = await import('./single_metric_viewer_embeddable');
return new SingleMetricViewerEmbeddable(initialInput, services, parent);
}
}

View file

@ -0,0 +1,157 @@
/*
* 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, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiForm,
EuiFormRow,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiFieldText,
EuiModal,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { MlJob } from '@elastic/elasticsearch/lib/api/types';
import type { SingleMetricViewerServices } from '..';
import { TimeRangeBounds } from '../../application/util/time_buckets';
import { SeriesControls } from '../../application/timeseriesexplorer/components/series_controls';
import {
APP_STATE_ACTION,
type TimeseriesexplorerActionType,
} from '../../application/timeseriesexplorer/timeseriesexplorer_constants';
export interface SingleMetricViewerInitializerProps {
bounds: TimeRangeBounds;
defaultTitle: string;
initialInput?: SingleMetricViewerServices;
job: MlJob;
onCreate: (props: {
panelTitle: string;
functionDescription?: string;
selectedDetectorIndex: number;
selectedEntities: any;
}) => void;
onCancel: () => void;
}
export const SingleMetricViewerInitializer: FC<SingleMetricViewerInitializerProps> = ({
bounds,
defaultTitle,
initialInput,
job,
onCreate,
onCancel,
}) => {
const [panelTitle, setPanelTitle] = useState<string>(defaultTitle);
const [functionDescription, setFunctionDescription] = useState<string | undefined>();
const [selectedDetectorIndex, setSelectedDetectorIndex] = useState<number>(0);
const [selectedEntities, setSelectedEntities] = useState<any>();
const isPanelTitleValid = panelTitle.length > 0;
const handleStateUpdate = (action: TimeseriesexplorerActionType, payload: any) => {
switch (action) {
case APP_STATE_ACTION.SET_ENTITIES:
setSelectedEntities(payload);
break;
case APP_STATE_ACTION.SET_FUNCTION_DESCRIPTION:
setFunctionDescription(payload);
break;
case APP_STATE_ACTION.SET_DETECTOR_INDEX:
setSelectedDetectorIndex(payload);
break;
default:
break;
}
};
return (
<EuiModal
maxWidth={false}
initialFocus="[name=panelTitle]"
onClose={onCancel}
data-test-subj={'mlSingleMetricViewerEmbeddableInitializer'}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.ml.SingleMetricViewerEmbeddable.setupModal.title"
defaultMessage="Single metric viewer configuration"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.singleMetricViewerEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
>
<EuiFieldText
data-test-subj="panelTitleInput"
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={(e) => setPanelTitle(e.target.value)}
isInvalid={!isPanelTitleValid}
/>
</EuiFormRow>
<EuiSpacer />
<SeriesControls
selectedJobId={job.job_id}
job={job}
appStateHandler={handleStateUpdate}
selectedDetectorIndex={selectedDetectorIndex}
selectedEntities={selectedEntities}
bounds={bounds}
functionDescription={functionDescription}
setFunctionDescription={setFunctionDescription}
/>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
onClick={onCancel}
data-test-subj="mlsingleMetricViewerInitializerCancelButton"
>
<FormattedMessage
id="xpack.ml.singleMetricViewerEmbeddable.setupModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
data-test-subj="mlsingleMetricViewerInitializerConfirmButton"
isDisabled={!isPanelTitleValid}
onClick={onCreate.bind(null, {
functionDescription,
panelTitle,
selectedDetectorIndex,
selectedEntities,
})}
fill
>
<FormattedMessage
id="xpack.ml.singleMetricViewerEmbeddable.setupModal.confirmButtonLabel"
defaultMessage="Confirm configurations"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -0,0 +1,75 @@
/*
* 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 from 'react';
import type { CoreStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { getDefaultSingleMetricViewerPanelTitle } from './single_metric_viewer_embeddable';
import type { SingleMetricViewerEmbeddableInput, SingleMetricViewerServices } from '..';
import { resolveJobSelection } from '../common/resolve_job_selection';
import { SingleMetricViewerInitializer } from './single_metric_viewer_initializer';
import type { MlStartDependencies } from '../../plugin';
export async function resolveEmbeddableSingleMetricViewerUserInput(
coreStart: CoreStart,
pluginStart: MlStartDependencies,
input: SingleMetricViewerServices
): Promise<Partial<SingleMetricViewerEmbeddableInput>> {
const { overlays, theme, i18n } = coreStart;
const { mlApiServices } = input;
const timefilter = pluginStart.data.query.timefilter.timefilter;
return new Promise(async (resolve, reject) => {
try {
const { jobIds } = await resolveJobSelection(coreStart, undefined, true);
const title = getDefaultSingleMetricViewerPanelTitle(jobIds);
const { jobs } = await mlApiServices.getJobs({ jobId: jobIds.join(',') });
const modalSession = overlays.openModal(
toMountPoint(
<KibanaContextProvider
services={{
mlServices: { ...input },
...coreStart,
}}
>
<SingleMetricViewerInitializer
defaultTitle={title}
initialInput={input}
job={jobs[0]}
bounds={timefilter.getActiveBounds()!}
onCreate={({
functionDescription,
panelTitle,
selectedDetectorIndex,
selectedEntities,
}) => {
modalSession.close();
resolve({
jobIds,
title: panelTitle,
functionDescription,
panelTitle,
selectedDetectorIndex,
selectedEntities,
});
}}
onCancel={() => {
modalSession.close();
reject();
}}
/>
</KibanaContextProvider>,
{ theme, i18n }
)
);
} catch (error) {
reject(error);
}
});
}

View file

@ -0,0 +1,46 @@
/*
* 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 { useEffect, useState } from 'react';
import { combineLatest, Observable } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { TimefilterContract } from '@kbn/data-plugin/public';
import { SingleMetricViewerEmbeddableInput } from '..';
import type { TimeRangeBounds } from '../../application/util/time_buckets';
export function useSingleMetricViewerInputResolver(
embeddableInput: Observable<SingleMetricViewerEmbeddableInput>,
refresh: Observable<void>,
timefilter: TimefilterContract,
onRenderComplete: () => void
) {
const [data, setData] = useState<any>();
const [bounds, setBounds] = useState<TimeRangeBounds | undefined>();
const [lastRefresh, setLastRefresh] = useState<number | undefined>();
useEffect(function subscribeToEmbeddableInput() {
const subscription = combineLatest([embeddableInput, refresh.pipe(startWith(null))]).subscribe(
(input) => {
if (input !== undefined) {
setData(input[0]);
if (timefilter !== undefined) {
const { timeRange } = input[0];
const currentBounds = timefilter.calculateBounds(timeRange);
setBounds(currentBounds);
setLastRefresh(Date.now());
}
onRenderComplete();
}
}
);
return () => subscription.unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { data, bounds, lastRefresh };
}

View file

@ -27,6 +27,9 @@ import {
MlEmbeddableTypes,
} from './constants';
import { MlResultsService } from '../application/services/results_service';
import type { MlApiServices } from '../application/services/ml_api_service';
import type { MlFieldFormatService } from '../application/services/field_format_service';
import type { MlTimeSeriesSeachService } from '../application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service';
export interface AnomalySwimlaneEmbeddableCustomInput {
jobIds: JobId[];
@ -100,13 +103,45 @@ export interface AnomalyChartsEmbeddableCustomInput {
export type AnomalyChartsEmbeddableInput = EmbeddableInput & AnomalyChartsEmbeddableCustomInput;
export interface SingleMetricViewerEmbeddableCustomInput {
jobIds: JobId[];
title: string;
functionDescription?: string;
panelTitle: string;
selectedDetectorIndex: number;
selectedEntities: MlEntityField[];
// Embeddable inputs which are not included in the default interface
filters: Filter[];
query: Query;
refreshConfig: RefreshInterval;
timeRange: TimeRange;
}
export type SingleMetricViewerEmbeddableInput = EmbeddableInput &
SingleMetricViewerEmbeddableCustomInput;
export interface AnomalyChartsServices {
anomalyDetectorService: AnomalyDetectorService;
anomalyExplorerService: AnomalyExplorerChartsService;
mlResultsService: MlResultsService;
mlApiServices?: MlApiServices;
}
export interface SingleMetricViewerServices {
anomalyExplorerService: AnomalyExplorerChartsService;
anomalyDetectorService: AnomalyDetectorService;
mlApiServices: MlApiServices;
mlFieldFormatService: MlFieldFormatService;
mlResultsService: MlResultsService;
mlTimeSeriesSearchService?: MlTimeSeriesSeachService;
}
export type AnomalyChartsEmbeddableServices = [CoreStart, MlDependencies, AnomalyChartsServices];
export type SingleMetricViewerEmbeddableServices = [
CoreStart,
MlDependencies,
SingleMetricViewerServices
];
export interface AnomalyChartsCustomOutput {
entityFields?: MlEntityField[];
severity?: number;