[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:
Melissa Alvarez 2022-07-12 14:35:50 -04:00 committed by GitHub
parent 8acc1466f8
commit 4ad614c578
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2732 additions and 25 deletions

View 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: '',
};
};

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

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

View 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',
};
}

View file

@ -10,10 +10,11 @@
"server": true,
"ui": true,
"requiredPlugins": [
"charts",
"data",
"licensing"
],
"optionalPlugins": [],
"requiredBundles": ["kibanaReact"],
"requiredBundles": ["kibanaReact", "fieldFormats"],
"extraPublicDirs": ["common"]
}

View file

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

View 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 { Subject } from 'rxjs';
export interface Refresh {
lastRefresh: number;
timeRange?: { start: string; end: string };
}
export const aiOpsRefresh$ = new Subject<Refresh>();

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { DatePickerWrapper } from './date_picker_wrapper';

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { DocumentCountContent } from './document_count_content';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { TotalCountHeader } from './total_count_header';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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,
};
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { FullTimeRangeSelector } from './full_time_range_selector';

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { SpikeAnalysisTable } from './spike_analysis_table';

View file

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

View 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,
};
};

View 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]);
};

View 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,
};
};

View 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]
);
}

View 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];
}

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

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

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

View file

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

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

View file

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

View file

@ -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" },
]
}