[Alerting] replace index threshold graph usage of watcher APIs with new API (#59385)
Changes the alerting UI to use the new time series query HTTP endpoint provided by the builtin index threshold alertType; previously it used a watcher HTTP endpoint. This is part of the ongoing index threshold work tracked in https://github.com/elastic/kibana/issues/53041
This commit is contained in:
parent
d969c08a00
commit
ba40d25d46
|
@ -8,6 +8,7 @@ const MINUTES_REGEX = /^[1-9][0-9]*m$/;
|
|||
const HOURS_REGEX = /^[1-9][0-9]*h$/;
|
||||
const DAYS_REGEX = /^[1-9][0-9]*d$/;
|
||||
|
||||
// parse an interval string '{digit*}{s|m|h|d}' into milliseconds
|
||||
export function parseDuration(duration: string): number {
|
||||
const parsed = parseInt(duration, 10);
|
||||
if (isSeconds(duration)) {
|
|
@ -4,6 +4,6 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { parseDuration, validateDurationSchema } from './parse_duration';
|
||||
export { parseDuration, validateDurationSchema } from '../../common/parse_duration';
|
||||
export { LicenseState } from './license_state';
|
||||
export { validateAlertTypeParams } from './validate_alert_type_params';
|
||||
|
|
|
@ -4,4 +4,13 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { TimeBuckets } from './time_buckets';
|
||||
export interface TimeSeriesResult {
|
||||
results: TimeSeriesResultRow[];
|
||||
}
|
||||
|
||||
export interface TimeSeriesResultRow {
|
||||
group: string;
|
||||
metrics: MetricResult[];
|
||||
}
|
||||
|
||||
export type MetricResult = [string, number]; // [iso date, value]
|
|
@ -18,19 +18,11 @@ import {
|
|||
getDateStartAfterDateEndErrorMessage,
|
||||
} from './date_range_info';
|
||||
|
||||
// The result is an object with a key for every field value aggregated
|
||||
// via the `aggField` property. If `aggField` is not specified, the
|
||||
// object will have a single key of `all documents`. The value associated
|
||||
// with each key is an array of 2-tuples of `[ ISO-date, calculated-value ]`
|
||||
|
||||
export interface TimeSeriesResult {
|
||||
results: TimeSeriesResultRow[];
|
||||
}
|
||||
export interface TimeSeriesResultRow {
|
||||
group: string;
|
||||
metrics: MetricResult[];
|
||||
}
|
||||
export type MetricResult = [string, number]; // [iso date, value]
|
||||
export {
|
||||
TimeSeriesResult,
|
||||
TimeSeriesResultRow,
|
||||
MetricResult,
|
||||
} from '../../../../common/alert_types/index_threshold';
|
||||
|
||||
// The parameters here are very similar to the alert parameters.
|
||||
// Missing are `comparator` and `threshold`, which aren't needed to generate
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
"version": "kibana",
|
||||
"server": false,
|
||||
"ui": true,
|
||||
"optionalPlugins": ["alerting", "alertingBuiltins"],
|
||||
"requiredPlugins": ["management", "charts", "data"]
|
||||
}
|
||||
|
|
|
@ -63,6 +63,7 @@ const expressionFieldsWithValidation = [
|
|||
|
||||
interface IndexThresholdProps {
|
||||
alertParams: IndexThresholdAlertParams;
|
||||
alertInterval: string;
|
||||
setAlertParams: (property: string, value: any) => void;
|
||||
setAlertProperty: (key: string, value: any) => void;
|
||||
errors: { [key: string]: string[] };
|
||||
|
@ -71,6 +72,7 @@ interface IndexThresholdProps {
|
|||
|
||||
export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThresholdProps> = ({
|
||||
alertParams,
|
||||
alertInterval,
|
||||
setAlertParams,
|
||||
setAlertProperty,
|
||||
errors,
|
||||
|
@ -477,6 +479,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr
|
|||
<Fragment>
|
||||
<ThresholdVisualization
|
||||
alertParams={alertParams}
|
||||
alertInterval={alertInterval}
|
||||
aggregationTypes={builtInAggregationTypes}
|
||||
comparators={builtInComparators}
|
||||
alertsContext={alertsContext}
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { HttpSetup } from 'kibana/public';
|
||||
import { TimeSeriesResult } from '../types';
|
||||
export { TimeSeriesResult } from '../types';
|
||||
|
||||
const WATCHER_API_ROOT = '/api/watcher';
|
||||
|
||||
|
@ -60,20 +62,35 @@ export const loadIndexPatterns = async () => {
|
|||
return savedObjects;
|
||||
};
|
||||
|
||||
const TimeSeriesQueryRoute = '/api/alerting_builtins/index_threshold/_time_series_query';
|
||||
|
||||
interface GetThresholdAlertVisualizationDataParams {
|
||||
model: any;
|
||||
visualizeOptions: any;
|
||||
http: HttpSetup;
|
||||
}
|
||||
|
||||
export async function getThresholdAlertVisualizationData({
|
||||
model,
|
||||
visualizeOptions,
|
||||
http,
|
||||
}: {
|
||||
model: any;
|
||||
visualizeOptions: any;
|
||||
http: HttpSetup;
|
||||
}): Promise<Record<string, any>> {
|
||||
const { visualizeData } = await http.post(`${WATCHER_API_ROOT}/watch/visualize`, {
|
||||
body: JSON.stringify({
|
||||
watch: model,
|
||||
options: visualizeOptions,
|
||||
}),
|
||||
}: GetThresholdAlertVisualizationDataParams): Promise<TimeSeriesResult> {
|
||||
const timeSeriesQueryParams = {
|
||||
index: model.index,
|
||||
timeField: model.timeField,
|
||||
aggType: model.aggType,
|
||||
aggField: model.aggField,
|
||||
groupBy: model.groupBy,
|
||||
termField: model.termField,
|
||||
termSize: model.termSize,
|
||||
timeWindowSize: model.timeWindowSize,
|
||||
timeWindowUnit: model.timeWindowUnit,
|
||||
dateStart: new Date(visualizeOptions.rangeFrom).toISOString(),
|
||||
dateEnd: new Date(visualizeOptions.rangeTo).toISOString(),
|
||||
interval: visualizeOptions.interval,
|
||||
};
|
||||
|
||||
return await http.post<TimeSeriesResult>(TimeSeriesQueryRoute, {
|
||||
body: JSON.stringify(timeSeriesQueryParams),
|
||||
});
|
||||
return visualizeData;
|
||||
}
|
||||
|
|
|
@ -1,123 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval';
|
||||
|
||||
describe('calcAutoIntervalNear', () => {
|
||||
test('1h/0 buckets = 0ms buckets', () => {
|
||||
const interval = calcAutoIntervalNear(0, Number(moment.duration(1, 'h')));
|
||||
expect(interval.asMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
test('undefined/100 buckets = 0ms buckets', () => {
|
||||
const interval = calcAutoIntervalNear(0, undefined as any);
|
||||
expect(interval.asMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
test('1ms/100 buckets = 1ms buckets', () => {
|
||||
const interval = calcAutoIntervalNear(100, Number(moment.duration(1, 'ms')));
|
||||
expect(interval.asMilliseconds()).toBe(1);
|
||||
});
|
||||
|
||||
test('200ms/100 buckets = 2ms buckets', () => {
|
||||
const interval = calcAutoIntervalNear(100, Number(moment.duration(200, 'ms')));
|
||||
expect(interval.asMilliseconds()).toBe(2);
|
||||
});
|
||||
|
||||
test('1s/1000 buckets = 1ms buckets', () => {
|
||||
const interval = calcAutoIntervalNear(1000, Number(moment.duration(1, 's')));
|
||||
expect(interval.asMilliseconds()).toBe(1);
|
||||
});
|
||||
|
||||
test('1000h/1000 buckets = 1h buckets', () => {
|
||||
const interval = calcAutoIntervalNear(1000, Number(moment.duration(1000, 'hours')));
|
||||
expect(interval.asHours()).toBe(1);
|
||||
});
|
||||
|
||||
test('1h/100 buckets = 30s buckets', () => {
|
||||
const interval = calcAutoIntervalNear(100, Number(moment.duration(1, 'hours')));
|
||||
expect(interval.asSeconds()).toBe(30);
|
||||
});
|
||||
|
||||
test('1d/25 buckets = 1h buckets', () => {
|
||||
const interval = calcAutoIntervalNear(25, Number(moment.duration(1, 'day')));
|
||||
expect(interval.asHours()).toBe(1);
|
||||
});
|
||||
|
||||
test('1y/1000 buckets = 12h buckets', () => {
|
||||
const interval = calcAutoIntervalNear(1000, Number(moment.duration(1, 'year')));
|
||||
expect(interval.asHours()).toBe(12);
|
||||
});
|
||||
|
||||
test('1y/10000 buckets = 1h buckets', () => {
|
||||
const interval = calcAutoIntervalNear(10000, Number(moment.duration(1, 'year')));
|
||||
expect(interval.asHours()).toBe(1);
|
||||
});
|
||||
|
||||
test('1y/100000 buckets = 5m buckets', () => {
|
||||
const interval = calcAutoIntervalNear(100000, Number(moment.duration(1, 'year')));
|
||||
expect(interval.asMinutes()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcAutoIntervalLessThan', () => {
|
||||
test('1h/0 buckets = 0ms buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(0, Number(moment.duration(1, 'h')));
|
||||
expect(interval.asMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
test('undefined/100 buckets = 0ms buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(0, undefined as any);
|
||||
expect(interval.asMilliseconds()).toBe(0);
|
||||
});
|
||||
|
||||
test('1ms/100 buckets = 1ms buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(100, Number(moment.duration(1, 'ms')));
|
||||
expect(interval.asMilliseconds()).toBe(1);
|
||||
});
|
||||
|
||||
test('200ms/100 buckets = 2ms buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(100, Number(moment.duration(200, 'ms')));
|
||||
expect(interval.asMilliseconds()).toBe(2);
|
||||
});
|
||||
|
||||
test('1s/1000 buckets = 1ms buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1, 's')));
|
||||
expect(interval.asMilliseconds()).toBe(1);
|
||||
});
|
||||
|
||||
test('1000h/1000 buckets = 1h buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1000, 'hours')));
|
||||
expect(interval.asHours()).toBe(1);
|
||||
});
|
||||
|
||||
test('1h/100 buckets = 30s buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(100, Number(moment.duration(1, 'hours')));
|
||||
expect(interval.asSeconds()).toBe(30);
|
||||
});
|
||||
|
||||
test('1d/25 buckets = 30m buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(25, Number(moment.duration(1, 'day')));
|
||||
expect(interval.asMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
test('1y/1000 buckets = 3h buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1, 'year')));
|
||||
expect(interval.asHours()).toBe(3);
|
||||
});
|
||||
|
||||
test('1y/10000 buckets = 30m buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(10000, Number(moment.duration(1, 'year')));
|
||||
expect(interval.asMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
test('1y/100000 buckets = 5m buckets', () => {
|
||||
const interval = calcAutoIntervalLessThan(100000, Number(moment.duration(1, 'year')));
|
||||
expect(interval.asMinutes()).toBe(5);
|
||||
});
|
||||
});
|
|
@ -1,132 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
const boundsDescending = [
|
||||
{
|
||||
bound: Infinity,
|
||||
interval: Number(moment.duration(1, 'year')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(1, 'year')),
|
||||
interval: Number(moment.duration(1, 'month')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(3, 'week')),
|
||||
interval: Number(moment.duration(1, 'week')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(1, 'week')),
|
||||
interval: Number(moment.duration(1, 'd')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(24, 'hour')),
|
||||
interval: Number(moment.duration(12, 'hour')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(6, 'hour')),
|
||||
interval: Number(moment.duration(3, 'hour')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(2, 'hour')),
|
||||
interval: Number(moment.duration(1, 'hour')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(45, 'minute')),
|
||||
interval: Number(moment.duration(30, 'minute')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(20, 'minute')),
|
||||
interval: Number(moment.duration(10, 'minute')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(9, 'minute')),
|
||||
interval: Number(moment.duration(5, 'minute')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(3, 'minute')),
|
||||
interval: Number(moment.duration(1, 'minute')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(45, 'second')),
|
||||
interval: Number(moment.duration(30, 'second')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(15, 'second')),
|
||||
interval: Number(moment.duration(10, 'second')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(7.5, 'second')),
|
||||
interval: Number(moment.duration(5, 'second')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(5, 'second')),
|
||||
interval: Number(moment.duration(1, 'second')),
|
||||
},
|
||||
{
|
||||
bound: Number(moment.duration(500, 'ms')),
|
||||
interval: Number(moment.duration(100, 'ms')),
|
||||
},
|
||||
];
|
||||
|
||||
function getPerBucketMs(count: number, duration: number) {
|
||||
const ms = duration / count;
|
||||
return isFinite(ms) ? ms : NaN;
|
||||
}
|
||||
|
||||
function normalizeMinimumInterval(targetMs: number) {
|
||||
const value = isNaN(targetMs) ? 0 : Math.max(Math.floor(targetMs), 1);
|
||||
return moment.duration(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Using some simple rules we pick a "pretty" interval that will
|
||||
* produce around the number of buckets desired given a time range.
|
||||
*
|
||||
* @param targetBucketCount desired number of buckets
|
||||
* @param duration time range the agg covers
|
||||
*/
|
||||
export function calcAutoIntervalNear(targetBucketCount: number, duration: number) {
|
||||
const targetPerBucketMs = getPerBucketMs(targetBucketCount, duration);
|
||||
|
||||
// Find the first bound which is smaller than our target.
|
||||
const lowerBoundIndex = boundsDescending.findIndex(({ bound }) => {
|
||||
const boundMs = Number(bound);
|
||||
return boundMs <= targetPerBucketMs;
|
||||
});
|
||||
|
||||
// The bound immediately preceeding that lower bound contains the
|
||||
// interval most closely matching our target.
|
||||
if (lowerBoundIndex !== -1) {
|
||||
const nearestInterval = boundsDescending[lowerBoundIndex - 1].interval;
|
||||
return moment.duration(nearestInterval);
|
||||
}
|
||||
|
||||
// If the target is smaller than any of our bounds, then we'll use it for the interval as-is.
|
||||
return normalizeMinimumInterval(targetPerBucketMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a "pretty" interval that produces no more than the maxBucketCount
|
||||
* for the given time range.
|
||||
*
|
||||
* @param maxBucketCount maximum number of buckets to create
|
||||
* @param duration amount of time covered by the agg
|
||||
*/
|
||||
export function calcAutoIntervalLessThan(maxBucketCount: number, duration: number) {
|
||||
const maxPerBucketMs = getPerBucketMs(maxBucketCount, duration);
|
||||
|
||||
for (const { interval } of boundsDescending) {
|
||||
// Find the highest interval which meets our per bucket limitation.
|
||||
if (interval <= maxPerBucketMs) {
|
||||
return moment.duration(interval);
|
||||
}
|
||||
}
|
||||
|
||||
// If the max is smaller than any of our intervals, then we'll use it for the interval as-is.
|
||||
return normalizeMinimumInterval(maxPerBucketMs);
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import dateMath from '@elastic/datemath';
|
||||
|
||||
import { parseEsInterval } from '../../../../../../../../../../src/plugins/data/public';
|
||||
|
||||
const unitsDesc = dateMath.unitsDesc;
|
||||
const largeMax = unitsDesc.indexOf('M');
|
||||
|
||||
/**
|
||||
* Convert a moment.duration into an es
|
||||
* compatible expression, and provide
|
||||
* associated metadata
|
||||
*
|
||||
* @param {moment.duration} duration
|
||||
* @return {object}
|
||||
*/
|
||||
export function convertDurationToNormalizedEsInterval(duration) {
|
||||
for (let i = 0; i < unitsDesc.length; i++) {
|
||||
const unit = unitsDesc[i];
|
||||
const val = duration.as(unit);
|
||||
// find a unit that rounds neatly
|
||||
if (val >= 1 && Math.floor(val) === val) {
|
||||
// if the unit is "large", like years, but
|
||||
// isn't set to 1 ES will puke. So keep going until
|
||||
// we get out of the "large" units
|
||||
if (i <= largeMax && val !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
value: val,
|
||||
unit: unit,
|
||||
expression: val + unit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ms = duration.as('ms');
|
||||
return {
|
||||
value: ms,
|
||||
unit: 'ms',
|
||||
expression: ms + 'ms',
|
||||
};
|
||||
}
|
||||
|
||||
export function convertIntervalToEsInterval(interval) {
|
||||
const { value, unit } = parseEsInterval(interval);
|
||||
return {
|
||||
value,
|
||||
unit,
|
||||
expression: interval,
|
||||
};
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export const TimeBuckets: any;
|
|
@ -1,397 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval';
|
||||
import {
|
||||
convertDurationToNormalizedEsInterval,
|
||||
convertIntervalToEsInterval,
|
||||
} from './calc_es_interval';
|
||||
import { fieldFormats, parseInterval } from '../../../../../../../../../../src/plugins/data/public';
|
||||
|
||||
function isValidMoment(m) {
|
||||
return m && 'isValid' in m && m.isValid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for wrapping the concept of an "Interval",
|
||||
* which describes a timespan that will separate moments.
|
||||
*
|
||||
* @param {state} object - one of ""
|
||||
* @param {[type]} display [description]
|
||||
*/
|
||||
function TimeBuckets(uiSettings, dataFieldsFormats) {
|
||||
this.uiSettings = uiSettings;
|
||||
this.dataFieldsFormats = dataFieldsFormats;
|
||||
return TimeBuckets.__cached__(this);
|
||||
}
|
||||
|
||||
/****
|
||||
* PUBLIC API
|
||||
****/
|
||||
|
||||
/**
|
||||
* Set the bounds that these buckets are expected to cover.
|
||||
* This is required to support interval "auto" as well
|
||||
* as interval scaling.
|
||||
*
|
||||
* @param {object} input - an object with properties min and max,
|
||||
* representing the edges for the time span
|
||||
* we should cover
|
||||
*
|
||||
* @returns {undefined}
|
||||
*/
|
||||
TimeBuckets.prototype.setBounds = function(input) {
|
||||
if (!input) return this.clearBounds();
|
||||
|
||||
let bounds;
|
||||
if (_.isPlainObject(input)) {
|
||||
// accept the response from timefilter.getActiveBounds()
|
||||
bounds = [input.min, input.max];
|
||||
} else {
|
||||
bounds = Array.isArray(input) ? input : [];
|
||||
}
|
||||
|
||||
const moments = _(bounds)
|
||||
.map(_.ary(moment, 1))
|
||||
.sortBy(Number);
|
||||
|
||||
const valid = moments.size() === 2 && moments.every(isValidMoment);
|
||||
if (!valid) {
|
||||
this.clearBounds();
|
||||
throw new Error('invalid bounds set: ' + input);
|
||||
}
|
||||
|
||||
this._lb = moments.shift();
|
||||
this._ub = moments.pop();
|
||||
if (this.getDuration().asSeconds() < 0) {
|
||||
throw new TypeError('Intervals must be positive');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the stored bounds
|
||||
*
|
||||
* @return {undefined}
|
||||
*/
|
||||
TimeBuckets.prototype.clearBounds = function() {
|
||||
this._lb = this._ub = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check to see if we have received bounds yet
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
TimeBuckets.prototype.hasBounds = function() {
|
||||
return isValidMoment(this._ub) && isValidMoment(this._lb);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the current bounds, if we have any.
|
||||
*
|
||||
* THIS DOES NOT CLONE THE BOUNDS, so editing them
|
||||
* may have unexpected side-effects. Always
|
||||
* call bounds.min.clone() before editing
|
||||
*
|
||||
* @return {object|undefined} - If bounds are not defined, this
|
||||
* returns undefined, else it returns the bounds
|
||||
* for these buckets. This object has two props,
|
||||
* min and max. Each property will be a moment()
|
||||
* object
|
||||
*
|
||||
*/
|
||||
TimeBuckets.prototype.getBounds = function() {
|
||||
if (!this.hasBounds()) return;
|
||||
return {
|
||||
min: this._lb,
|
||||
max: this._ub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a moment duration object representing
|
||||
* the distance between the bounds, if the bounds
|
||||
* are set.
|
||||
*
|
||||
* @return {moment.duration|undefined}
|
||||
*/
|
||||
TimeBuckets.prototype.getDuration = function() {
|
||||
if (!this.hasBounds()) return;
|
||||
return moment.duration(this._ub - this._lb, 'ms');
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the interval at which buckets should be
|
||||
* generated.
|
||||
*
|
||||
* Input can be one of the following:
|
||||
* - Any object from src/legacy/ui/agg_types/buckets/_interval_options.js
|
||||
* - "auto"
|
||||
* - Pass a valid moment unit
|
||||
* - a moment.duration object.
|
||||
*
|
||||
* @param {object|string|moment.duration} input - see desc
|
||||
*/
|
||||
TimeBuckets.prototype.setInterval = function(input) {
|
||||
// Preserve the original units because they're lost when the interval is converted to a
|
||||
// moment duration object.
|
||||
this.originalInterval = input;
|
||||
|
||||
let interval = input;
|
||||
|
||||
// selection object -> val
|
||||
if (_.isObject(input)) {
|
||||
interval = input.val;
|
||||
}
|
||||
|
||||
if (!interval || interval === 'auto') {
|
||||
this._i = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
if (_.isString(interval)) {
|
||||
input = interval;
|
||||
interval = parseInterval(interval);
|
||||
if (+interval === 0) {
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// if the value wasn't converted to a duration, and isn't
|
||||
// already a duration, we have a problem
|
||||
if (!moment.isDuration(interval)) {
|
||||
throw new TypeError('"' + input + '" is not a valid interval.');
|
||||
}
|
||||
|
||||
this._i = interval;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the interval for the buckets. If the
|
||||
* number of buckets created by the interval set
|
||||
* is larger than config:histogram:maxBars then the
|
||||
* interval will be scaled up. If the number of buckets
|
||||
* created is less than one, the interval is scaled back.
|
||||
*
|
||||
* The interval object returned is a moment.duration
|
||||
* object that has been decorated with the following
|
||||
* properties.
|
||||
*
|
||||
* interval.description: a text description of the interval.
|
||||
* designed to be used list "field per {{ desc }}".
|
||||
* - "minute"
|
||||
* - "10 days"
|
||||
* - "3 years"
|
||||
*
|
||||
* interval.expr: the elasticsearch expression that creates this
|
||||
* interval. If the interval does not properly form an elasticsearch
|
||||
* expression it will be forced into one.
|
||||
*
|
||||
* interval.scaled: the interval was adjusted to
|
||||
* accommodate the maxBars setting.
|
||||
*
|
||||
* interval.scale: the number that y-values should be
|
||||
* multiplied by
|
||||
*
|
||||
* interval.scaleDescription: a description that reflects
|
||||
* the values which will be produced by using the
|
||||
* interval.scale.
|
||||
*
|
||||
*
|
||||
* @return {[type]} [description]
|
||||
*/
|
||||
TimeBuckets.prototype.getInterval = function(useNormalizedEsInterval = true) {
|
||||
const self = this;
|
||||
const duration = self.getDuration();
|
||||
const parsedInterval = readInterval();
|
||||
|
||||
if (useNormalizedEsInterval) {
|
||||
return decorateInterval(maybeScaleInterval(parsedInterval));
|
||||
} else {
|
||||
return decorateInterval(parsedInterval);
|
||||
}
|
||||
|
||||
// either pull the interval from state or calculate the auto-interval
|
||||
function readInterval() {
|
||||
const interval = self._i;
|
||||
if (moment.isDuration(interval)) return interval;
|
||||
return calcAutoIntervalNear(self.uiSettings.get('histogram:barTarget'), Number(duration));
|
||||
}
|
||||
|
||||
// check to see if the interval should be scaled, and scale it if so
|
||||
function maybeScaleInterval(interval) {
|
||||
if (!self.hasBounds()) return interval;
|
||||
|
||||
const maxLength = self.uiSettings.get('histogram:maxBars');
|
||||
const approxLen = duration / interval;
|
||||
let scaled;
|
||||
|
||||
if (approxLen > maxLength) {
|
||||
scaled = calcAutoIntervalLessThan(maxLength, Number(duration));
|
||||
} else {
|
||||
return interval;
|
||||
}
|
||||
|
||||
if (+scaled === +interval) return interval;
|
||||
|
||||
decorateInterval(interval);
|
||||
return _.assign(scaled, {
|
||||
preScaled: interval,
|
||||
scale: interval / scaled,
|
||||
scaled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// append some TimeBuckets specific props to the interval
|
||||
function decorateInterval(interval) {
|
||||
const esInterval = useNormalizedEsInterval
|
||||
? convertDurationToNormalizedEsInterval(interval)
|
||||
: convertIntervalToEsInterval(self.originalInterval);
|
||||
interval.esValue = esInterval.value;
|
||||
interval.esUnit = esInterval.unit;
|
||||
interval.expression = esInterval.expression;
|
||||
interval.overflow = duration > interval ? moment.duration(interval - duration) : false;
|
||||
|
||||
const prettyUnits = moment.normalizeUnits(esInterval.unit);
|
||||
if (esInterval.value === 1) {
|
||||
interval.description = prettyUnits;
|
||||
} else {
|
||||
interval.description = esInterval.value + ' ' + prettyUnits + 's';
|
||||
}
|
||||
|
||||
return interval;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a date format string that will represent dates that
|
||||
* progress at our interval.
|
||||
*
|
||||
* Since our interval can be as small as 1ms, the default
|
||||
* date format is usually way too much. with `dateFormat:scaled`
|
||||
* users can modify how dates are formatted within series
|
||||
* produced by TimeBuckets
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
TimeBuckets.prototype.getScaledDateFormat = function() {
|
||||
const interval = this.getInterval();
|
||||
const rules = this.uiSettings.get('dateFormat:scaled');
|
||||
|
||||
for (let i = rules.length - 1; i >= 0; i--) {
|
||||
const rule = rules[i];
|
||||
if (!rule[0] || interval >= moment.duration(rule[0])) {
|
||||
return rule[1];
|
||||
}
|
||||
}
|
||||
|
||||
return this.uiSettings.get('dateFormat');
|
||||
};
|
||||
|
||||
TimeBuckets.prototype.getScaledDateFormatter = function() {
|
||||
const fieldFormatsService = this.dataFieldsFormats;
|
||||
const DateFieldFormat = fieldFormatsService.getType(fieldFormats.FIELD_FORMAT_IDS.DATE);
|
||||
|
||||
return new DateFieldFormat(
|
||||
{
|
||||
pattern: this.getScaledDateFormat(),
|
||||
},
|
||||
configPath => this.uiSettings.get(configPath)
|
||||
);
|
||||
};
|
||||
|
||||
TimeBuckets.__cached__ = function(self) {
|
||||
let cache = {};
|
||||
const sameMoment = same(moment.isMoment);
|
||||
const sameDuration = same(moment.isDuration);
|
||||
|
||||
const desc = {
|
||||
__cached__: {
|
||||
value: self,
|
||||
},
|
||||
};
|
||||
|
||||
const breakers = {
|
||||
setBounds: 'bounds',
|
||||
clearBounds: 'bounds',
|
||||
setInterval: 'interval',
|
||||
};
|
||||
|
||||
const resources = {
|
||||
bounds: {
|
||||
setup: function() {
|
||||
return [self._lb, self._ub];
|
||||
},
|
||||
changes: function(prev) {
|
||||
return !sameMoment(prev[0], self._lb) || !sameMoment(prev[1], self._ub);
|
||||
},
|
||||
},
|
||||
interval: {
|
||||
setup: function() {
|
||||
return self._i;
|
||||
},
|
||||
changes: function(prev) {
|
||||
return !sameDuration(prev, this._i);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function cachedGetter(prop) {
|
||||
return {
|
||||
value: function cachedGetter(...rest) {
|
||||
if (cache.hasOwnProperty(prop)) {
|
||||
return cache[prop];
|
||||
}
|
||||
|
||||
return (cache[prop] = self[prop](...rest));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cacheBreaker(prop) {
|
||||
const resource = resources[breakers[prop]];
|
||||
const setup = resource.setup;
|
||||
const changes = resource.changes;
|
||||
const fn = self[prop];
|
||||
|
||||
return {
|
||||
value: function cacheBreaker() {
|
||||
const prev = setup.call(self);
|
||||
const ret = fn.apply(self, arguments);
|
||||
|
||||
if (changes.call(self, prev)) {
|
||||
cache = {};
|
||||
}
|
||||
|
||||
return ret;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function same(checkType) {
|
||||
return function(a, b) {
|
||||
if (a === b) return true;
|
||||
if (checkType(a) === checkType(b)) return +a === +b;
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
_.forOwn(TimeBuckets.prototype, function(fn, prop) {
|
||||
if (prop[0] === '_') return;
|
||||
|
||||
if (breakers.hasOwnProperty(prop)) {
|
||||
desc[prop] = cacheBreaker(prop);
|
||||
} else {
|
||||
desc[prop] = cachedGetter(prop);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.create(self, desc);
|
||||
};
|
||||
|
||||
export { TimeBuckets };
|
|
@ -4,6 +4,12 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export {
|
||||
TimeSeriesResult,
|
||||
TimeSeriesResultRow,
|
||||
MetricResult,
|
||||
} from '../../../../../../alerting_builtins/common/alert_types/index_threshold';
|
||||
|
||||
export interface Comparator {
|
||||
text: string;
|
||||
value: string;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { IUiSettingsClient } from 'kibana/public';
|
||||
import { IUiSettingsClient, HttpSetup } from 'kibana/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
AnnotationDomainTypes,
|
||||
|
@ -18,17 +18,16 @@ import {
|
|||
Position,
|
||||
ScaleType,
|
||||
Settings,
|
||||
niceTimeFormatter,
|
||||
} from '@elastic/charts';
|
||||
import dateMath from '@elastic/datemath';
|
||||
import moment from 'moment-timezone';
|
||||
import { EuiCallOut, EuiLoadingChart, EuiSpacer, EuiEmptyPrompt, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { getThresholdAlertVisualizationData } from './lib/api';
|
||||
import { AggregationType, Comparator } from '../../../../common/types';
|
||||
/* TODO: This file was copied from ui/time_buckets for NP migration. We should clean this up and add TS support */
|
||||
import { TimeBuckets } from './lib/time_buckets';
|
||||
import { AlertsContextValue } from '../../../context/alerts_context';
|
||||
import { IndexThresholdAlertParams } from './types';
|
||||
import { parseDuration } from '../../../../../../alerting/common/parse_duration';
|
||||
|
||||
const customTheme = () => {
|
||||
return {
|
||||
|
@ -60,35 +59,26 @@ const getTimezone = (uiSettings: IUiSettingsClient) => {
|
|||
return tzOffset;
|
||||
};
|
||||
|
||||
const getDomain = (alertParams: any) => {
|
||||
const VISUALIZE_TIME_WINDOW_MULTIPLIER = 5;
|
||||
const fromExpression = `now-${alertParams.timeWindowSize * VISUALIZE_TIME_WINDOW_MULTIPLIER}${
|
||||
alertParams.timeWindowUnit
|
||||
}`;
|
||||
const toExpression = 'now';
|
||||
const fromMoment = dateMath.parse(fromExpression);
|
||||
const toMoment = dateMath.parse(toExpression);
|
||||
const visualizeTimeWindowFrom = fromMoment ? fromMoment.valueOf() : 0;
|
||||
const visualizeTimeWindowTo = toMoment ? toMoment.valueOf() : 0;
|
||||
return {
|
||||
min: visualizeTimeWindowFrom,
|
||||
max: visualizeTimeWindowTo,
|
||||
};
|
||||
};
|
||||
const getDomain = (alertInterval: string) => {
|
||||
const VISUALIZE_INTERVALS = 30;
|
||||
let intervalMillis: number;
|
||||
|
||||
const getTimeBuckets = (
|
||||
uiSettings: IUiSettingsClient,
|
||||
dataFieldsFormats: any,
|
||||
alertParams: any
|
||||
) => {
|
||||
const domain = getDomain(alertParams);
|
||||
const timeBuckets = new TimeBuckets(uiSettings, dataFieldsFormats);
|
||||
timeBuckets.setBounds(domain);
|
||||
return timeBuckets;
|
||||
try {
|
||||
intervalMillis = parseDuration(alertInterval);
|
||||
} catch (err) {
|
||||
intervalMillis = 1000 * 60; // default to one minute if not parseable
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
min: now - intervalMillis * VISUALIZE_INTERVALS,
|
||||
max: now,
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
alertParams: IndexThresholdAlertParams;
|
||||
alertInterval: string;
|
||||
aggregationTypes: { [key: string]: AggregationType };
|
||||
comparators: {
|
||||
[key: string]: Comparator;
|
||||
|
@ -96,8 +86,10 @@ interface Props {
|
|||
alertsContext: AlertsContextValue;
|
||||
}
|
||||
|
||||
type MetricResult = [number, number]; // [epochMillis, value]
|
||||
export const ThresholdVisualization: React.FunctionComponent<Props> = ({
|
||||
alertParams,
|
||||
alertInterval,
|
||||
aggregationTypes,
|
||||
comparators,
|
||||
alertsContext,
|
||||
|
@ -119,18 +111,14 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
|
|||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<undefined | any>(undefined);
|
||||
const [visualizationData, setVisualizationData] = useState<Record<string, any>>([]);
|
||||
const [visualizationData, setVisualizationData] = useState<Record<string, MetricResult[]>>();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setVisualizationData(
|
||||
await getThresholdAlertVisualizationData({
|
||||
model: alertWithoutActions,
|
||||
visualizeOptions,
|
||||
http,
|
||||
})
|
||||
await getVisualizationData(alertWithoutActions, visualizeOptions, http)
|
||||
);
|
||||
} catch (e) {
|
||||
if (toastNotifications) {
|
||||
|
@ -167,15 +155,11 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
|
|||
}
|
||||
const chartsTheme = charts.theme.useChartsTheme();
|
||||
|
||||
const domain = getDomain(alertParams);
|
||||
const timeBuckets = new TimeBuckets(uiSettings, dataFieldsFormats);
|
||||
timeBuckets.setBounds(domain);
|
||||
const interval = timeBuckets.getInterval().expression;
|
||||
const domain = getDomain(alertInterval);
|
||||
const visualizeOptions = {
|
||||
rangeFrom: domain.min,
|
||||
rangeTo: domain.max,
|
||||
interval,
|
||||
timezone: getTimezone(uiSettings),
|
||||
rangeFrom: new Date(domain.min).toISOString(),
|
||||
rangeTo: new Date(domain.max).toISOString(),
|
||||
interval: alertInterval,
|
||||
};
|
||||
|
||||
// Fetching visualization data is independent of alert actions
|
||||
|
@ -237,11 +221,7 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
|
|||
}
|
||||
});
|
||||
});
|
||||
const dateFormatter = (d: number) => {
|
||||
return moment(d)
|
||||
.tz(timezone)
|
||||
.format(getTimeBuckets(uiSettings, dataFieldsFormats, alertParams).getScaledDateFormat());
|
||||
};
|
||||
const dateFormatter = niceTimeFormatter([domain.min, domain.max]);
|
||||
const aggLabel = aggregationTypes[aggType].text;
|
||||
return (
|
||||
<div data-test-subj="alertVisualizationChart">
|
||||
|
@ -316,3 +296,22 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({
|
|||
|
||||
return null;
|
||||
};
|
||||
|
||||
// convert the data from the visualization API into something easier to digest with charts
|
||||
async function getVisualizationData(model: any, visualizeOptions: any, http: HttpSetup) {
|
||||
const vizData = await getThresholdAlertVisualizationData({
|
||||
model,
|
||||
visualizeOptions,
|
||||
http,
|
||||
});
|
||||
const result: Record<string, Array<[number, number]>> = {};
|
||||
|
||||
for (const groupMetrics of vizData.results) {
|
||||
result[groupMetrics.group] = groupMetrics.metrics.map(metricResult => [
|
||||
Date.parse(metricResult[0]),
|
||||
metricResult[1],
|
||||
]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@ export const AlertForm = ({
|
|||
{AlertParamsExpressionComponent ? (
|
||||
<AlertParamsExpressionComponent
|
||||
alertParams={alert.params}
|
||||
alertInterval={`${alertInterval ?? 1}${alertIntervalUnit}`}
|
||||
errors={errors}
|
||||
setAlertParams={setAlertParams}
|
||||
setAlertProperty={setAlertProperty}
|
||||
|
|
Loading…
Reference in a new issue