mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
fa98e5871c
commit
ee34012cd9
39 changed files with 3341 additions and 136 deletions
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -32,3 +32,5 @@ export const mlForecastService: {
|
|||
|
||||
getForecastDateRange: (job: Job, forecastId: string) => Promise<ForecastDateRange>;
|
||||
};
|
||||
|
||||
export type MlForecastService = typeof mlForecastService;
|
||||
|
|
|
@ -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>;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) &&
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: {},
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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>;
|
55
x-pack/plugins/ml/public/application/util/index_service.ts
Normal file
55
x-pack/plugins/ml/public/application/util/index_service.ts
Normal 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>;
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
|
@ -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')
|
||||
);
|
|
@ -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';
|
|
@ -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 [];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue