mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ML] Explain log rate spikes: UI Part 1 (#135948)
* wip: create initial use full data and log spike table and histogram chart * wip: adds timepicker and use full data complete functionality * ensure time selectin persists in url * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * add impact and score bar to table * fix filename typo * add lazy component wrapper, fix translation naming * update type names * remove duplicate code * update error type * rename overall stats hook to doccountstats hook Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
8acc1466f8
commit
4ad614c578
37 changed files with 2732 additions and 25 deletions
184
x-pack/plugins/aiops/common/error_utils.ts
Normal file
184
x-pack/plugins/aiops/common/error_utils.ts
Normal file
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* 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 { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import Boom from '@hapi/boom';
|
||||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
|
||||
export interface WrappedError {
|
||||
body: {
|
||||
attributes: {
|
||||
body: EsErrorBody;
|
||||
};
|
||||
message: Boom.Boom;
|
||||
};
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
export interface EsErrorRootCause {
|
||||
type: string;
|
||||
reason: string;
|
||||
caused_by?: EsErrorRootCause;
|
||||
script?: string;
|
||||
}
|
||||
|
||||
export interface EsErrorBody {
|
||||
error: {
|
||||
root_cause?: EsErrorRootCause[];
|
||||
caused_by?: EsErrorRootCause;
|
||||
type: string;
|
||||
reason: string;
|
||||
};
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface AiOpsResponseError {
|
||||
statusCode: number;
|
||||
error: string;
|
||||
message: string;
|
||||
attributes?: {
|
||||
body: EsErrorBody;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AiOpsErrorObject {
|
||||
causedBy?: string;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
fullError?: EsErrorBody;
|
||||
}
|
||||
|
||||
export interface AiOpsHttpFetchError<T> extends IHttpFetchError {
|
||||
body: T;
|
||||
}
|
||||
|
||||
export type ErrorType =
|
||||
| WrappedError
|
||||
| AiOpsHttpFetchError<AiOpsResponseError>
|
||||
| EsErrorBody
|
||||
| Boom.Boom
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
export function isEsErrorBody(error: any): error is EsErrorBody {
|
||||
return error && error.error?.reason !== undefined;
|
||||
}
|
||||
|
||||
export function isErrorString(error: any): error is string {
|
||||
return typeof error === 'string';
|
||||
}
|
||||
|
||||
export function isErrorMessage(error: any): error is ErrorMessage {
|
||||
return error && error.message !== undefined && typeof error.message === 'string';
|
||||
}
|
||||
|
||||
export function isAiOpsResponseError(error: any): error is AiOpsResponseError {
|
||||
return typeof error.body === 'object' && 'message' in error.body;
|
||||
}
|
||||
|
||||
export function isBoomError(error: any): error is Boom.Boom {
|
||||
return error?.isBoom === true;
|
||||
}
|
||||
|
||||
export function isWrappedError(error: any): error is WrappedError {
|
||||
return error && isBoomError(error.body?.message) === true;
|
||||
}
|
||||
|
||||
export const extractErrorProperties = (error: ErrorType): AiOpsErrorObject => {
|
||||
// extract properties of the error object from within the response error
|
||||
// coming from Kibana, Elasticsearch, and our own AiOps messages
|
||||
|
||||
// some responses contain raw es errors as part of a bulk response
|
||||
// e.g. if some jobs fail the action in a bulk request
|
||||
|
||||
if (isEsErrorBody(error)) {
|
||||
return {
|
||||
message: error.error.reason,
|
||||
statusCode: error.status,
|
||||
fullError: error,
|
||||
};
|
||||
}
|
||||
|
||||
if (isErrorString(error)) {
|
||||
return {
|
||||
message: error,
|
||||
};
|
||||
}
|
||||
if (isWrappedError(error)) {
|
||||
return error.body.message?.output?.payload;
|
||||
}
|
||||
|
||||
if (isBoomError(error)) {
|
||||
return {
|
||||
message: error.output.payload.message,
|
||||
statusCode: error.output.payload.statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
if (error?.body === undefined && !error?.message) {
|
||||
return {
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof error.body === 'string') {
|
||||
return {
|
||||
message: error.body,
|
||||
};
|
||||
}
|
||||
|
||||
if (isAiOpsResponseError(error)) {
|
||||
if (
|
||||
typeof error.body.attributes === 'object' &&
|
||||
typeof error.body.attributes.body?.error?.reason === 'string'
|
||||
) {
|
||||
const errObj: AiOpsErrorObject = {
|
||||
message: error.body.attributes.body.error.reason,
|
||||
statusCode: error.body.statusCode,
|
||||
fullError: error.body.attributes.body,
|
||||
};
|
||||
if (
|
||||
typeof error.body.attributes.body.error.caused_by === 'object' &&
|
||||
(typeof error.body.attributes.body.error.caused_by?.reason === 'string' ||
|
||||
typeof error.body.attributes.body.error.caused_by?.caused_by?.reason === 'string')
|
||||
) {
|
||||
errObj.causedBy =
|
||||
error.body.attributes.body.error.caused_by?.caused_by?.reason ||
|
||||
error.body.attributes.body.error.caused_by?.reason;
|
||||
}
|
||||
if (
|
||||
Array.isArray(error.body.attributes.body.error.root_cause) &&
|
||||
typeof error.body.attributes.body.error.root_cause[0] === 'object' &&
|
||||
isPopulatedObject(error.body.attributes.body.error.root_cause[0], ['script'])
|
||||
) {
|
||||
errObj.causedBy = error.body.attributes.body.error.root_cause[0].script;
|
||||
errObj.message += `: '${error.body.attributes.body.error.root_cause[0].script}'`;
|
||||
}
|
||||
return errObj;
|
||||
} else {
|
||||
return {
|
||||
message: error.body.message,
|
||||
statusCode: error.body.statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isErrorMessage(error)) {
|
||||
return {
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
// If all else fail return an empty message instead of JSON.stringify
|
||||
return {
|
||||
message: '',
|
||||
};
|
||||
};
|
65
x-pack/plugins/aiops/common/parse_interval.ts
Normal file
65
x-pack/plugins/aiops/common/parse_interval.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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 { duration, Duration, unitOfTime } from 'moment';
|
||||
import dateMath from '@kbn/datemath';
|
||||
|
||||
type SupportedUnits = unitOfTime.Base;
|
||||
|
||||
// Assume interval is in the form (value)(unit), such as "1h"
|
||||
const INTERVAL_STRING_RE = new RegExp('^([0-9]*)\\s*(' + dateMath.units.join('|') + ')$');
|
||||
|
||||
// moment.js is only designed to allow fractional values between 0 and 1
|
||||
// for units of hour or less.
|
||||
const SUPPORT_ZERO_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h'];
|
||||
|
||||
// List of time units which are supported for use in Elasticsearch durations
|
||||
// (such as anomaly detection job bucket spans)
|
||||
// See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units
|
||||
const SUPPORT_ES_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h', 'd'];
|
||||
|
||||
// Parses an interval String, such as 7d, 1h or 30m to a moment duration.
|
||||
// Optionally carries out an additional check that the interval is supported as a
|
||||
// time unit by Elasticsearch, as units greater than 'd' for example cannot be used
|
||||
// for anomaly detection job bucket spans.
|
||||
// Differs from the Kibana ui/utils/parse_interval in the following ways:
|
||||
// 1. A value-less interval such as 'm' is not allowed - in line with the ML back-end
|
||||
// not accepting such interval Strings for the bucket span of a job.
|
||||
// 2. Zero length durations 0ms, 0s, 0m and 0h are accepted as-is.
|
||||
// Note that when adding or subtracting fractional durations, moment is only designed
|
||||
// to work with units less than 'day'.
|
||||
// 3. Fractional intervals e.g. 1.5h or 4.5d are not allowed, in line with the behaviour
|
||||
// of the Elasticsearch date histogram aggregation.
|
||||
export function parseInterval(
|
||||
interval: string | number,
|
||||
checkValidEsUnit = false
|
||||
): Duration | null {
|
||||
const matches = String(interval).trim().match(INTERVAL_STRING_RE);
|
||||
if (!Array.isArray(matches) || matches.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = parseInt(matches[1], 10);
|
||||
const unit = matches[2] as SupportedUnits;
|
||||
|
||||
// In line with moment.js, only allow zero value intervals when the unit is less than 'day'.
|
||||
// And check for isNaN as e.g. valueless 'm' will pass the regex test,
|
||||
// plus an optional check that the unit is not w/M/y which are not fully supported by ES.
|
||||
if (
|
||||
isNaN(value) ||
|
||||
(value < 1 && SUPPORT_ZERO_DURATION_UNITS.indexOf(unit) === -1) ||
|
||||
(checkValidEsUnit === true && SUPPORT_ES_DURATION_UNITS.indexOf(unit) === -1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return duration(value, unit);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
45
x-pack/plugins/aiops/common/time_buckets.d.ts
vendored
Normal file
45
x-pack/plugins/aiops/common/time_buckets.d.ts
vendored
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { Moment } from 'moment';
|
||||
|
||||
export interface TimeRangeBounds {
|
||||
min?: Moment;
|
||||
max?: Moment;
|
||||
}
|
||||
|
||||
export declare interface TimeBucketsInterval {
|
||||
asMilliseconds: () => number;
|
||||
asSeconds: () => number;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface TimeBucketsConfig {
|
||||
'histogram:maxBars': number;
|
||||
'histogram:barTarget': number;
|
||||
dateFormat: string;
|
||||
'dateFormat:scaled': string[][];
|
||||
}
|
||||
|
||||
export declare class TimeBuckets {
|
||||
constructor(timeBucketsConfig: TimeBucketsConfig);
|
||||
public setBarTarget(barTarget: number): void;
|
||||
public setMaxBars(maxBars: number): void;
|
||||
public setInterval(interval: string): void;
|
||||
public setBounds(bounds: TimeRangeBounds): void;
|
||||
public getBounds(): { min: Moment; max: Moment };
|
||||
public getInterval(): TimeBucketsInterval;
|
||||
public getScaledDateFormat(): string;
|
||||
}
|
||||
|
||||
export declare function getTimeBucketsFromCache(): InstanceType<typeof TimeBuckets>;
|
||||
|
||||
export declare function getBoundsRoundedToInterval(
|
||||
bounds: TimeRangeBounds,
|
||||
interval: TimeBucketsInterval,
|
||||
inclusiveEnd?: boolean
|
||||
): Required<TimeRangeBounds>;
|
516
x-pack/plugins/aiops/common/time_buckets.js
Normal file
516
x-pack/plugins/aiops/common/time_buckets.js
Normal file
|
@ -0,0 +1,516 @@
|
|||
/*
|
||||
* 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 { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
|
||||
import { UI_SETTINGS } from '@kbn/data-plugin/common';
|
||||
import { ary, assign, isPlainObject, isString, sortBy } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import dateMath from '@kbn/datemath';
|
||||
import { parseInterval } from './parse_interval';
|
||||
|
||||
const { duration: d } = moment;
|
||||
|
||||
export function timeBucketsCalcAutoIntervalProvider() {
|
||||
// Note there is a current issue with Kibana (Kibana issue #9184)
|
||||
// which means we can't round to, for example, 2 week or 3 week buckets,
|
||||
// so there is a large gap between the 1 week and 1 month rule.
|
||||
const roundingRules = [
|
||||
[d(500, 'ms'), d(100, 'ms')],
|
||||
[d(5, 'second'), d(1, 'second')],
|
||||
[d(10, 'second'), d(5, 'second')],
|
||||
[d(15, 'second'), d(10, 'second')],
|
||||
[d(30, 'second'), d(15, 'second')],
|
||||
[d(1, 'minute'), d(30, 'second')],
|
||||
[d(5, 'minute'), d(1, 'minute')],
|
||||
[d(10, 'minute'), d(5, 'minute')],
|
||||
[d(15, 'minute'), d(10, 'minute')],
|
||||
[d(30, 'minute'), d(10, 'minute')],
|
||||
[d(1, 'hour'), d(30, 'minute')],
|
||||
[d(2, 'hour'), d(1, 'hour')],
|
||||
[d(4, 'hour'), d(2, 'hour')],
|
||||
[d(6, 'hour'), d(4, 'hour')],
|
||||
[d(8, 'hour'), d(6, 'hour')],
|
||||
[d(12, 'hour'), d(8, 'hour')],
|
||||
[d(24, 'hour'), d(12, 'hour')],
|
||||
[d(2, 'd'), d(1, 'd')],
|
||||
[d(4, 'd'), d(2, 'd')],
|
||||
[d(1, 'week'), d(4, 'd')],
|
||||
// [ d(2, 'week'), d(1, 'week') ],
|
||||
// [ d(1, 'month'), d(2, 'week') ],
|
||||
[d(1, 'month'), d(1, 'week')],
|
||||
[d(1, 'year'), d(1, 'month')],
|
||||
[Infinity, d(1, 'year')],
|
||||
];
|
||||
|
||||
const revRoundingRules = roundingRules.slice(0).reverse();
|
||||
|
||||
function find(rules, check, last) {
|
||||
function pick(buckets, duration) {
|
||||
const target = duration / buckets;
|
||||
let lastResp;
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i];
|
||||
const resp = check(rule[0], rule[1], target);
|
||||
|
||||
if (resp == null) {
|
||||
if (!last) {
|
||||
continue;
|
||||
}
|
||||
if (lastResp) {
|
||||
return lastResp;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!last) {
|
||||
return resp;
|
||||
}
|
||||
lastResp = resp;
|
||||
}
|
||||
|
||||
// fallback to just a number of milliseconds, ensure ms is >= 1
|
||||
const ms = Math.max(Math.floor(target), 1);
|
||||
return moment.duration(ms, 'ms');
|
||||
}
|
||||
|
||||
return function (buckets, duration) {
|
||||
const interval = pick(buckets, duration);
|
||||
if (interval) {
|
||||
return moment.duration(interval._data);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
near: find(
|
||||
revRoundingRules,
|
||||
function near(upperBound, lowerBound, target) {
|
||||
// upperBound - first duration in rule
|
||||
// lowerBound - second duration in rule
|
||||
// target - target interval in milliseconds.
|
||||
if (upperBound > target) {
|
||||
if (upperBound === Infinity) {
|
||||
return lowerBound;
|
||||
}
|
||||
|
||||
const boundMs = upperBound.asMilliseconds();
|
||||
const intervalMs = lowerBound.asMilliseconds();
|
||||
const retInterval =
|
||||
Math.abs(boundMs - target) <= Math.abs(intervalMs) ? upperBound : lowerBound;
|
||||
return retInterval;
|
||||
}
|
||||
},
|
||||
true
|
||||
),
|
||||
|
||||
lessThan: find(revRoundingRules, function (upperBound, lowerBound, target) {
|
||||
// upperBound - first duration in rule
|
||||
// lowerBound - second duration in rule
|
||||
// target - target interval in milliseconds. Must not return intervals less than this duration.
|
||||
if (lowerBound < target) {
|
||||
return upperBound !== Infinity ? upperBound : lowerBound;
|
||||
}
|
||||
}),
|
||||
|
||||
atLeast: find(revRoundingRules, function atLeast(upperBound, lowerBound, target) {
|
||||
// Unmodified from Kibana ui/time_buckets/calc_auto_interval.js.
|
||||
if (lowerBound <= target) {
|
||||
return lowerBound;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const unitsDesc = dateMath.unitsDesc;
|
||||
|
||||
// Index of the list of time interval units at which larger units (i.e. weeks, months, years) need
|
||||
// need to be converted to multiples of the largest unit supported in ES aggregation intervals (i.e. days).
|
||||
// Note that similarly the largest interval supported for ML bucket spans is 'd'.
|
||||
const timeUnitsMaxSupportedIndex = unitsDesc.indexOf('w');
|
||||
|
||||
const calcAuto = timeBucketsCalcAutoIntervalProvider();
|
||||
|
||||
/**
|
||||
* Helper object for wrapping the concept of an "Interval", which
|
||||
* describes a timespan that will separate buckets of time,
|
||||
* for example the interval between points on a time series chart.
|
||||
*/
|
||||
export function TimeBuckets(timeBucketsConfig, fieldFormats) {
|
||||
this._timeBucketsConfig = timeBucketsConfig;
|
||||
this._fieldFormats = fieldFormats;
|
||||
this.barTarget = this._timeBucketsConfig[UI_SETTINGS.HISTOGRAM_BAR_TARGET];
|
||||
this.maxBars = this._timeBucketsConfig[UI_SETTINGS.HISTOGRAM_MAX_BARS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the target number of bars.
|
||||
*
|
||||
* @param {number} bt - target number of bars (buckets).
|
||||
*
|
||||
* @returns {undefined}
|
||||
*/
|
||||
TimeBuckets.prototype.setBarTarget = function (bt) {
|
||||
this.barTarget = bt;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the maximum number of bars.
|
||||
*
|
||||
* @param {number} mb - maximum number of bars (buckets).
|
||||
*
|
||||
* @returns {undefined}
|
||||
*/
|
||||
TimeBuckets.prototype.setMaxBars = function (mb) {
|
||||
this.maxBars = mb;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = sortBy(bounds.map(ary(moment, 1)), Number);
|
||||
|
||||
const valid = moments.length === 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.
|
||||
*
|
||||
* Note that 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:
|
||||
* - "auto"
|
||||
* - an interval String, such as 7d, 1h or 30m which can be parsed to a moment duration using ml/common/util/parse_interval
|
||||
* - a moment.duration object.
|
||||
*
|
||||
* @param {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;
|
||||
|
||||
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 () {
|
||||
const self = this;
|
||||
const duration = self.getDuration();
|
||||
return decorateInterval(maybeScaleInterval(readInterval()), duration);
|
||||
|
||||
// either pull the interval from state or calculate the auto-interval
|
||||
function readInterval() {
|
||||
const interval = self._i;
|
||||
if (moment.isDuration(interval)) return interval;
|
||||
return calcAuto.near(self.barTarget, 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.maxBars;
|
||||
const approxLen = duration / interval;
|
||||
let scaled;
|
||||
|
||||
// If the number of buckets we got back from using the barTarget is less than
|
||||
// maxBars, than use the lessThan rule to try and get closer to maxBars.
|
||||
if (approxLen > maxLength) {
|
||||
scaled = calcAuto.lessThan(maxLength, duration);
|
||||
} else {
|
||||
return interval;
|
||||
}
|
||||
|
||||
if (+scaled === +interval) return interval;
|
||||
|
||||
decorateInterval(interval, duration);
|
||||
return assign(scaled, {
|
||||
preScaled: interval,
|
||||
scale: interval / scaled,
|
||||
scaled: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an interval which in the last step of calculation is rounded to
|
||||
* the closest multiple of the supplied divisor (in seconds).
|
||||
*
|
||||
* @return {moment.duration|undefined}
|
||||
*/
|
||||
TimeBuckets.prototype.getIntervalToNearestMultiple = function (divisorSecs) {
|
||||
const interval = this.getInterval();
|
||||
const intervalSecs = interval.asSeconds();
|
||||
|
||||
const remainder = intervalSecs % divisorSecs;
|
||||
if (remainder === 0) {
|
||||
return interval;
|
||||
}
|
||||
|
||||
// Create a new interval which is a multiple of the supplied divisor (not zero).
|
||||
let nearestMultiple =
|
||||
remainder > divisorSecs / 2 ? intervalSecs + divisorSecs - remainder : intervalSecs - remainder;
|
||||
nearestMultiple = nearestMultiple === 0 ? divisorSecs : nearestMultiple;
|
||||
const nearestMultipleInt = moment.duration(nearestMultiple, 'seconds');
|
||||
decorateInterval(nearestMultipleInt, this.getDuration());
|
||||
|
||||
// Check to see if the new interval is scaled compared to the original.
|
||||
const preScaled = interval.preScaled;
|
||||
if (preScaled !== undefined && preScaled < nearestMultipleInt) {
|
||||
nearestMultipleInt.preScaled = preScaled;
|
||||
nearestMultipleInt.scale = preScaled / nearestMultipleInt;
|
||||
nearestMultipleInt.scaled = true;
|
||||
}
|
||||
|
||||
return nearestMultipleInt;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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._timeBucketsConfig['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._timeBucketsConfig.dateFormat;
|
||||
};
|
||||
|
||||
TimeBuckets.prototype.getScaledDateFormatter = function () {
|
||||
const fieldFormats = this._fieldFormats;
|
||||
const DateFieldFormat = fieldFormats.getType(FIELD_FORMAT_IDS.DATE);
|
||||
return new DateFieldFormat(
|
||||
{
|
||||
pattern: this.getScaledDateFormat(),
|
||||
},
|
||||
// getConfig
|
||||
this._timeBucketsConfig
|
||||
);
|
||||
};
|
||||
|
||||
// Appends some TimeBuckets specific properties to the moment.js duration interval.
|
||||
// Uses the originalDuration from which the time bucket was created to calculate the overflow
|
||||
// property (i.e. difference between the supplied duration and the calculated bucket interval).
|
||||
function decorateInterval(interval, originalDuration) {
|
||||
const esInterval = calcEsInterval(interval);
|
||||
interval.esValue = esInterval.value;
|
||||
interval.esUnit = esInterval.unit;
|
||||
interval.expression = esInterval.expression;
|
||||
interval.overflow =
|
||||
originalDuration > interval ? moment.duration(interval - originalDuration) : false;
|
||||
|
||||
const prettyUnits = moment.normalizeUnits(esInterval.unit);
|
||||
if (esInterval.value === 1) {
|
||||
interval.description = prettyUnits;
|
||||
} else {
|
||||
interval.description = `${esInterval.value} ${prettyUnits}s`;
|
||||
}
|
||||
|
||||
return interval;
|
||||
}
|
||||
|
||||
function isValidMoment(m) {
|
||||
return m && 'isValid' in m && m.isValid();
|
||||
}
|
||||
|
||||
export function getBoundsRoundedToInterval(bounds, interval, inclusiveEnd = false) {
|
||||
// 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) };
|
||||
}
|
||||
|
||||
export function calcEsInterval(duration) {
|
||||
// Converts a moment.duration into an Elasticsearch compatible interval expression,
|
||||
// and provides associated metadata.
|
||||
|
||||
// Note this was a copy of Kibana's original ui/time_buckets/calc_es_interval,
|
||||
// but with the definition of a 'large' unit changed from 'M' to 'w',
|
||||
// bringing it into line with the time units supported by Elasticsearch
|
||||
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) {
|
||||
// Apart from for date histograms, ES only supports time units up to 'd',
|
||||
// meaning we can't for example use 'w' for job bucket spans.
|
||||
// See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units
|
||||
// So keep going until we get out of the "large" units.
|
||||
if (i <= timeUnitsMaxSupportedIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
value: val,
|
||||
unit,
|
||||
expression: val + unit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ms = duration.as('ms');
|
||||
return {
|
||||
value: ms,
|
||||
unit: 'ms',
|
||||
expression: ms + 'ms',
|
||||
};
|
||||
}
|
|
@ -10,10 +10,11 @@
|
|||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": [
|
||||
"charts",
|
||||
"data",
|
||||
"licensing"
|
||||
],
|
||||
"optionalPlugins": [],
|
||||
"requiredBundles": ["kibanaReact"],
|
||||
"requiredBundles": ["kibanaReact", "fieldFormats"],
|
||||
"extraPublicDirs": ["common"]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { getCoreStart } from '../../kibana_services';
|
||||
|
||||
export interface GetTimeFieldRangeResponse {
|
||||
success: boolean;
|
||||
start: { epoch: number; string: string };
|
||||
end: { epoch: number; string: string };
|
||||
}
|
||||
|
||||
export async function getTimeFieldRange({
|
||||
index,
|
||||
timeFieldName,
|
||||
query,
|
||||
runtimeMappings,
|
||||
}: {
|
||||
index: string;
|
||||
timeFieldName?: string;
|
||||
query?: QueryDslQueryContainer;
|
||||
runtimeMappings?: estypes.MappingRuntimeFields;
|
||||
}) {
|
||||
const body = JSON.stringify({ index, timeFieldName, query, runtimeMappings });
|
||||
const coreStart = getCoreStart();
|
||||
|
||||
return await coreStart.http.fetch<GetTimeFieldRangeResponse>({
|
||||
path: `/internal/file_upload/time_field_range`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { Subject } from 'rxjs';
|
||||
|
||||
export interface Refresh {
|
||||
lastRefresh: number;
|
||||
timeRange?: { start: string; end: string };
|
||||
}
|
||||
|
||||
export const aiOpsRefresh$ = new Subject<Refresh>();
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* 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, useMemo, useState } from 'react';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui';
|
||||
import type { TimeRange } from '@kbn/es-query';
|
||||
import { TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public';
|
||||
|
||||
import { useUrlState } from '../../hooks/url_state';
|
||||
import { useAiOpsKibana } from '../../kibana_context';
|
||||
import { aiOpsRefresh$ } from '../../application/services/timefilter_refresh_service';
|
||||
|
||||
interface TimePickerQuickRange {
|
||||
from: string;
|
||||
to: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
interface Duration {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
interface RefreshInterval {
|
||||
pause: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function getRecentlyUsedRangesFactory(timeHistory: TimeHistoryContract) {
|
||||
return function (): Duration[] {
|
||||
return (
|
||||
timeHistory.get()?.map(({ from, to }: TimeRange) => {
|
||||
return {
|
||||
start: from,
|
||||
end: to,
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function updateLastRefresh(timeRange: OnRefreshProps) {
|
||||
aiOpsRefresh$.next({ lastRefresh: Date.now(), timeRange });
|
||||
}
|
||||
|
||||
export const DatePickerWrapper: FC = () => {
|
||||
const { services } = useAiOpsKibana();
|
||||
const config = services.uiSettings;
|
||||
const { timefilter, history } = services.data.query.timefilter;
|
||||
|
||||
const [globalState, setGlobalState] = useUrlState('_g');
|
||||
const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(history);
|
||||
|
||||
const refreshInterval: RefreshInterval =
|
||||
globalState?.refreshInterval ?? timefilter.getRefreshInterval();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const setRefreshInterval = useCallback(
|
||||
debounce((refreshIntervalUpdate: RefreshInterval) => {
|
||||
setGlobalState('refreshInterval', refreshIntervalUpdate, true);
|
||||
}, 200),
|
||||
[setGlobalState]
|
||||
);
|
||||
|
||||
const [time, setTime] = useState(timefilter.getTime());
|
||||
const [recentlyUsedRanges, setRecentlyUsedRanges] = useState(getRecentlyUsedRanges());
|
||||
const [isAutoRefreshSelectorEnabled, setIsAutoRefreshSelectorEnabled] = useState(
|
||||
timefilter.isAutoRefreshSelectorEnabled()
|
||||
);
|
||||
const [isTimeRangeSelectorEnabled, setIsTimeRangeSelectorEnabled] = useState(
|
||||
timefilter.isTimeRangeSelectorEnabled()
|
||||
);
|
||||
|
||||
const dateFormat = config.get('dateFormat');
|
||||
const timePickerQuickRanges = config.get<TimePickerQuickRange[]>(
|
||||
UI_SETTINGS.TIMEPICKER_QUICK_RANGES
|
||||
);
|
||||
|
||||
const commonlyUsedRanges = useMemo(
|
||||
() =>
|
||||
timePickerQuickRanges.map(({ from, to, display }) => ({
|
||||
start: from,
|
||||
end: to,
|
||||
label: display,
|
||||
})),
|
||||
[timePickerQuickRanges]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = new Subscription();
|
||||
const refreshIntervalUpdate$ = timefilter.getRefreshIntervalUpdate$();
|
||||
if (refreshIntervalUpdate$ !== undefined) {
|
||||
subscriptions.add(
|
||||
refreshIntervalUpdate$.subscribe((r) => {
|
||||
setRefreshInterval(timefilter.getRefreshInterval());
|
||||
})
|
||||
);
|
||||
}
|
||||
const timeUpdate$ = timefilter.getTimeUpdate$();
|
||||
if (timeUpdate$ !== undefined) {
|
||||
subscriptions.add(
|
||||
timeUpdate$.subscribe((v) => {
|
||||
setTime(timefilter.getTime());
|
||||
})
|
||||
);
|
||||
}
|
||||
const enabledUpdated$ = timefilter.getEnabledUpdated$();
|
||||
if (enabledUpdated$ !== undefined) {
|
||||
subscriptions.add(
|
||||
enabledUpdated$.subscribe((w) => {
|
||||
setIsAutoRefreshSelectorEnabled(timefilter.isAutoRefreshSelectorEnabled());
|
||||
setIsTimeRangeSelectorEnabled(timefilter.isTimeRangeSelectorEnabled());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
subscriptions.unsubscribe();
|
||||
};
|
||||
}, [setRefreshInterval, timefilter]);
|
||||
|
||||
function updateFilter({ start, end }: Duration) {
|
||||
const newTime = { from: start, to: end };
|
||||
// Update timefilter for controllers listening for changes
|
||||
timefilter.setTime(newTime);
|
||||
setTime(newTime);
|
||||
setRecentlyUsedRanges(getRecentlyUsedRanges());
|
||||
}
|
||||
|
||||
function updateInterval({
|
||||
isPaused: pause,
|
||||
refreshInterval: value,
|
||||
}: {
|
||||
isPaused: boolean;
|
||||
refreshInterval: number;
|
||||
}) {
|
||||
setRefreshInterval({ pause, value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce pause when it's set to false with 0 refresh interval.
|
||||
*/
|
||||
const isPaused = refreshInterval.pause || (!refreshInterval.pause && !refreshInterval.value);
|
||||
|
||||
return isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled ? (
|
||||
<div className="mlNavigationMenu__datePickerWrapper">
|
||||
<EuiSuperDatePicker
|
||||
start={time.from}
|
||||
end={time.to}
|
||||
isPaused={isPaused}
|
||||
isAutoRefreshOnly={!isTimeRangeSelectorEnabled}
|
||||
refreshInterval={refreshInterval.value}
|
||||
onTimeChange={updateFilter}
|
||||
onRefresh={updateLastRefresh}
|
||||
onRefreshChange={updateInterval}
|
||||
recentlyUsedRanges={recentlyUsedRanges}
|
||||
dateFormat={dateFormat}
|
||||
commonlyUsedRanges={commonlyUsedRanges}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
|
@ -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 { DatePickerWrapper } from './date_picker_wrapper';
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
Axis,
|
||||
BarSeries,
|
||||
BrushEndListener,
|
||||
Chart,
|
||||
ElementClickListener,
|
||||
Position,
|
||||
ScaleType,
|
||||
Settings,
|
||||
XYChartElementEvent,
|
||||
XYBrushEvent,
|
||||
} from '@elastic/charts';
|
||||
import moment from 'moment';
|
||||
import { IUiSettingsClient } from '@kbn/core/public';
|
||||
import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common';
|
||||
import { useAiOpsKibana } from '../../../kibana_context';
|
||||
|
||||
export interface DocumentCountChartPoint {
|
||||
time: number | string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
width?: number;
|
||||
chartPoints: DocumentCountChartPoint[];
|
||||
timeRangeEarliest: number;
|
||||
timeRangeLatest: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
const SPEC_ID = 'document_count';
|
||||
|
||||
function getTimezone(uiSettings: IUiSettingsClient) {
|
||||
if (uiSettings.isDefault('dateFormat:tz')) {
|
||||
const detectedTimezone = moment.tz.guess();
|
||||
if (detectedTimezone) return detectedTimezone;
|
||||
else return moment().format('Z');
|
||||
} else {
|
||||
return uiSettings.get('dateFormat:tz', 'Browser');
|
||||
}
|
||||
}
|
||||
|
||||
export const DocumentCountChart: FC<Props> = ({
|
||||
width,
|
||||
chartPoints,
|
||||
timeRangeEarliest,
|
||||
timeRangeLatest,
|
||||
interval,
|
||||
}) => {
|
||||
const {
|
||||
services: { data, uiSettings, fieldFormats, charts },
|
||||
} = useAiOpsKibana();
|
||||
|
||||
const chartTheme = charts.theme.useChartsTheme();
|
||||
const chartBaseTheme = charts.theme.useChartsBaseTheme();
|
||||
|
||||
const xAxisFormatter = fieldFormats.deserialize({ id: 'date' });
|
||||
const useLegacyTimeAxis = uiSettings.get('visualization:useLegacyTimeAxis', false);
|
||||
|
||||
const seriesName = i18n.translate('xpack.aiops.dataGrid.field.documentCountChart.seriesLabel', {
|
||||
defaultMessage: 'document count',
|
||||
});
|
||||
|
||||
const xDomain = {
|
||||
min: timeRangeEarliest,
|
||||
max: timeRangeLatest,
|
||||
};
|
||||
|
||||
const adjustedChartPoints = useMemo(() => {
|
||||
// Display empty chart when no data in range
|
||||
if (chartPoints.length < 1) return [{ time: timeRangeEarliest, value: 0 }];
|
||||
|
||||
// If chart has only one bucket
|
||||
// it won't show up correctly unless we add an extra data point
|
||||
if (chartPoints.length === 1) {
|
||||
return [
|
||||
...chartPoints,
|
||||
{ time: interval ? Number(chartPoints[0].time) + interval : timeRangeEarliest, value: 0 },
|
||||
];
|
||||
}
|
||||
return chartPoints;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartPoints, timeRangeEarliest, timeRangeLatest, interval]);
|
||||
|
||||
const timefilterUpdateHandler = useCallback(
|
||||
(ranges: { from: number; to: number }) => {
|
||||
data.query.timefilter.timefilter.setTime({
|
||||
from: moment(ranges.from).toISOString(),
|
||||
to: moment(ranges.to).toISOString(),
|
||||
mode: 'absolute',
|
||||
});
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
const onBrushEnd = ({ x }: XYBrushEvent) => {
|
||||
if (!x) {
|
||||
return;
|
||||
}
|
||||
const [from, to] = x;
|
||||
timefilterUpdateHandler({ from, to });
|
||||
};
|
||||
|
||||
const onElementClick: ElementClickListener = ([elementData]) => {
|
||||
const startRange = (elementData as XYChartElementEvent)[0].x;
|
||||
|
||||
const range = {
|
||||
from: startRange,
|
||||
to: startRange + interval,
|
||||
};
|
||||
timefilterUpdateHandler(range);
|
||||
};
|
||||
|
||||
const timeZone = getTimezone(uiSettings);
|
||||
|
||||
return (
|
||||
<div style={{ width: width ?? '100%' }} data-test-subj="aiopsDocumentCountChart">
|
||||
<Chart
|
||||
size={{
|
||||
width: '100%',
|
||||
height: 120,
|
||||
}}
|
||||
>
|
||||
<Settings
|
||||
xDomain={xDomain}
|
||||
onBrushEnd={onBrushEnd as BrushEndListener}
|
||||
onElementClick={onElementClick}
|
||||
theme={chartTheme}
|
||||
baseTheme={chartBaseTheme}
|
||||
/>
|
||||
<Axis
|
||||
id="bottom"
|
||||
position={Position.Bottom}
|
||||
showOverlappingTicks={true}
|
||||
tickFormat={(value) => xAxisFormatter.convert(value)}
|
||||
timeAxisLayerCount={useLegacyTimeAxis ? 0 : 2}
|
||||
style={useLegacyTimeAxis ? {} : MULTILAYER_TIME_AXIS_STYLE}
|
||||
/>
|
||||
<Axis id="left" position={Position.Left} />
|
||||
<BarSeries
|
||||
id={SPEC_ID}
|
||||
name={seriesName}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="time"
|
||||
yAccessors={['value']}
|
||||
data={adjustedChartPoints}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</Chart>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 { DocumentCountChart } from './document_count_chart';
|
||||
export type { DocumentCountChartPoint } from './document_count_chart';
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { FC } from 'react';
|
||||
import { DocumentCountChart, DocumentCountChartPoint } from '../document_count_chart';
|
||||
import { TotalCountHeader } from '../total_count_header';
|
||||
import { DocumentCountStats } from '../../../get_document_stats';
|
||||
|
||||
export interface Props {
|
||||
documentCountStats?: DocumentCountStats;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const DocumentCountContent: FC<Props> = ({ documentCountStats, totalCount }) => {
|
||||
if (documentCountStats === undefined) {
|
||||
return totalCount !== undefined ? <TotalCountHeader totalCount={totalCount} /> : null;
|
||||
}
|
||||
|
||||
const { timeRangeEarliest, timeRangeLatest } = documentCountStats;
|
||||
if (timeRangeEarliest === undefined || timeRangeLatest === undefined)
|
||||
return <TotalCountHeader totalCount={totalCount} />;
|
||||
|
||||
let chartPoints: DocumentCountChartPoint[] = [];
|
||||
if (documentCountStats.buckets !== undefined) {
|
||||
const buckets: Record<string, number> = documentCountStats?.buckets;
|
||||
chartPoints = Object.entries(buckets).map(([time, value]) => ({ time: +time, value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TotalCountHeader totalCount={totalCount} />
|
||||
<DocumentCountChart
|
||||
chartPoints={chartPoints}
|
||||
timeRangeEarliest={timeRangeEarliest}
|
||||
timeRangeLatest={timeRangeLatest}
|
||||
interval={documentCountStats.interval}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 { DocumentCountContent } from './document_count_content';
|
|
@ -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 { TotalCountHeader } from './total_count_header';
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 { EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React from 'react';
|
||||
|
||||
export const TotalCountHeader = ({ totalCount }: { totalCount: number }) => (
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s" data-test-subj="aiopsTotalDocCountHeader">
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.searchPanel.totalDocCountLabel"
|
||||
defaultMessage="Total documents: {strongTotalCount}"
|
||||
values={{
|
||||
strongTotalCount: (
|
||||
<strong data-test-subj="aiopsTotalDocCount">
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.searchPanel.totalDocCountNumber"
|
||||
defaultMessage="{totalCount, plural, one {#} other {#}}"
|
||||
values={{ totalCount }}
|
||||
/>
|
||||
</strong>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
);
|
|
@ -7,16 +7,32 @@
|
|||
|
||||
import React, { useEffect, FC } from 'react';
|
||||
|
||||
import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiPageBody,
|
||||
EuiPageContentBody,
|
||||
EuiPageContentHeader,
|
||||
EuiPageContentHeaderSection,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { ProgressControls } from '@kbn/aiops-components';
|
||||
import { useFetchStream } from '@kbn/aiops-utils';
|
||||
import type { WindowParameters } from '@kbn/aiops-utils';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
|
||||
import { useAiOpsKibana } from '../../kibana_context';
|
||||
import { initialState, streamReducer } from '../../../common/api/stream_reducer';
|
||||
import type { ApiExplainLogRateSpikes } from '../../../common/api';
|
||||
import { SpikeAnalysisTable } from '../spike_analysis_table';
|
||||
import { FullTimeRangeSelector } from '../full_time_range_selector';
|
||||
import { DocumentCountContent } from '../document_count_content/document_count_content';
|
||||
import { DatePickerWrapper } from '../date_picker_wrapper';
|
||||
import { useData } from '../../hooks/use_data';
|
||||
import { useUrlState } from '../../hooks/url_state';
|
||||
|
||||
/**
|
||||
* ExplainLogRateSpikes props require a data view.
|
||||
|
@ -32,10 +48,14 @@ export const ExplainLogRateSpikes: FC<ExplainLogRateSpikesProps> = ({
|
|||
dataView,
|
||||
windowParameters,
|
||||
}) => {
|
||||
const kibana = useKibana();
|
||||
const basePath = kibana.services.http?.basePath.get() ?? '';
|
||||
const { services } = useAiOpsKibana();
|
||||
const basePath = services.http?.basePath.get() ?? '';
|
||||
|
||||
const { cancel, start, data, isRunning } = useFetchStream<
|
||||
const [globalState, setGlobalState] = useUrlState('_g');
|
||||
|
||||
const { docStats, timefilter } = useData(dataView, setGlobalState);
|
||||
|
||||
const { cancel, start, data, isRunning, error } = useFetchStream<
|
||||
ApiExplainLogRateSpikes,
|
||||
typeof basePath
|
||||
>(
|
||||
|
@ -56,25 +76,100 @@ export const ExplainLogRateSpikes: FC<ExplainLogRateSpikesProps> = ({
|
|||
{ reducer: streamReducer, initialState }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalState?.time !== undefined) {
|
||||
timefilter.setTime({
|
||||
from: globalState.time.from,
|
||||
to: globalState.time.to,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(globalState?.time), timefilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalState?.refreshInterval !== undefined) {
|
||||
timefilter.setRefreshInterval(globalState.refreshInterval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(globalState?.refreshInterval), timefilter]);
|
||||
|
||||
useEffect(() => {
|
||||
start();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!dataView || !timefilter) return null;
|
||||
|
||||
return (
|
||||
<EuiText>
|
||||
<h2>{dataView.title}</h2>
|
||||
<ProgressControls
|
||||
progress={data.loaded}
|
||||
progressMessage={data.loadingState ?? ''}
|
||||
isRunning={isRunning}
|
||||
onRefresh={start}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiCodeBlock language="json" fontSize="s" paddingSize="s">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</EuiCodeBlock>
|
||||
</EuiText>
|
||||
<>
|
||||
<EuiPageBody data-test-subj="aiOpsIndexPage" paddingSize="none" panelled={false}>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<EuiPageContentHeader className="aiOpsPageHeader">
|
||||
<EuiPageContentHeaderSection>
|
||||
<div className="aiOpsTitleHeader">
|
||||
<EuiTitle size={'s'}>
|
||||
<h2>{dataView.title}</h2>
|
||||
</EuiTitle>
|
||||
</div>
|
||||
</EuiPageContentHeaderSection>
|
||||
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="flexEnd"
|
||||
gutterSize="s"
|
||||
data-test-subj="aiOpsTimeRangeSelectorSection"
|
||||
>
|
||||
{dataView.timeFieldName !== undefined && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<FullTimeRangeSelector
|
||||
dataView={dataView}
|
||||
query={undefined}
|
||||
disabled={false}
|
||||
timefilter={timefilter}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<DatePickerWrapper />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPageContentHeader>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiHorizontalRule />
|
||||
<EuiPageContentBody>
|
||||
<EuiFlexGroup direction="column">
|
||||
{docStats?.totalCount !== undefined && (
|
||||
<EuiFlexItem>
|
||||
<DocumentCountContent
|
||||
documentCountStats={docStats.documentCountStats}
|
||||
totalCount={docStats.totalCount}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<ProgressControls
|
||||
progress={data.loaded}
|
||||
progressMessage={data.loadingState ?? ''}
|
||||
isRunning={isRunning}
|
||||
onRefresh={start}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{data?.changePoints ? (
|
||||
<EuiFlexItem>
|
||||
<SpikeAnalysisTable
|
||||
changePointData={data.changePoints}
|
||||
loading={isRunning}
|
||||
error={error}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
</EuiPageContentBody>
|
||||
</EuiPageBody>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { parse, stringify } from 'query-string';
|
||||
import { isEqual } from 'lodash';
|
||||
import { encode } from 'rison-node';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Accessor,
|
||||
Dictionary,
|
||||
parseUrlState,
|
||||
Provider as UrlStateContextProvider,
|
||||
isRisonSerializationRequired,
|
||||
getNestedProperty,
|
||||
SetUrlState,
|
||||
} from '../../hooks/url_state';
|
||||
|
||||
import { ExplainLogRateSpikes, ExplainLogRateSpikesProps } from './explain_log_rate_spikes';
|
||||
|
||||
export const ExplainLogRateSpikesWrapper: FC<ExplainLogRateSpikesProps> = (props) => {
|
||||
const history = useHistory();
|
||||
const { search: urlSearchString } = useLocation();
|
||||
|
||||
const setUrlState: SetUrlState = useCallback(
|
||||
(
|
||||
accessor: Accessor,
|
||||
attribute: string | Dictionary<any>,
|
||||
value?: any,
|
||||
replaceState?: boolean
|
||||
) => {
|
||||
const prevSearchString = urlSearchString;
|
||||
const urlState = parseUrlState(prevSearchString);
|
||||
const parsedQueryString = parse(prevSearchString, { sort: false });
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(urlState, accessor)) {
|
||||
urlState[accessor] = {};
|
||||
}
|
||||
|
||||
if (typeof attribute === 'string') {
|
||||
if (isEqual(getNestedProperty(urlState, `${accessor}.${attribute}`), value)) {
|
||||
return prevSearchString;
|
||||
}
|
||||
|
||||
urlState[accessor][attribute] = value;
|
||||
} else {
|
||||
const attributes = attribute;
|
||||
Object.keys(attributes).forEach((a) => {
|
||||
urlState[accessor][a] = attributes[a];
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const oldLocationSearchString = stringify(parsedQueryString, {
|
||||
sort: false,
|
||||
encode: false,
|
||||
});
|
||||
|
||||
Object.keys(urlState).forEach((a) => {
|
||||
if (isRisonSerializationRequired(a)) {
|
||||
parsedQueryString[a] = encode(urlState[a]);
|
||||
} else {
|
||||
parsedQueryString[a] = urlState[a];
|
||||
}
|
||||
});
|
||||
const newLocationSearchString = stringify(parsedQueryString, {
|
||||
sort: false,
|
||||
encode: false,
|
||||
});
|
||||
|
||||
if (oldLocationSearchString !== newLocationSearchString) {
|
||||
const newSearchString = stringify(parsedQueryString, { sort: false });
|
||||
if (replaceState) {
|
||||
history.replace({ search: newSearchString });
|
||||
} else {
|
||||
history.push({ search: newSearchString });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Could not save url state', error);
|
||||
}
|
||||
},
|
||||
[history, urlSearchString]
|
||||
);
|
||||
|
||||
return (
|
||||
<UrlStateContextProvider value={{ searchString: urlSearchString, setUrlState }}>
|
||||
<ExplainLogRateSpikes {...props} />{' '}
|
||||
</UrlStateContextProvider>
|
||||
);
|
||||
};
|
|
@ -6,8 +6,8 @@
|
|||
*/
|
||||
|
||||
export type { ExplainLogRateSpikesProps } from './explain_log_rate_spikes';
|
||||
import { ExplainLogRateSpikes } from './explain_log_rate_spikes';
|
||||
import { ExplainLogRateSpikesWrapper } from './explain_log_rate_spikes_wrapper';
|
||||
|
||||
// required for dynamic import using React.lazy()
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ExplainLogRateSpikes;
|
||||
export default ExplainLogRateSpikesWrapper;
|
||||
|
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { TimefilterContract } from '@kbn/data-plugin/public';
|
||||
import { DataView } from '@kbn/data-plugin/common';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiPopover,
|
||||
EuiRadioGroup,
|
||||
EuiRadioGroupOption,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useAiOpsKibana } from '../../kibana_context';
|
||||
import { setFullTimeRange } from './full_time_range_selector_service';
|
||||
import { AIOPS_FROZEN_TIER_PREFERENCE, useStorage } from '../../hooks/use_storage';
|
||||
|
||||
interface Props {
|
||||
timefilter: TimefilterContract;
|
||||
dataView: DataView;
|
||||
disabled: boolean;
|
||||
query?: QueryDslQueryContainer;
|
||||
callback?: (a: any) => void;
|
||||
}
|
||||
|
||||
const FROZEN_TIER_PREFERENCE = {
|
||||
EXCLUDE: 'exclude-frozen',
|
||||
INCLUDE: 'include-frozen',
|
||||
} as const;
|
||||
|
||||
type FrozenTierPreference = typeof FROZEN_TIER_PREFERENCE[keyof typeof FROZEN_TIER_PREFERENCE];
|
||||
|
||||
export const FullTimeRangeSelector: FC<Props> = ({
|
||||
timefilter,
|
||||
dataView,
|
||||
query,
|
||||
disabled,
|
||||
callback,
|
||||
}) => {
|
||||
const {
|
||||
services: {
|
||||
notifications: { toasts },
|
||||
},
|
||||
} = useAiOpsKibana();
|
||||
|
||||
// wrapper around setFullTimeRange to allow for the calling of the optional callBack prop
|
||||
const setRange = useCallback(
|
||||
async (i: DataView, q?: QueryDslQueryContainer, excludeFrozenData?: boolean) => {
|
||||
try {
|
||||
const fullTimeRange = await setFullTimeRange(timefilter, i, q, excludeFrozenData, toasts);
|
||||
if (typeof callback === 'function') {
|
||||
callback(fullTimeRange);
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.addDanger(
|
||||
i18n.translate(
|
||||
'xpack.aiops.index.fullTimeRangeSelector.errorSettingTimeRangeNotification',
|
||||
{
|
||||
defaultMessage: 'An error occurred setting the time range.',
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[callback, timefilter, toasts]
|
||||
);
|
||||
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
|
||||
const [frozenDataPreference, setFrozenDataPreference] = useStorage<FrozenTierPreference>(
|
||||
AIOPS_FROZEN_TIER_PREFERENCE,
|
||||
// By default we will exclude frozen data tier
|
||||
FROZEN_TIER_PREFERENCE.EXCLUDE
|
||||
);
|
||||
|
||||
const setPreference = useCallback(
|
||||
(id: string) => {
|
||||
setFrozenDataPreference(id as FrozenTierPreference);
|
||||
setRange(dataView, query, id === FROZEN_TIER_PREFERENCE.EXCLUDE);
|
||||
closePopover();
|
||||
},
|
||||
[dataView, query, setFrozenDataPreference, setRange]
|
||||
);
|
||||
|
||||
const onButtonClick = () => {
|
||||
setPopover(!isPopoverOpen);
|
||||
};
|
||||
|
||||
const closePopover = () => {
|
||||
setPopover(false);
|
||||
};
|
||||
|
||||
const sortOptions: EuiRadioGroupOption[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: FROZEN_TIER_PREFERENCE.EXCLUDE,
|
||||
label: i18n.translate(
|
||||
'xpack.aiops.index.fullTimeRangeSelector.useFullDataExcludingFrozenMenuLabel',
|
||||
{
|
||||
defaultMessage: 'Exclude frozen data tier',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
id: FROZEN_TIER_PREFERENCE.INCLUDE,
|
||||
label: i18n.translate(
|
||||
'xpack.aiops.index.fullTimeRangeSelector.useFullDataIncludingFrozenMenuLabel',
|
||||
{
|
||||
defaultMessage: 'Include frozen data tier',
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() => (
|
||||
<EuiPanel>
|
||||
<EuiRadioGroup
|
||||
options={sortOptions}
|
||||
idSelected={frozenDataPreference}
|
||||
onChange={setPreference}
|
||||
compressed
|
||||
/>
|
||||
</EuiPanel>
|
||||
),
|
||||
[sortOptions, frozenDataPreference, setPreference]
|
||||
);
|
||||
|
||||
const buttonTooltip = useMemo(
|
||||
() =>
|
||||
frozenDataPreference === FROZEN_TIER_PREFERENCE.EXCLUDE ? (
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.fullTimeRangeSelector.useFullDataExcludingFrozenButtonTooltip"
|
||||
defaultMessage="Use full range of data excluding frozen data tier."
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.fullTimeRangeSelector.useFullDataIncludingFrozenButtonTooltip"
|
||||
defaultMessage="Use full range of data including frozen data tier, which might have slower search results."
|
||||
/>
|
||||
),
|
||||
[frozenDataPreference]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup responsive={false} gutterSize="xs" alignItems="center">
|
||||
<EuiToolTip content={buttonTooltip}>
|
||||
<EuiButton
|
||||
isDisabled={disabled}
|
||||
onClick={() => setRange(dataView, query, true)}
|
||||
data-test-subj="aiopsExplainLogRatesSpikeButtonUseFullData"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.index.fullTimeRangeSelector.useFullDataButtonLabel"
|
||||
defaultMessage="Use full data"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiToolTip>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
id={'mlFullTimeRangeSelectorOption'}
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
display="base"
|
||||
size="m"
|
||||
iconType="boxesVertical"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.aiops.index.fullTimeRangeSelector.moreOptionsButtonAriaLabel',
|
||||
{
|
||||
defaultMessage: 'More options',
|
||||
}
|
||||
)}
|
||||
onClick={onButtonClick}
|
||||
/>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downRight"
|
||||
>
|
||||
{popoverContent}
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import { TimefilterContract } from '@kbn/data-plugin/public';
|
||||
import dateMath from '@kbn/datemath';
|
||||
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { ToastsStart } from '@kbn/core/public';
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
import { getTimeFieldRange } from '../../application/services/time_field_range';
|
||||
import { addExcludeFrozenToQuery } from '../../query_utils';
|
||||
|
||||
export interface GetTimeFieldRangeResponse {
|
||||
success: boolean;
|
||||
start: { epoch: number; string: string };
|
||||
end: { epoch: number; string: string };
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export async function setFullTimeRange(
|
||||
timefilter: TimefilterContract,
|
||||
dataView: DataView,
|
||||
query?: QueryDslQueryContainer,
|
||||
excludeFrozenData?: boolean,
|
||||
toasts?: ToastsStart
|
||||
): Promise<GetTimeFieldRangeResponse> {
|
||||
const runtimeMappings = dataView.getRuntimeMappings();
|
||||
const resp = await getTimeFieldRange({
|
||||
index: dataView.title,
|
||||
timeFieldName: dataView.timeFieldName,
|
||||
query: excludeFrozenData ? addExcludeFrozenToQuery(query) : query,
|
||||
...(isPopulatedObject(runtimeMappings) ? { runtimeMappings } : {}),
|
||||
});
|
||||
|
||||
if (resp.start.epoch && resp.end.epoch) {
|
||||
timefilter.setTime({
|
||||
from: moment(resp.start.epoch).toISOString(),
|
||||
to: moment(resp.end.epoch).toISOString(),
|
||||
});
|
||||
} else {
|
||||
toasts?.addWarning({
|
||||
title: i18n.translate('xpack.aiops.index.fullTimeRangeSelector.noResults', {
|
||||
defaultMessage: 'No results match your search criteria',
|
||||
}),
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export function getTimeFilterRange(timefilter: TimefilterContract): TimeRange {
|
||||
const fromMoment = dateMath.parse(timefilter.getTime().from);
|
||||
const toMoment = dateMath.parse(timefilter.getTime().to);
|
||||
const from = fromMoment !== undefined ? fromMoment.valueOf() : 0;
|
||||
const to = toMoment !== undefined ? toMoment.valueOf() : 0;
|
||||
|
||||
return {
|
||||
to,
|
||||
from,
|
||||
};
|
||||
}
|
|
@ -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 { FullTimeRangeSelector } from './full_time_range_selector';
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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';
|
||||
|
||||
// This is currently copied over from the APM plugin
|
||||
const CORRELATIONS_IMPACT_THRESHOLD = {
|
||||
HIGH: i18n.translate('xpack.aiops.correlations.highImpactText', {
|
||||
defaultMessage: 'High',
|
||||
}),
|
||||
MEDIUM: i18n.translate('xpack.aiops.correlations.mediumImpactText', {
|
||||
defaultMessage: 'Medium',
|
||||
}),
|
||||
LOW: i18n.translate('xpack.aiops.correlations.lowImpactText', {
|
||||
defaultMessage: 'Low',
|
||||
}),
|
||||
VERY_LOW: i18n.translate('xpack.aiops.correlations.veryLowImpactText', {
|
||||
defaultMessage: 'Very low',
|
||||
}),
|
||||
} as const;
|
||||
|
||||
type FailedTransactionsCorrelationsImpactThreshold =
|
||||
typeof CORRELATIONS_IMPACT_THRESHOLD[keyof typeof CORRELATIONS_IMPACT_THRESHOLD];
|
||||
|
||||
export function getFailedTransactionsCorrelationImpactLabel(
|
||||
pValue: number | null,
|
||||
isFallbackResult?: boolean
|
||||
): {
|
||||
impact: FailedTransactionsCorrelationsImpactThreshold;
|
||||
color: string;
|
||||
} | null {
|
||||
if (pValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isFallbackResult)
|
||||
return {
|
||||
impact: CORRELATIONS_IMPACT_THRESHOLD.VERY_LOW,
|
||||
color: 'default',
|
||||
};
|
||||
|
||||
// The lower the p value, the higher the impact
|
||||
if (pValue >= 0 && pValue < 1e-6)
|
||||
return {
|
||||
impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH,
|
||||
color: 'danger',
|
||||
};
|
||||
if (pValue >= 1e-6 && pValue < 0.001)
|
||||
return {
|
||||
impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM,
|
||||
color: 'warning',
|
||||
};
|
||||
if (pValue >= 0.001 && pValue < 0.02)
|
||||
return {
|
||||
impact: CORRELATIONS_IMPACT_THRESHOLD.LOW,
|
||||
color: 'default',
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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 { EuiProgress } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
export const unit = 16;
|
||||
|
||||
// TODO: extend from EUI's EuiProgress prop interface
|
||||
export interface ImpactBarProps extends Record<string, unknown> {
|
||||
value: number;
|
||||
size?: 's' | 'l' | 'm';
|
||||
max?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const style = { width: `${unit * 6}px` };
|
||||
|
||||
export function ImpactBar({
|
||||
value,
|
||||
size = 'm',
|
||||
max = 100,
|
||||
color = 'primary',
|
||||
...rest
|
||||
}: ImpactBarProps) {
|
||||
return <EuiProgress size={size} value={value} max={max} color={color} style={style} {...rest} />;
|
||||
}
|
|
@ -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 { SpikeAnalysisTable } from './spike_analysis_table';
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { EuiBadge, EuiBasicTable, EuiBasicTableColumn, RIGHT_ALIGNMENT } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ChangePoint } from '../../../common/types';
|
||||
import { ImpactBar } from './impact_bar';
|
||||
import { getFailedTransactionsCorrelationImpactLabel } from './get_failed_transactions_correlation_impact_label';
|
||||
|
||||
const PAGINATION_SIZE_OPTIONS = [5, 10, 20, 50];
|
||||
const noDataText = i18n.translate('xpack.aiops.correlations.correlationsTable.noDataText', {
|
||||
defaultMessage: 'No data',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
changePointData: ChangePoint[];
|
||||
error?: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const SpikeAnalysisTable: FC<Props> = ({ changePointData, error, loading }) => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<ChangePoint>> = [
|
||||
{
|
||||
field: 'score',
|
||||
name: (
|
||||
<>
|
||||
{i18n.translate(
|
||||
'xpack.aiops.correlations.failedTransactions.correlationsTable.pValueLabel',
|
||||
{
|
||||
defaultMessage: 'Score',
|
||||
}
|
||||
)}
|
||||
</>
|
||||
),
|
||||
align: RIGHT_ALIGNMENT,
|
||||
render: (_, { score }) => {
|
||||
return (
|
||||
<>
|
||||
<ImpactBar size="m" value={Number(score.toFixed(2))} label={score.toFixed(2)} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'pValue',
|
||||
name: (
|
||||
<>
|
||||
{i18n.translate(
|
||||
'xpack.aiops.correlations.failedTransactions.correlationsTable.impactLabel',
|
||||
{
|
||||
defaultMessage: 'Impact',
|
||||
}
|
||||
)}
|
||||
</>
|
||||
),
|
||||
render: (_, { pValue }) => {
|
||||
const label = getFailedTransactionsCorrelationImpactLabel(pValue);
|
||||
return label ? <EuiBadge color={label.color}>{label.impact}</EuiBadge> : null;
|
||||
},
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'fieldName',
|
||||
name: i18n.translate(
|
||||
'xpack.aiops.correlations.failedTransactions.correlationsTable.fieldNameLabel',
|
||||
{ defaultMessage: 'Field name' }
|
||||
),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'fieldValue',
|
||||
name: i18n.translate(
|
||||
'xpack.aiops.correlations.failedTransactions.correlationsTable.fieldValueLabel',
|
||||
{ defaultMessage: 'Field value' }
|
||||
),
|
||||
render: (_, { fieldValue }) => String(fieldValue).slice(0, 50),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'pValue',
|
||||
name: 'p-value',
|
||||
render: (pValue: number) => pValue.toPrecision(3),
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const onChange = useCallback((tableSettings) => {
|
||||
const { index, size } = tableSettings.page;
|
||||
|
||||
setPageIndex(index);
|
||||
setPageSize(size);
|
||||
}, []);
|
||||
|
||||
const { pagination, pageOfItems } = useMemo(() => {
|
||||
const pageStart = pageIndex * pageSize;
|
||||
|
||||
const itemCount = changePointData?.length ?? 0;
|
||||
return {
|
||||
pageOfItems: changePointData?.slice(pageStart, pageStart + pageSize),
|
||||
pagination: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
totalItemCount: itemCount,
|
||||
pageSizeOptions: PAGINATION_SIZE_OPTIONS,
|
||||
},
|
||||
};
|
||||
}, [pageIndex, pageSize, changePointData]);
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
columns={columns}
|
||||
items={pageOfItems ?? []}
|
||||
noItemsMessage={noDataText}
|
||||
onChange={onChange}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
error={error}
|
||||
// sorting={sorting}
|
||||
// rowProps={(term) => {
|
||||
// return {
|
||||
// onClick: () => {
|
||||
// // if (setPinnedSignificantTerm) {
|
||||
// // setPinnedSignificantTerm(term);
|
||||
// // }
|
||||
// },
|
||||
// onMouseEnter: () => {
|
||||
// // setSelectedSignificantTerm(term);
|
||||
// },
|
||||
// onMouseLeave: () => {
|
||||
// // setSelectedSignificantTerm(null);
|
||||
// },
|
||||
// // style:
|
||||
// // selectedTerm &&
|
||||
// // selectedTerm.fieldValue === term.fieldValue &&
|
||||
// // selectedTerm.fieldName === term.fieldName
|
||||
// // ? {
|
||||
// // backgroundColor: euiTheme.eui.euiColorLightestShade,
|
||||
// // }
|
||||
// // : null,
|
||||
// };
|
||||
// }}
|
||||
/>
|
||||
);
|
||||
};
|
114
x-pack/plugins/aiops/public/get_document_stats.ts
Normal file
114
x-pack/plugins/aiops/public/get_document_stats.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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, get } from 'lodash';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
import { buildBaseFilterCriteria } from './query_utils';
|
||||
|
||||
export interface DocumentCountStats {
|
||||
interval?: number;
|
||||
buckets?: { [key: string]: number };
|
||||
timeRangeEarliest?: number;
|
||||
timeRangeLatest?: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface DocumentStatsSearchStrategyParams {
|
||||
earliest?: number;
|
||||
latest?: number;
|
||||
intervalMs?: number;
|
||||
index: string;
|
||||
timeFieldName?: string;
|
||||
runtimeFieldMap?: estypes.MappingRuntimeFields;
|
||||
fieldsToFetch?: string[];
|
||||
}
|
||||
|
||||
export const getDocumentCountStatsRequest = (params: DocumentStatsSearchStrategyParams) => {
|
||||
const {
|
||||
index,
|
||||
timeFieldName,
|
||||
earliest: earliestMs,
|
||||
latest: latestMs,
|
||||
runtimeFieldMap,
|
||||
// searchQuery,
|
||||
intervalMs,
|
||||
fieldsToFetch,
|
||||
} = params;
|
||||
|
||||
const size = 0;
|
||||
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, {
|
||||
match_all: {},
|
||||
});
|
||||
|
||||
// Don't use the sampler aggregation as this can lead to some potentially
|
||||
// confusing date histogram results depending on the date range of data amongst shards.
|
||||
const aggs = {
|
||||
eventRate: {
|
||||
date_histogram: {
|
||||
field: timeFieldName,
|
||||
fixed_interval: `${intervalMs}ms`,
|
||||
min_doc_count: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const searchBody = {
|
||||
query: {
|
||||
bool: {
|
||||
filter: filterCriteria,
|
||||
},
|
||||
},
|
||||
...(!fieldsToFetch && timeFieldName !== undefined && intervalMs !== undefined && intervalMs > 0
|
||||
? { aggs }
|
||||
: {}),
|
||||
...(isPopulatedObject(runtimeFieldMap) ? { runtime_mappings: runtimeFieldMap } : {}),
|
||||
track_total_hits: true,
|
||||
size,
|
||||
};
|
||||
return {
|
||||
index,
|
||||
body: searchBody,
|
||||
};
|
||||
};
|
||||
|
||||
export const processDocumentCountStats = (
|
||||
body: estypes.SearchResponse | undefined,
|
||||
params: DocumentStatsSearchStrategyParams
|
||||
): DocumentCountStats | undefined => {
|
||||
if (!body) return undefined;
|
||||
|
||||
const totalCount = (body.hits.total as estypes.SearchTotalHits).value ?? body.hits.total ?? 0;
|
||||
|
||||
if (
|
||||
params.intervalMs === undefined ||
|
||||
params.earliest === undefined ||
|
||||
params.latest === undefined
|
||||
) {
|
||||
return {
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
const buckets: { [key: string]: number } = {};
|
||||
const dataByTimeBucket: Array<{ key: string; doc_count: number }> = get(
|
||||
body,
|
||||
['aggregations', 'eventRate', 'buckets'],
|
||||
[]
|
||||
);
|
||||
each(dataByTimeBucket, (dataForTime) => {
|
||||
const time = dataForTime.key;
|
||||
buckets[time] = dataForTime.doc_count;
|
||||
});
|
||||
|
||||
return {
|
||||
interval: params.intervalMs,
|
||||
buckets,
|
||||
timeRangeEarliest: params.earliest,
|
||||
timeRangeLatest: params.latest,
|
||||
totalCount,
|
||||
};
|
||||
};
|
147
x-pack/plugins/aiops/public/hooks/url_state.ts
Normal file
147
x-pack/plugins/aiops/public/hooks/url_state.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* 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 { parse } from 'query-string';
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react';
|
||||
import { decode } from 'rison-node';
|
||||
|
||||
export interface Dictionary<TValue> {
|
||||
[id: string]: TValue;
|
||||
}
|
||||
|
||||
// duplicate of ml/object_utils
|
||||
export const getNestedProperty = (
|
||||
obj: Record<string, any>,
|
||||
accessor: string,
|
||||
defaultValue?: any
|
||||
) => {
|
||||
const value = accessor.split('.').reduce((o, i) => o?.[i], obj);
|
||||
|
||||
if (value === undefined) return defaultValue;
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export type Accessor = '_a' | '_g';
|
||||
export type SetUrlState = (
|
||||
accessor: Accessor,
|
||||
attribute: string | Dictionary<any>,
|
||||
value?: any,
|
||||
replaceState?: boolean
|
||||
) => void;
|
||||
export interface UrlState {
|
||||
searchString: string;
|
||||
setUrlState: SetUrlState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of URL query parameters that require the rison serialization.
|
||||
*/
|
||||
const risonSerializedParams = new Set(['_a', '_g']);
|
||||
|
||||
/**
|
||||
* Checks if the URL query parameter requires rison serialization.
|
||||
* @param queryParam
|
||||
*/
|
||||
export function isRisonSerializationRequired(queryParam: string): boolean {
|
||||
return risonSerializedParams.has(queryParam);
|
||||
}
|
||||
|
||||
export function parseUrlState(search: string): Dictionary<any> {
|
||||
const urlState: Dictionary<any> = {};
|
||||
const parsedQueryString = parse(search, { sort: false });
|
||||
|
||||
try {
|
||||
Object.keys(parsedQueryString).forEach((a) => {
|
||||
if (isRisonSerializationRequired(a)) {
|
||||
urlState[a] = decode(parsedQueryString[a] as string);
|
||||
} else {
|
||||
urlState[a] = parsedQueryString[a];
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Could not read url state', error);
|
||||
}
|
||||
|
||||
return urlState;
|
||||
}
|
||||
|
||||
// Compared to the original appState/globalState,
|
||||
// this no longer makes use of fetch/save methods.
|
||||
// - Reading from `location.search` is the successor of `fetch`.
|
||||
// - `history.push()` is the successor of `save`.
|
||||
// - The exposed state and set call make use of the above and make sure that
|
||||
// different urlStates(e.g. `_a` / `_g`) don't overwrite each other.
|
||||
// This uses a context to be able to maintain only one instance
|
||||
// of the url state. It gets passed down with `UrlStateProvider`
|
||||
// and can be used via `useUrlState`.
|
||||
export const aiOpsUrlStateStore = createContext<UrlState>({
|
||||
searchString: '',
|
||||
setUrlState: () => {},
|
||||
});
|
||||
|
||||
export const { Provider } = aiOpsUrlStateStore;
|
||||
|
||||
export const useUrlState = (accessor: Accessor) => {
|
||||
const { searchString, setUrlState: setUrlStateContext } = useContext(aiOpsUrlStateStore);
|
||||
|
||||
const urlState = useMemo(() => {
|
||||
const fullUrlState = parseUrlState(searchString);
|
||||
if (typeof fullUrlState === 'object') {
|
||||
return fullUrlState[accessor];
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchString]);
|
||||
|
||||
const setUrlState = useCallback(
|
||||
(attribute: string | Dictionary<any>, value?: any, replaceState?: boolean) => {
|
||||
setUrlStateContext(accessor, attribute, value, replaceState);
|
||||
},
|
||||
[accessor, setUrlStateContext]
|
||||
);
|
||||
return [urlState, setUrlState];
|
||||
};
|
||||
|
||||
export const AppStateKey = 'AIOPS_INDEX_VIEWER';
|
||||
|
||||
/**
|
||||
* Hook for managing the URL state of the page.
|
||||
*/
|
||||
export const usePageUrlState = <PageUrlState extends {}>(
|
||||
pageKey: typeof AppStateKey,
|
||||
defaultState?: PageUrlState
|
||||
): [PageUrlState, (update: Partial<PageUrlState>, replaceState?: boolean) => void] => {
|
||||
const [appState, setAppState] = useUrlState('_a');
|
||||
const pageState = appState?.[pageKey];
|
||||
|
||||
const resultPageState: PageUrlState = useMemo(() => {
|
||||
return {
|
||||
...(defaultState ?? {}),
|
||||
...(pageState ?? {}),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageState]);
|
||||
|
||||
const onStateUpdate = useCallback(
|
||||
(update: Partial<PageUrlState>, replaceState?: boolean) => {
|
||||
setAppState(
|
||||
pageKey,
|
||||
{
|
||||
...resultPageState,
|
||||
...update,
|
||||
},
|
||||
replaceState
|
||||
);
|
||||
},
|
||||
[pageKey, resultPageState, setAppState]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return [resultPageState, onStateUpdate];
|
||||
}, [resultPageState, onStateUpdate]);
|
||||
};
|
102
x-pack/plugins/aiops/public/hooks/use_data.ts
Normal file
102
x-pack/plugins/aiops/public/hooks/use_data.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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, useMemo, useState } from 'react'; // useCallback, useRef
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { merge } from 'rxjs';
|
||||
import { UI_SETTINGS } from '@kbn/data-plugin/common';
|
||||
import { useAiOpsKibana } from '../kibana_context';
|
||||
import { useTimefilter } from './use_time_filter';
|
||||
import { aiOpsRefresh$ } from '../application/services/timefilter_refresh_service';
|
||||
import { TimeBuckets } from '../../common/time_buckets';
|
||||
import { useDocumentCountStats } from './use_document_count_stats';
|
||||
import { Dictionary } from './url_state';
|
||||
import { DocumentStatsSearchStrategyParams } from '../get_document_stats';
|
||||
|
||||
export const useData = (
|
||||
currentDataView: DataView,
|
||||
onUpdate: (params: Dictionary<unknown>) => void
|
||||
) => {
|
||||
const { services } = useAiOpsKibana();
|
||||
const { uiSettings } = services;
|
||||
const [lastRefresh, setLastRefresh] = useState(0);
|
||||
|
||||
const _timeBuckets = useMemo(() => {
|
||||
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'),
|
||||
});
|
||||
}, [uiSettings]);
|
||||
|
||||
const timefilter = useTimefilter({
|
||||
timeRangeSelector: currentDataView?.timeFieldName !== undefined,
|
||||
autoRefreshSelector: true,
|
||||
});
|
||||
|
||||
const fieldStatsRequest: DocumentStatsSearchStrategyParams | undefined = useMemo(
|
||||
() => {
|
||||
// Obtain the interval to use for date histogram aggregations
|
||||
// (such as the document count chart). Aim for 75 bars.
|
||||
const buckets = _timeBuckets;
|
||||
const tf = timefilter;
|
||||
if (!buckets || !tf || !currentDataView) return;
|
||||
const activeBounds = tf.getActiveBounds();
|
||||
let earliest: number | undefined;
|
||||
let latest: number | undefined;
|
||||
if (activeBounds !== undefined && currentDataView.timeFieldName !== undefined) {
|
||||
earliest = activeBounds.min?.valueOf();
|
||||
latest = activeBounds.max?.valueOf();
|
||||
}
|
||||
const bounds = tf.getActiveBounds();
|
||||
const BAR_TARGET = 75;
|
||||
buckets.setInterval('auto');
|
||||
if (bounds) {
|
||||
buckets.setBounds(bounds);
|
||||
buckets.setBarTarget(BAR_TARGET);
|
||||
}
|
||||
const aggInterval = buckets.getInterval();
|
||||
|
||||
return {
|
||||
earliest,
|
||||
latest,
|
||||
intervalMs: aggInterval?.asMilliseconds(),
|
||||
index: currentDataView.title,
|
||||
timeFieldName: currentDataView.timeFieldName,
|
||||
runtimeFieldMap: currentDataView.getRuntimeMappings(),
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[_timeBuckets, timefilter, currentDataView.id, lastRefresh]
|
||||
);
|
||||
const { docStats } = useDocumentCountStats(fieldStatsRequest, lastRefresh);
|
||||
|
||||
useEffect(() => {
|
||||
const timeUpdateSubscription = merge(
|
||||
timefilter.getTimeUpdate$(),
|
||||
timefilter.getAutoRefreshFetch$(),
|
||||
aiOpsRefresh$
|
||||
).subscribe(() => {
|
||||
if (onUpdate) {
|
||||
onUpdate({
|
||||
time: timefilter.getTime(),
|
||||
refreshInterval: timefilter.getRefreshInterval(),
|
||||
});
|
||||
}
|
||||
setLastRefresh(Date.now());
|
||||
});
|
||||
return () => {
|
||||
timeUpdateSubscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
docStats,
|
||||
timefilter,
|
||||
};
|
||||
};
|
102
x-pack/plugins/aiops/public/hooks/use_document_count_stats.ts
Normal file
102
x-pack/plugins/aiops/public/hooks/use_document_count_stats.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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 { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { ToastsStart } from '@kbn/core/public';
|
||||
import { useAiOpsKibana } from '../kibana_context';
|
||||
import { extractErrorProperties } from '../../common/error_utils';
|
||||
import {
|
||||
DocumentCountStats,
|
||||
getDocumentCountStatsRequest,
|
||||
processDocumentCountStats,
|
||||
DocumentStatsSearchStrategyParams,
|
||||
} from '../get_document_stats';
|
||||
|
||||
export interface DocumentStats {
|
||||
totalCount: number;
|
||||
documentCountStats?: DocumentCountStats;
|
||||
}
|
||||
|
||||
function displayError(toastNotifications: ToastsStart, index: string, err: any) {
|
||||
if (err.statusCode === 500) {
|
||||
toastNotifications.addError(err, {
|
||||
title: i18n.translate('xpack.aiops.index.dataLoader.internalServerErrorMessage', {
|
||||
defaultMessage:
|
||||
'Error loading data in index {index}. {message}. ' +
|
||||
'The request may have timed out. Try using a smaller sample size or narrowing the time range.',
|
||||
values: {
|
||||
index,
|
||||
message: err.error ?? err.message,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
toastNotifications.addError(err, {
|
||||
title: i18n.translate('xpack.aiops.index.errorLoadingDataMessage', {
|
||||
defaultMessage: 'Error loading data in index {index}. {message}.',
|
||||
values: {
|
||||
index,
|
||||
message: err.error ?? err.message,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useDocumentCountStats<TParams extends DocumentStatsSearchStrategyParams>(
|
||||
searchParams: TParams | undefined,
|
||||
lastRefresh: number
|
||||
): {
|
||||
docStats: DocumentStats;
|
||||
} {
|
||||
const {
|
||||
services: {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
},
|
||||
} = useAiOpsKibana();
|
||||
|
||||
const [stats, setStats] = useState<DocumentStats>({
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
const fetchDocumentCountData = useCallback(async () => {
|
||||
if (!searchParams) return;
|
||||
|
||||
try {
|
||||
const resp: any = await lastValueFrom(
|
||||
data.search.search({
|
||||
params: getDocumentCountStatsRequest(searchParams).body,
|
||||
})
|
||||
);
|
||||
const documentCountStats = processDocumentCountStats(resp?.rawResponse, searchParams);
|
||||
const totalCount = documentCountStats?.totalCount ?? 0;
|
||||
setStats({
|
||||
documentCountStats,
|
||||
totalCount,
|
||||
});
|
||||
} catch (error) {
|
||||
displayError(toasts, searchParams!.index, extractErrorProperties(error));
|
||||
}
|
||||
}, [data?.search, searchParams, toasts]);
|
||||
|
||||
useEffect(
|
||||
function getDocumentCountData() {
|
||||
fetchDocumentCountData();
|
||||
},
|
||||
[fetchDocumentCountData]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
docStats: stats,
|
||||
}),
|
||||
[stats]
|
||||
);
|
||||
}
|
44
x-pack/plugins/aiops/public/hooks/use_storage.ts
Normal file
44
x-pack/plugins/aiops/public/hooks/use_storage.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
import { useAiOpsKibana } from '../kibana_context';
|
||||
|
||||
export const AIOPS_FROZEN_TIER_PREFERENCE = 'aiOps.frozenDataTierPreference';
|
||||
|
||||
export type AiOps = Partial<{
|
||||
[AIOPS_FROZEN_TIER_PREFERENCE]: 'exclude_frozen' | 'include_frozen';
|
||||
}> | null;
|
||||
|
||||
export type AiOpsKey = keyof Exclude<AiOps, null>;
|
||||
|
||||
/**
|
||||
* Hook for accessing and changing a value in the storage.
|
||||
* @param key - Storage key
|
||||
* @param initValue
|
||||
*/
|
||||
export function useStorage<T>(key: AiOpsKey, initValue?: T): [T, (value: T) => void] {
|
||||
const {
|
||||
services: { storage },
|
||||
} = useAiOpsKibana();
|
||||
|
||||
const [val, setVal] = useState<T>(storage.get(key) ?? initValue);
|
||||
|
||||
const setStorage = useCallback(
|
||||
(value: T): void => {
|
||||
try {
|
||||
storage.set(key, value);
|
||||
setVal(value);
|
||||
} catch (e) {
|
||||
throw new Error('Unable to update storage with provided value');
|
||||
}
|
||||
},
|
||||
[key, storage]
|
||||
);
|
||||
|
||||
return [val, setStorage];
|
||||
}
|
38
x-pack/plugins/aiops/public/hooks/use_time_filter.ts
Normal file
38
x-pack/plugins/aiops/public/hooks/use_time_filter.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { useAiOpsKibana } from '../kibana_context';
|
||||
|
||||
interface UseTimefilterOptions {
|
||||
timeRangeSelector?: boolean;
|
||||
autoRefreshSelector?: boolean;
|
||||
}
|
||||
|
||||
export const useTimefilter = ({
|
||||
timeRangeSelector,
|
||||
autoRefreshSelector,
|
||||
}: UseTimefilterOptions = {}) => {
|
||||
const { services } = useAiOpsKibana();
|
||||
const { timefilter } = services.data.query.timefilter;
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRangeSelector === true) {
|
||||
timefilter.enableTimeRangeSelector();
|
||||
} else if (timeRangeSelector === false) {
|
||||
timefilter.disableTimeRangeSelector();
|
||||
}
|
||||
|
||||
if (autoRefreshSelector === true) {
|
||||
timefilter.enableAutoRefreshSelector();
|
||||
} else if (autoRefreshSelector === false) {
|
||||
timefilter.disableAutoRefreshSelector();
|
||||
}
|
||||
}, [timeRangeSelector, autoRefreshSelector, timefilter]);
|
||||
|
||||
return timefilter;
|
||||
};
|
18
x-pack/plugins/aiops/public/kibana_context.ts
Normal file
18
x-pack/plugins/aiops/public/kibana_context.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 { CoreStart } from '@kbn/core/public';
|
||||
import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { AiOpsStartDependencies } from './plugin';
|
||||
|
||||
export type StartServices = CoreStart &
|
||||
AiOpsStartDependencies & {
|
||||
storage: IStorageWrapper;
|
||||
};
|
||||
export type AiOpsKibanaReactContextValue = KibanaReactContextValue<StartServices>;
|
||||
export const useAiOpsKibana = () => useKibana<StartServices>();
|
15
x-pack/plugins/aiops/public/kibana_services.ts
Normal file
15
x-pack/plugins/aiops/public/kibana_services.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { CoreStart } from '@kbn/core/public';
|
||||
|
||||
let coreStart: CoreStart;
|
||||
export function setStartServices(core: CoreStart) {
|
||||
coreStart = core;
|
||||
}
|
||||
|
||||
export const getCoreStart = () => coreStart;
|
|
@ -6,11 +6,23 @@
|
|||
*/
|
||||
|
||||
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import { ChartsPluginStart } from '@kbn/charts-plugin/public';
|
||||
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
|
||||
import { AiopsPluginSetup, AiopsPluginStart } from './types';
|
||||
import { setStartServices } from './kibana_services';
|
||||
|
||||
export interface AiOpsStartDependencies {
|
||||
data: DataPublicPluginStart;
|
||||
charts: ChartsPluginStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
}
|
||||
|
||||
export class AiopsPlugin implements Plugin<AiopsPluginSetup, AiopsPluginStart> {
|
||||
public setup(core: CoreSetup) {}
|
||||
public start(core: CoreStart) {}
|
||||
public start(core: CoreStart) {
|
||||
setStartServices(core);
|
||||
}
|
||||
public stop() {}
|
||||
}
|
||||
|
|
87
x-pack/plugins/aiops/public/query_utils.ts
Normal file
87
x-pack/plugins/aiops/public/query_utils.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { Query } from '@kbn/es-query';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
|
||||
/*
|
||||
* Contains utility functions for building and processing queries.
|
||||
*/
|
||||
|
||||
// Builds the base filter criteria used in queries,
|
||||
// adding criteria for the time range and an optional query.
|
||||
export function buildBaseFilterCriteria(
|
||||
timeFieldName?: string,
|
||||
earliestMs?: number,
|
||||
latestMs?: number,
|
||||
query?: Query['query']
|
||||
): estypes.QueryDslQueryContainer[] {
|
||||
const filterCriteria = [];
|
||||
if (timeFieldName && earliestMs && latestMs) {
|
||||
filterCriteria.push({
|
||||
range: {
|
||||
[timeFieldName]: {
|
||||
gte: earliestMs,
|
||||
lte: latestMs,
|
||||
format: 'epoch_millis',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query && typeof query === 'object') {
|
||||
filterCriteria.push(query);
|
||||
}
|
||||
|
||||
return filterCriteria;
|
||||
}
|
||||
|
||||
export const addExcludeFrozenToQuery = (originalQuery: QueryDslQueryContainer | undefined) => {
|
||||
const FROZEN_TIER_TERM = {
|
||||
term: {
|
||||
_tier: {
|
||||
value: 'data_frozen',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (!originalQuery) {
|
||||
return {
|
||||
bool: {
|
||||
must_not: [FROZEN_TIER_TERM],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const query = cloneDeep(originalQuery);
|
||||
|
||||
delete query.match_all;
|
||||
|
||||
if (isPopulatedObject(query.bool)) {
|
||||
// Must_not can be both arrays or singular object
|
||||
if (Array.isArray(query.bool.must_not)) {
|
||||
query.bool.must_not.push(FROZEN_TIER_TERM);
|
||||
} else {
|
||||
// If there's already a must_not condition
|
||||
if (isPopulatedObject(query.bool.must_not)) {
|
||||
query.bool.must_not = [query.bool.must_not, FROZEN_TIER_TERM];
|
||||
}
|
||||
if (query.bool.must_not === undefined) {
|
||||
query.bool.must_not = [FROZEN_TIER_TERM];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query.bool = {
|
||||
must_not: [FROZEN_TIER_TERM],
|
||||
};
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
|
@ -11,7 +11,9 @@ import { EuiErrorBoundary, EuiLoadingContent } from '@elastic/eui';
|
|||
|
||||
import type { ExplainLogRateSpikesProps } from './components/explain_log_rate_spikes';
|
||||
|
||||
const ExplainLogRateSpikesLazy = React.lazy(() => import('./components/explain_log_rate_spikes'));
|
||||
const ExplainLogRateSpikesWrapperLazy = React.lazy(
|
||||
() => import('./components/explain_log_rate_spikes')
|
||||
);
|
||||
|
||||
const LazyWrapper: FC = ({ children }) => (
|
||||
<EuiErrorBoundary>
|
||||
|
@ -25,6 +27,6 @@ const LazyWrapper: FC = ({ children }) => (
|
|||
*/
|
||||
export const ExplainLogRateSpikes: FC<ExplainLogRateSpikesProps> = (props) => (
|
||||
<LazyWrapper>
|
||||
<ExplainLogRateSpikesLazy {...props} />
|
||||
<ExplainLogRateSpikesWrapperLazy {...props} />
|
||||
</LazyWrapper>
|
||||
);
|
||||
|
|
|
@ -24,5 +24,6 @@
|
|||
{ "path": "../../../src/plugins/navigation/tsconfig.json" },
|
||||
{ "path": "../../../src/plugins/unified_search/tsconfig.json" },
|
||||
{ "path": "../security/tsconfig.json" },
|
||||
{ "path": "../../../src/plugins/charts/tsconfig.json" },
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue