[AO] Create a new rule under Observability plugin based on the metrics threshold rule (#158665)

## Summary
### NOTE: This is a draft PR to MVP the new rule combination
(Threshold). More PRs to follow up
It fixes https://github.com/elastic/kibana/issues/158260 by providing
the _new_ **Threshold rule**
It fixes https://github.com/elastic/kibana/issues/159326

<img width="586" alt="Screenshot 2023-05-30 at 17 55 32"
src="0e485266-d93f-442e-81f4-77aa673ed497">


##  Done 

- [x] Clone the Metric threshold and update the imports 
- [x] The rule is listed in the rule creation flyout with its params and
preview charts
- [x] Working Rule registry
- [x] Working Rule executor 
- [x] Working feature id in the rule registry 
- [x] Working alerts table and alert summary
- [x] Use DataView instead of the Metrics indices under settings
- [x] Update the i18n keys 
- [x] Fix/Update failing checks/tests. Green CI  
- [x] Hide it behind a feature flag
`xpack.observability.unsafe.thresholdRule.enabled`




## 🏗️ To be done (could be irrelevant, or create a separate issue for
it):
- [ ] <del> Remove the `metrics` word </del>
- [ ] <del> Update file and variable names to match the new rule
context.</del>
- [ ] <del> Rearrange files, constants, and exports </del>

## 🎯 DoD 
Having the rule working like the Metric threshold one and seeing its
related alerts.

---------
This commit is contained in:
Faisal Kanout 2023-06-09 18:59:55 +03:00 committed by GitHub
parent e325d4102b
commit 2d4f19e2ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
133 changed files with 13494 additions and 51 deletions

View file

@ -2164,6 +2164,10 @@
}
}
},
"threshold-explorer-view": {
"dynamic": false,
"properties": {}
},
"observability-onboarding-state": {
"properties": {
"state": {

View file

@ -94,7 +94,7 @@ pageLoadAssetSize:
monitoring: 80000
navigation: 37269
newsfeed: 42228
observability: 95000
observability: 100000
observabilityOnboarding: 19573
observabilityShared: 52256
osquery: 107090

View file

@ -147,6 +147,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"tag": "e2544392fe6563e215bb677abc8b01c2601ef2dc",
"task": "04f30bd7bae923f3a53c31ab3b9745a93872fc02",
"telemetry": "7b00bcf1c7b4f6db1192bb7405a6a63e78b699fd",
"threshold-explorer-view": "175306806f9fc8e13fcc1c8953ec4ba89bda1b70",
"ui-metric": "d227284528fd19904e9d972aea0a13716fc5fe24",
"upgrade-assistant-ml-upgrade-operation": "421f52731cb24e242d70672ba4725e169277efb3",
"upgrade-assistant-reindex-operation": "01f3c3e051659ace56492a73928987e717537a93",

View file

@ -265,6 +265,7 @@ describe('split .kibana index into multiple system indices', () => {
"synthetics-privates-locations",
"tag",
"telemetry",
"threshold-explorer-view",
"ui-metric",
"upgrade-assistant-ml-upgrade-operation",
"upgrade-assistant-reindex-operation",

View file

@ -123,6 +123,7 @@ const previouslyRegisteredTypes = [
'telemetry',
'timelion-sheet',
'tsvb-validation-telemetry',
'threshold-explorer-view',
'ui-counter',
'ui-metric',
'upgrade-assistant-ml-upgrade-operation',

View file

@ -307,6 +307,7 @@ kibana_vars=(
xpack.observability.unsafe.alertDetails.metrics.enabled
xpack.observability.unsafe.alertDetails.logs.enabled
xpack.observability.unsafe.alertDetails.uptime.enabled
xpack.observability.unsafe.thresholdRule.enabled
xpack.reporting.capture.browser.autoDownload
xpack.reporting.capture.browser.chromium.disableSandbox
xpack.reporting.capture.browser.chromium.inspect

View file

@ -259,6 +259,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.observability.unsafe.alertDetails.metrics.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.logs.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)',
'xpack.observability.unsafe.thresholdRule.enabled (boolean)',
'xpack.observability_onboarding.ui.enabled (boolean)',
];
// We don't assert that actualExposedConfigKeys and expectedExposedConfigKeys are equal, because test failure messages with large

View file

@ -7,10 +7,11 @@
import { i18n } from '@kbn/i18n';
export const SLO_BURN_RATE_RULE_ID = 'slo.rules.burnRate';
export const SLO_BURN_RATE_RULE_TYPE_ID = 'slo.rules.burnRate';
export const OBSERVABILITY_THRESHOLD_RULE_TYPE_ID = 'observability.threshold';
export const INVALID_EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
export const ALERT_STATUS_ALL = 'all';
export const ALERTS_URL_STORAGE_KEY = '_a';
export const ALERT_ACTION_ID = 'slo.burnRate.alert';

View file

@ -45,7 +45,6 @@ export {
} from './progressive_loading';
export const sloFeatureId = 'slo';
export const casesFeatureId = 'observabilityCases';
// The ID of the observability app. Should more appropriately be called

View file

@ -0,0 +1,50 @@
/*
* 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 { difference, first, values } from 'lodash';
import { euiPaletteColorBlind } from '@elastic/eui';
export enum Color {
color0 = 'color0',
color1 = 'color1',
color2 = 'color2',
color3 = 'color3',
color4 = 'color4',
color5 = 'color5',
color6 = 'color6',
color7 = 'color7',
color8 = 'color8',
color9 = 'color9',
}
export type Palette = {
[K in keyof typeof Color]: string;
};
const euiPalette = euiPaletteColorBlind();
export const defaultPalette: Palette = {
[Color.color0]: euiPalette[1], // (blue)
[Color.color1]: euiPalette[2], // (pink)
[Color.color2]: euiPalette[0], // (green-ish)
[Color.color3]: euiPalette[3], // (purple)
[Color.color4]: euiPalette[4], // (light pink)
[Color.color5]: euiPalette[5], // (yellow)
[Color.color6]: euiPalette[6], // (tan)
[Color.color7]: euiPalette[7], // (orange)
[Color.color8]: euiPalette[8], // (brown)
[Color.color9]: euiPalette[9], // (red)
};
export const createPaletteTransformer = (palette: Palette) => (color: Color) => palette[color];
export const colorTransformer = createPaletteTransformer(defaultPalette);
export const sampleColor = (usedColors: Color[] = []): Color => {
const available = difference(values(Color) as Color[], usedColors);
return first(available) || Color.color0;
};

View file

@ -0,0 +1,21 @@
/*
* 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 const SNAPSHOT_CUSTOM_AGGREGATIONS = ['avg', 'max', 'min', 'rate'] as const;
export const TIMESTAMP_FIELD = '@timestamp';
export const METRIC_EXPLORER_AGGREGATIONS = [
'avg',
'max',
'min',
'cardinality',
'rate',
'count',
'sum',
'p95',
'p99',
'custom',
] as const;

View file

@ -0,0 +1,24 @@
/*
* 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 { InfraWaffleMapDataFormat } from './types';
import { createBytesFormatter } from './bytes';
describe('createDataFormatter', () => {
it('should format bytes as bytesDecimal', () => {
const formatter = createBytesFormatter(InfraWaffleMapDataFormat.bytesDecimal);
expect(formatter(1000000)).toBe('1 MB');
});
it('should format bytes as bitsDecimal', () => {
const formatter = createBytesFormatter(InfraWaffleMapDataFormat.bitsDecimal);
expect(formatter(1000000)).toBe('8 Mbit');
});
it('should format bytes as abbreviatedNumber', () => {
const formatter = createBytesFormatter(InfraWaffleMapDataFormat.abbreviatedNumber);
expect(formatter(1000000)).toBe('1 M');
});
});

View file

@ -0,0 +1,53 @@
/*
* 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 { formatNumber } from './number';
import { InfraWaffleMapDataFormat } from './types';
/**
* The labels are derived from these two Wikipedia articles.
* https://en.wikipedia.org/wiki/Kilobit
* https://en.wikipedia.org/wiki/Kilobyte
*/
const LABELS = {
[InfraWaffleMapDataFormat.bytesDecimal]: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
[InfraWaffleMapDataFormat.bitsDecimal]: [
'bit',
'kbit',
'Mbit',
'Gbit',
'Tbit',
'Pbit',
'Ebit',
'Zbit',
'Ybit',
],
[InfraWaffleMapDataFormat.abbreviatedNumber]: ['', 'K', 'M', 'B', 'T'],
};
const BASES = {
[InfraWaffleMapDataFormat.bytesDecimal]: 1000,
[InfraWaffleMapDataFormat.bitsDecimal]: 1000,
[InfraWaffleMapDataFormat.abbreviatedNumber]: 1000,
};
/*
* This formatter always assumes you're input is bytes and the output is a string
* in whatever format you've defined. Bytes in Format Out.
*/
export const createBytesFormatter = (format: InfraWaffleMapDataFormat) => (bytes: number) => {
const labels = LABELS[format];
const base = BASES[format];
const value = format === InfraWaffleMapDataFormat.bitsDecimal ? bytes * 8 : bytes;
// Use an exponential equation to get the power to determine which label to use. If the power
// is greater then the max label then use the max label.
const power = Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(base)), labels.length - 1);
if (power < 0) {
return `${formatNumber(value)} ${labels[0]}`;
}
return `${formatNumber(value / Math.pow(base, power))} ${labels[power]}`;
};

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 { i18n } from '@kbn/i18n';
export function localizedDate(dateTime: number | Date, locale: string = i18n.getLocale()) {
const formatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return formatter.format(dateTime);
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const formatHighPrecision = (val: number) => {
return Number(val).toLocaleString('en', {
maximumFractionDigits: 5,
});
};

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 { createBytesFormatter } from './bytes';
import { formatNumber } from './number';
import { formatPercent } from './percent';
import { ThresholdFormatterType } from '../types';
import { formatHighPrecision } from './high_precision';
import { InfraWaffleMapDataFormat } from './types';
export const FORMATTERS = {
number: formatNumber,
// Because the implimentation for formatting large numbers is the same as formatting
// bytes we are re-using the same code, we just format the number using the abbreviated number format.
abbreviatedNumber: createBytesFormatter(InfraWaffleMapDataFormat.abbreviatedNumber),
// bytes in bytes formatted string out
bytes: createBytesFormatter(InfraWaffleMapDataFormat.bytesDecimal),
// bytes in bits formatted string out
bits: createBytesFormatter(InfraWaffleMapDataFormat.bitsDecimal),
percent: formatPercent,
highPrecision: formatHighPrecision,
};
export const createFormatter =
(format: ThresholdFormatterType, template: string = '{{value}}') =>
(val: string | number) => {
if (val == null) {
return '';
}
const fmtFn = FORMATTERS[format];
const value = fmtFn(Number(val));
return template.replace(/{{value}}/g, value);
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const formatNumber = (val: number) => {
return Number(val).toLocaleString('en', {
maximumFractionDigits: 1,
});
};

View file

@ -0,0 +1,11 @@
/*
* 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 { formatNumber } from './number';
export const formatPercent = (val: number) => {
return `${formatNumber(val * 100)}%`;
};

View file

@ -0,0 +1,78 @@
/*
* 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.
*/
enum InfraFormatterType {
number = 'number',
abbreviatedNumber = 'abbreviatedNumber',
bytes = 'bytes',
bits = 'bits',
percent = 'percent',
}
interface MetricFormatter {
formatter: InfraFormatterType;
template: string;
bounds?: { min: number; max: number };
}
interface MetricFormatters {
[key: string]: MetricFormatter;
}
export const METRIC_FORMATTERS: MetricFormatters = {
['count']: { formatter: InfraFormatterType.number, template: '{{value}}' },
['cpu']: {
formatter: InfraFormatterType.percent,
template: '{{value}}',
},
['memory']: {
formatter: InfraFormatterType.percent,
template: '{{value}}',
},
['rx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' },
['tx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' },
['logRate']: {
formatter: InfraFormatterType.abbreviatedNumber,
template: '{{value}}/s',
},
['diskIOReadBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}/s',
},
['diskIOWriteBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}/s',
},
['s3BucketSize']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}',
},
['s3TotalRequests']: {
formatter: InfraFormatterType.abbreviatedNumber,
template: '{{value}}',
},
['s3NumberOfObjects']: {
formatter: InfraFormatterType.abbreviatedNumber,
template: '{{value}}',
},
['s3UploadBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}',
},
['s3DownloadBytes']: {
formatter: InfraFormatterType.bytes,
template: '{{value}}',
},
['sqsOldestMessage']: {
formatter: InfraFormatterType.number,
template: '{{value}} seconds',
},
['rdsLatency']: {
formatter: InfraFormatterType.number,
template: '{{value}} ms',
},
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum InfraWaffleMapDataFormat {
bytesDecimal = 'bytesDecimal',
bitsDecimal = 'bitsDecimal',
abbreviatedNumber = 'abbreviatedNumber',
}

View file

@ -0,0 +1,27 @@
/*
* 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 { metricValueFormatter } from './metric_value_formatter';
describe('metricValueFormatter', () => {
const testData = [
{ value: null, metric: undefined, result: '[NO DATA]' },
{ value: null, metric: 'system.cpu.user.pct', result: '[NO DATA]' },
{ value: 50, metric: undefined, result: '50' },
{ value: 0.7, metric: 'system.cpu.user.pct', result: '70%' },
{ value: 0.7012345, metric: 'system.cpu.user.pct', result: '70.1%' },
{ value: 208, metric: 'system.cpu.user.ticks', result: '208' },
{ value: 0.8, metric: 'system.cpu.user.ticks', result: '0.8' },
];
it.each(testData)(
'metricValueFormatter($value, $metric) = $result',
({ value, metric, result }) => {
expect(metricValueFormatter(value, metric)).toBe(result);
}
);
});

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { createFormatter } from './formatters';
export const metricValueFormatter = (value: number | null, metric: string = '') => {
const noDataValue = i18n.translate(
'xpack.observability.threshold.rule.alerting.noDataFormattedValue',
{
defaultMessage: '[NO DATA]',
}
);
const formatter = metric.endsWith('.pct')
? createFormatter('percent')
: createFormatter('highPrecision');
return value == null ? noDataValue : formatter(value);
};

View file

@ -0,0 +1,166 @@
/*
* 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 * as rt from 'io-ts';
import { xor } from 'lodash';
export const METRIC_EXPLORER_AGGREGATIONS = [
'avg',
'max',
'min',
'cardinality',
'rate',
'count',
'sum',
'p95',
'p99',
'custom',
] as const;
export const OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS = ['custom', 'rate', 'p95', 'p99'];
type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number];
const metricsExplorerAggregationKeys = METRIC_EXPLORER_AGGREGATIONS.reduce<
Record<MetricExplorerAggregations, null>
>((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<MetricExplorerAggregations, null>);
export const metricsExplorerAggregationRT = rt.keyof(metricsExplorerAggregationKeys);
export type MetricExplorerCustomMetricAggregations = Exclude<
MetricsExplorerAggregation,
'custom' | 'rate' | 'p95' | 'p99'
>;
const metricsExplorerCustomMetricAggregationKeys = xor(
METRIC_EXPLORER_AGGREGATIONS,
OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS
).reduce<Record<MetricExplorerCustomMetricAggregations, null>>(
(acc, agg) => ({ ...acc, [agg]: null }),
{} as Record<MetricExplorerCustomMetricAggregations, null>
);
export const metricsExplorerCustomMetricAggregationRT = rt.keyof(
metricsExplorerCustomMetricAggregationKeys
);
export const metricsExplorerMetricRequiredFieldsRT = rt.type({
aggregation: metricsExplorerAggregationRT,
});
export const metricsExplorerCustomMetricRT = rt.intersection([
rt.type({
name: rt.string,
aggregation: metricsExplorerCustomMetricAggregationRT,
}),
rt.partial({
field: rt.string,
filter: rt.string,
}),
]);
export type MetricsExplorerCustomMetric = rt.TypeOf<typeof metricsExplorerCustomMetricRT>;
export const metricsExplorerMetricOptionalFieldsRT = rt.partial({
field: rt.union([rt.string, rt.undefined]),
custom_metrics: rt.array(metricsExplorerCustomMetricRT),
equation: rt.string,
});
export const metricsExplorerMetricRT = rt.intersection([
metricsExplorerMetricRequiredFieldsRT,
metricsExplorerMetricOptionalFieldsRT,
]);
export const timeRangeRT = rt.type({
from: rt.number,
to: rt.number,
interval: rt.string,
});
export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({
timerange: timeRangeRT,
indexPattern: rt.string,
metrics: rt.array(metricsExplorerMetricRT),
});
const groupByRT = rt.union([rt.string, rt.null, rt.undefined]);
export const afterKeyObjectRT = rt.record(rt.string, rt.union([rt.string, rt.null]));
export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({
groupBy: rt.union([groupByRT, rt.array(groupByRT)]),
afterKey: rt.union([rt.string, rt.null, rt.undefined, afterKeyObjectRT]),
limit: rt.union([rt.number, rt.null, rt.undefined]),
filterQuery: rt.union([rt.string, rt.null, rt.undefined]),
forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
});
export const metricsExplorerRequestBodyRT = rt.intersection([
metricsExplorerRequestBodyRequiredFieldsRT,
metricsExplorerRequestBodyOptionalFieldsRT,
]);
export const metricsExplorerPageInfoRT = rt.type({
total: rt.number,
afterKey: rt.union([rt.string, rt.null, afterKeyObjectRT]),
});
export const metricsExplorerColumnTypeRT = rt.keyof({
date: null,
number: null,
string: null,
});
export const metricsExplorerColumnRT = rt.type({
name: rt.string,
type: metricsExplorerColumnTypeRT,
});
export const metricsExplorerRowRT = rt.intersection([
rt.type({
timestamp: rt.number,
}),
rt.record(
rt.string,
rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)])
),
]);
export const metricsExplorerSeriesRT = rt.intersection([
rt.type({
id: rt.string,
columns: rt.array(metricsExplorerColumnRT),
rows: rt.array(metricsExplorerRowRT),
}),
rt.partial({
keys: rt.array(rt.string),
}),
]);
export const metricsExplorerResponseRT = rt.type({
series: rt.array(metricsExplorerSeriesRT),
pageInfo: metricsExplorerPageInfoRT,
});
export type AfterKey = rt.TypeOf<typeof afterKeyObjectRT>;
export type MetricsExplorerAggregation = rt.TypeOf<typeof metricsExplorerAggregationRT>;
export type MetricsExplorerColumnType = rt.TypeOf<typeof metricsExplorerColumnTypeRT>;
export type MetricsExplorerMetric = rt.TypeOf<typeof metricsExplorerMetricRT>;
export type MetricsExplorerPageInfo = rt.TypeOf<typeof metricsExplorerPageInfoRT>;
export type MetricsExplorerColumn = rt.TypeOf<typeof metricsExplorerColumnRT>;
export type MetricsExplorerRow = rt.TypeOf<typeof metricsExplorerRowRT>;
export type MetricsExplorerSeries = rt.TypeOf<typeof metricsExplorerSeriesRT>;
export type MetricsExplorerRequestBody = rt.TypeOf<typeof metricsExplorerRequestBodyRT>;
export type MetricsExplorerResponse = rt.TypeOf<typeof metricsExplorerResponseRT>;

View file

@ -0,0 +1,372 @@
/*
* 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 * as rt from 'io-ts';
import { indexPatternRt } from '@kbn/io-ts-utils';
import { ML_ANOMALY_THRESHOLD } from '@kbn/ml-anomaly-utils/anomaly_threshold';
import { values } from 'lodash';
import { Color } from './color_palette';
import { metricsExplorerMetricRT } from './metrics_explorer';
import { TimeUnitChar } from '../utils/formatters/duration';
import { SNAPSHOT_CUSTOM_AGGREGATIONS } from './constants';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface DeepPartialArray<T> extends Array<DeepPartial<T>> {}
type DeepPartialObject<T> = { [P in keyof T]+?: DeepPartial<T[P]> };
export type DeepPartial<T> = T extends any[]
? DeepPartialArray<T[number]>
: T extends object
? DeepPartialObject<T>
: T;
export const ThresholdFormatterTypeRT = rt.keyof({
abbreviatedNumber: null,
bits: null,
bytes: null,
number: null,
percent: null,
highPrecision: null,
});
export type ThresholdFormatterType = rt.TypeOf<typeof ThresholdFormatterTypeRT>;
const pointRT = rt.type({
timestamp: rt.number,
value: rt.number,
});
export type Point = rt.TypeOf<typeof pointRT>;
const serieRT = rt.type({
id: rt.string,
points: rt.array(pointRT),
});
const seriesRT = rt.array(serieRT);
export type Series = rt.TypeOf<typeof seriesRT>;
export const getLogAlertsChartPreviewDataSuccessResponsePayloadRT = rt.type({
data: rt.type({
series: seriesRT,
}),
});
export type GetLogAlertsChartPreviewDataSuccessResponsePayload = rt.TypeOf<
typeof getLogAlertsChartPreviewDataSuccessResponsePayloadRT
>;
/**
* Properties specific to the Metrics Source Configuration.
*/
export const SourceConfigurationTimestampColumnRuntimeType = rt.type({
timestampColumn: rt.type({
id: rt.string,
}),
});
export const SourceConfigurationMessageColumnRuntimeType = rt.type({
messageColumn: rt.type({
id: rt.string,
}),
});
export const SourceConfigurationFieldColumnRuntimeType = rt.type({
fieldColumn: rt.type({
id: rt.string,
field: rt.string,
}),
});
export const SourceConfigurationColumnRuntimeType = rt.union([
SourceConfigurationTimestampColumnRuntimeType,
SourceConfigurationMessageColumnRuntimeType,
SourceConfigurationFieldColumnRuntimeType,
]);
// Kibana data views
export const logDataViewReferenceRT = rt.type({
type: rt.literal('data_view'),
dataViewId: rt.string,
});
export type LogDataViewReference = rt.TypeOf<typeof logDataViewReferenceRT>;
// Index name
export const logIndexNameReferenceRT = rt.type({
type: rt.literal('index_name'),
indexName: rt.string,
});
export type LogIndexNameReference = rt.TypeOf<typeof logIndexNameReferenceRT>;
export const logIndexReferenceRT = rt.union([logDataViewReferenceRT, logIndexNameReferenceRT]);
/**
* Properties that represent a full source configuration, which is the result of merging static values with
* saved values.
*/
const SourceConfigurationFieldsRT = rt.type({
message: rt.array(rt.string),
});
export const SourceConfigurationRT = rt.type({
name: rt.string,
description: rt.string,
metricAlias: rt.string,
logIndices: logIndexReferenceRT,
inventoryDefaultView: rt.string,
metricsExplorerDefaultView: rt.string,
fields: SourceConfigurationFieldsRT,
logColumns: rt.array(SourceConfigurationColumnRuntimeType),
anomalyThreshold: rt.number,
});
export const metricsSourceConfigurationPropertiesRT = rt.strict({
name: SourceConfigurationRT.props.name,
description: SourceConfigurationRT.props.description,
metricAlias: SourceConfigurationRT.props.metricAlias,
inventoryDefaultView: SourceConfigurationRT.props.inventoryDefaultView,
metricsExplorerDefaultView: SourceConfigurationRT.props.metricsExplorerDefaultView,
anomalyThreshold: rt.number,
});
export type MetricsSourceConfigurationProperties = rt.TypeOf<
typeof metricsSourceConfigurationPropertiesRT
>;
export const partialMetricsSourceConfigurationReqPayloadRT = rt.partial({
...metricsSourceConfigurationPropertiesRT.type.props,
metricAlias: indexPatternRt,
});
export const partialMetricsSourceConfigurationPropertiesRT = rt.partial({
...metricsSourceConfigurationPropertiesRT.type.props,
});
export type PartialMetricsSourceConfigurationProperties = rt.TypeOf<
typeof partialMetricsSourceConfigurationPropertiesRT
>;
const metricsSourceConfigurationOriginRT = rt.keyof({
fallback: null,
internal: null,
stored: null,
});
/**
* Source status
*/
const SourceStatusFieldRuntimeType = rt.type({
name: rt.string,
type: rt.string,
searchable: rt.boolean,
aggregatable: rt.boolean,
displayable: rt.boolean,
});
export const SourceStatusRuntimeType = rt.type({
logIndicesExist: rt.boolean,
metricIndicesExist: rt.boolean,
remoteClustersExist: rt.boolean,
indexFields: rt.array(SourceStatusFieldRuntimeType),
});
export const metricsSourceStatusRT = rt.strict({
metricIndicesExist: SourceStatusRuntimeType.props.metricIndicesExist,
remoteClustersExist: SourceStatusRuntimeType.props.metricIndicesExist,
indexFields: SourceStatusRuntimeType.props.indexFields,
});
export type MetricsSourceStatus = rt.TypeOf<typeof metricsSourceStatusRT>;
export const metricsSourceConfigurationRT = rt.exact(
rt.intersection([
rt.type({
id: rt.string,
origin: metricsSourceConfigurationOriginRT,
configuration: metricsSourceConfigurationPropertiesRT,
}),
rt.partial({
updatedAt: rt.number,
version: rt.string,
status: metricsSourceStatusRT,
}),
])
);
export type MetricsSourceConfiguration = rt.TypeOf<typeof metricsSourceConfigurationRT>;
export type PartialMetricsSourceConfiguration = DeepPartial<MetricsSourceConfiguration>;
export const metricsSourceConfigurationResponseRT = rt.type({
source: metricsSourceConfigurationRT,
});
export type MetricsSourceConfigurationResponse = rt.TypeOf<
typeof metricsSourceConfigurationResponseRT
>;
export enum Comparator {
GT = '>',
LT = '<',
GT_OR_EQ = '>=',
LT_OR_EQ = '<=',
BETWEEN = 'between',
OUTSIDE_RANGE = 'outside',
}
export enum Aggregators {
COUNT = 'count',
AVERAGE = 'avg',
SUM = 'sum',
MIN = 'min',
MAX = 'max',
RATE = 'rate',
CARDINALITY = 'cardinality',
P95 = 'p95',
P99 = 'p99',
CUSTOM = 'custom',
}
const metricsExplorerOptionsMetricRT = rt.intersection([
metricsExplorerMetricRT,
rt.partial({
rate: rt.boolean,
color: rt.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record<Color, null>),
label: rt.string,
}),
]);
export type MetricsExplorerOptionsMetric = rt.TypeOf<typeof metricsExplorerOptionsMetricRT>;
export enum MetricsExplorerChartType {
line = 'line',
area = 'area',
bar = 'bar',
}
export enum InfraRuleType {
MetricThreshold = 'metrics.alert.threshold',
InventoryThreshold = 'metrics.alert.inventory.threshold',
Anomaly = 'metrics.alert.anomaly',
}
export enum AlertStates {
OK,
ALERT,
WARNING,
NO_DATA,
ERROR,
}
const metricAnomalyNodeTypeRT = rt.union([rt.literal('hosts'), rt.literal('k8s')]);
const metricAnomalyMetricRT = rt.union([
rt.literal('memory_usage'),
rt.literal('network_in'),
rt.literal('network_out'),
]);
const metricAnomalyInfluencerFilterRT = rt.type({
fieldName: rt.string,
fieldValue: rt.string,
});
export interface MetricAnomalyParams {
nodeType: rt.TypeOf<typeof metricAnomalyNodeTypeRT>;
metric: rt.TypeOf<typeof metricAnomalyMetricRT>;
alertInterval?: string;
sourceId?: string;
spaceId?: string;
threshold: Exclude<ML_ANOMALY_THRESHOLD, ML_ANOMALY_THRESHOLD.LOW>;
influencerFilter: rt.TypeOf<typeof metricAnomalyInfluencerFilterRT> | undefined;
}
// Types for the executor
export interface MetricThresholdParams {
criteria: MetricExpressionParams[];
filterQuery?: string;
filterQueryText?: string;
sourceId?: string;
alertOnNoData?: boolean;
alertOnGroupDisappear?: boolean;
}
interface BaseMetricExpressionParams {
timeSize: number;
timeUnit: TimeUnitChar;
sourceId?: string;
threshold: number[];
comparator: Comparator;
warningComparator?: Comparator;
warningThreshold?: number[];
}
export interface NonCountMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Exclude<Aggregators, [Aggregators.COUNT, Aggregators.CUSTOM]>;
metric: string;
}
export interface CountMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Aggregators.COUNT;
}
export type CustomMetricAggTypes = Exclude<
Aggregators,
Aggregators.CUSTOM | Aggregators.RATE | Aggregators.P95 | Aggregators.P99
>;
export interface MetricExpressionCustomMetric {
name: string;
aggType: CustomMetricAggTypes;
field?: string;
filter?: string;
}
export interface CustomMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Aggregators.CUSTOM;
customMetrics: MetricExpressionCustomMetric[];
equation?: string;
label?: string;
}
export type MetricExpressionParams =
| NonCountMetricExpressionParams
| CountMetricExpressionParams
| CustomMetricExpressionParams;
export const QUERY_INVALID: unique symbol = Symbol('QUERY_INVALID');
export type FilterQuery = string | typeof QUERY_INVALID;
export interface AlertExecutionDetails {
alertId: string;
executionId: string;
}
export enum InfraFormatterType {
number = 'number',
abbreviatedNumber = 'abbreviatedNumber',
bytes = 'bytes',
bits = 'bits',
percent = 'percent',
}
export type SnapshotCustomAggregation = typeof SNAPSHOT_CUSTOM_AGGREGATIONS[number];
const snapshotCustomAggregationKeys = SNAPSHOT_CUSTOM_AGGREGATIONS.reduce<
Record<SnapshotCustomAggregation, null>
>((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<SnapshotCustomAggregation, null>);
export const SnapshotCustomAggregationRT = rt.keyof(snapshotCustomAggregationKeys);
export const SnapshotCustomMetricInputRT = rt.intersection([
rt.type({
type: rt.literal('custom'),
field: rt.string,
aggregation: SnapshotCustomAggregationRT,
id: rt.string,
}),
rt.partial({
label: rt.string,
}),
]);
export type SnapshotCustomMetricInput = rt.TypeOf<typeof SnapshotCustomMetricInputRT>;

View file

@ -14,4 +14,5 @@ export const observabilityAlertFeatureIds: ValidFeatureId[] = [
AlertConsumers.LOGS,
AlertConsumers.UPTIME,
AlertConsumers.SLO,
AlertConsumers.OBSERVABILITY,
];

View file

@ -85,6 +85,7 @@ const withCore = makeDecorator({
metrics: { enabled: false },
uptime: { enabled: false },
},
thresholdRule: { enabled: false },
},
coPilot: {
enabled: false,

View file

@ -41,6 +41,7 @@ jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({
metrics: { enabled: false },
uptime: { enabled: false },
},
thresholdRule: { enabled: false },
},
coPilot: {
enabled: false,

View file

@ -16,7 +16,7 @@ import { useCloneSlo } from '../../../hooks/slo/use_clone_slo';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
import { isApmIndicatorType } from '../../../utils/slo/indicator';
import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url';
import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
import { paths } from '../../../config/paths';
import {
@ -253,7 +253,7 @@ export function HeaderControl({ isLoading, slo }: Props) {
{!!slo && isRuleFlyoutVisible ? (
<AddRuleFlyout
consumer={sloFeatureId}
ruleTypeId={SLO_BURN_RATE_RULE_ID}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
canChangeTrigger={false}
onClose={onCloseRuleFlyout}
initialValues={{ name: `${slo.name} burn rate`, params: { sloId: slo.id } }}

View file

@ -36,7 +36,7 @@ import {
transformValuesToUpdateSLOInput,
} from '../helpers/process_slo_form_values';
import { paths } from '../../../config/paths';
import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
import { sloFeatureId } from '../../../../common';
@ -304,7 +304,7 @@ export function SloEditForm({ slo }: Props) {
canChangeTrigger={false}
consumer={sloFeatureId}
initialValues={{ name: `${slo.name} Burn Rate rule`, params: { sloId: slo.id } }}
ruleTypeId={SLO_BURN_RATE_RULE_ID}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
onClose={handleCloseRuleFlyout}
onSave={handleCloseRuleFlyout}
/>

View file

@ -33,7 +33,7 @@ import {
transformSloResponseToCreateSloInput,
transformValuesToCreateSLOInput,
} from '../../slo_edit/helpers/process_slo_form_values';
import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
import { paths } from '../../../config/paths';
import type { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
@ -271,7 +271,7 @@ export function SloListItem({
<AddRuleFlyout
consumer={sloFeatureId}
filteredRuleTypes={filteredRuleTypes}
ruleTypeId={SLO_BURN_RATE_RULE_ID}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
initialValues={{ name: `${slo.name} Burn Rate rule`, params: { sloId: slo.id } }}
onSave={handleSavedRule}
onClose={() => {

View file

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertDetailsAppSection should render annotations 1`] = `
Array [
Object {
"annotations": Array [
<AlertAnnotation
alertStart={1678716383695}
color="#BD271E"
dateFormat="YYYY-MM-DD HH:mm"
id="alert_start_annotation"
/>,
<AlertActiveTimeRangeAnnotation
alertStart={1678716383695}
color="#BD271E"
id="alert_time_range_annotation"
/>,
],
"chartType": "line",
"derivedIndexPattern": Object {
"fields": Array [],
"title": "metricbeat-*",
},
"expression": Object {
"aggType": "count",
"comparator": ">",
"threshold": Array [
2000,
],
"timeSize": 15,
"timeUnit": "m",
},
"filterQuery": undefined,
"groupBy": Array [
"host.hostname",
],
"hideTitle": true,
"source": Object {
"id": "default",
},
"timeRange": Object {
"from": "2023-03-28T10:43:13.802Z",
"to": "2023-03-29T13:14:09.581Z",
},
},
Object {},
]
`;

View file

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExpressionRow should render a helpText for the of expression 1`] = `
<FormattedMessage
defaultMessage="Can't find a metric? {documentationLink}."
id="xpack.observability.threshold.rule.alertFlyout.ofExpression.helpTextDetail"
values={
Object {
"documentationLink": <EuiLink
data-test-subj="thresholdRuleExpressionRowLearnHowToAddMoreDataLink"
href="https://www.elastic.co/guide/en/observability/current/configure-settings.html"
target="BLANK"
>
<FormattedMessage
defaultMessage="Learn how to add more data"
id="xpack.observability.threshold.rule.alertFlyout.ofExpression.popoverLinkLabel"
values={Object {}}
/>
</EuiLink>,
}
}
/>
`;

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiLink } from '@elastic/eui';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { coreMock as mockCoreMock } from '@kbn/core/public/mocks';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
buildMetricThresholdAlert,
buildMetricThresholdRule,
} from '../mocks/metric_threshold_rule';
import { AlertDetailsAppSection } from './alert_details_app_section';
import { ExpressionChart } from './expression_chart';
const mockedChartStartContract = chartPluginMock.createStartContract();
jest.mock('@kbn/observability-alert-details', () => ({
AlertAnnotation: () => {},
AlertActiveTimeRangeAnnotation: () => {},
getPaddedAlertTimeRange: () => ({
from: '2023-03-28T10:43:13.802Z',
to: '2023-03-29T13:14:09.581Z',
}),
}));
jest.mock('./expression_chart', () => ({
ExpressionChart: jest.fn(() => <div data-test-subj="ExpressionChart" />),
}));
jest.mock('../../../utils/kibana_react', () => ({
useKibana: () => ({
services: {
...mockCoreMock.createStart(),
charts: mockedChartStartContract,
},
}),
}));
jest.mock('../helpers/source', () => ({
withSourceProvider: () => jest.fn,
useSourceContext: () => ({
source: { id: 'default' },
createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }),
}),
}));
describe('AlertDetailsAppSection', () => {
const queryClient = new QueryClient();
const mockedSetAlertSummaryFields = jest.fn();
const ruleLink = 'ruleLink';
const renderComponent = () => {
return render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<AlertDetailsAppSection
alert={buildMetricThresholdAlert()}
rule={buildMetricThresholdRule()}
ruleLink={ruleLink}
setAlertSummaryFields={mockedSetAlertSummaryFields}
/>
</QueryClientProvider>
</IntlProvider>
);
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render rule and alert data', async () => {
const result = renderComponent();
expect((await result.findByTestId('metricThresholdAppSection')).children.length).toBe(3);
expect(result.getByTestId('threshold-2000-2500')).toBeTruthy();
});
it('should render rule link', async () => {
renderComponent();
expect(mockedSetAlertSummaryFields).toBeCalledTimes(1);
expect(mockedSetAlertSummaryFields).toBeCalledWith([
{
label: 'Rule',
value: (
<EuiLink data-test-subj="alertDetailsAppSectionRuleLink" href={ruleLink}>
Monitoring hosts
</EuiLink>
),
},
]);
});
it('should render annotations', async () => {
const mockedExpressionChart = jest.fn(() => <div data-test-subj="ExpressionChart" />);
(ExpressionChart as jest.Mock).mockImplementation(mockedExpressionChart);
renderComponent();
expect(mockedExpressionChart).toHaveBeenCalledTimes(3);
expect(mockedExpressionChart.mock.calls[0]).toMatchSnapshot();
});
});

View file

@ -0,0 +1,177 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useMemo } from 'react';
import moment from 'moment';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { ALERT_END, ALERT_START, ALERT_EVALUATION_VALUES } from '@kbn/rule-data-utils';
import { Rule } from '@kbn/alerting-plugin/common';
import {
AlertAnnotation,
getPaddedAlertTimeRange,
AlertActiveTimeRangeAnnotation,
} from '@kbn/observability-alert-details';
import { useKibana } from '../../../utils/kibana_react';
import { metricValueFormatter } from '../../../../common/threshold_rule/metric_value_formatter';
import { AlertSummaryField, TopAlert } from '../../..';
import { generateUniqueKey } from '../lib/generate_unique_key';
import { ExpressionChart } from './expression_chart';
import { TIME_LABELS } from './criterion_preview_chart/criterion_preview_chart';
import { Threshold } from './threshold';
import { MetricsExplorerChartType } from '../hooks/use_metrics_explorer_options';
import { useSourceContext, withSourceProvider } from '../helpers/source';
import { MetricThresholdRuleTypeParams } from '../types';
// TODO Use a generic props for app sections https://github.com/elastic/kibana/issues/152690
export type MetricThresholdRule = Rule<
MetricThresholdRuleTypeParams & {
filterQueryText?: string;
groupBy?: string | string[];
}
>;
export type MetricThresholdAlert = TopAlert;
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm';
const ALERT_START_ANNOTATION_ID = 'alert_start_annotation';
const ALERT_TIME_RANGE_ANNOTATION_ID = 'alert_time_range_annotation';
interface AppSectionProps {
alert: MetricThresholdAlert;
rule: MetricThresholdRule;
ruleLink: string;
setAlertSummaryFields: React.Dispatch<React.SetStateAction<AlertSummaryField[] | undefined>>;
}
export function AlertDetailsAppSection({
alert,
rule,
ruleLink,
setAlertSummaryFields,
}: AppSectionProps) {
const { uiSettings, charts } = useKibana().services;
const { source, createDerivedIndexPattern } = useSourceContext();
const { euiTheme } = useEuiTheme();
const derivedIndexPattern = useMemo(
() => createDerivedIndexPattern(),
[createDerivedIndexPattern]
);
const chartProps = {
theme: charts.theme.useChartsTheme(),
baseTheme: charts.theme.useChartsBaseTheme(),
};
const timeRange = getPaddedAlertTimeRange(alert.fields[ALERT_START]!, alert.fields[ALERT_END]);
const alertEnd = alert.fields[ALERT_END] ? moment(alert.fields[ALERT_END]).valueOf() : undefined;
const annotations = [
<AlertAnnotation
alertStart={alert.start}
color={euiTheme.colors.danger}
dateFormat={uiSettings.get('dateFormat') || DEFAULT_DATE_FORMAT}
id={ALERT_START_ANNOTATION_ID}
key={ALERT_START_ANNOTATION_ID}
/>,
<AlertActiveTimeRangeAnnotation
alertStart={alert.start}
alertEnd={alertEnd}
color={euiTheme.colors.danger}
id={ALERT_TIME_RANGE_ANNOTATION_ID}
key={ALERT_TIME_RANGE_ANNOTATION_ID}
/>,
];
useEffect(() => {
setAlertSummaryFields([
{
label: i18n.translate(
'xpack.observability.threshold.rule.alertDetailsAppSection.summaryField.rule',
{
defaultMessage: 'Rule',
}
),
value: (
<EuiLink data-test-subj="alertDetailsAppSectionRuleLink" href={ruleLink}>
{rule.name}
</EuiLink>
),
},
]);
}, [alert, rule, ruleLink, setAlertSummaryFields]);
return !!rule.params.criteria ? (
<EuiFlexGroup direction="column" data-test-subj="metricThresholdAppSection">
{rule.params.criteria.map((criterion, index) => (
<EuiFlexItem key={generateUniqueKey(criterion)}>
<EuiPanel hasBorder hasShadow={false}>
<EuiTitle size="xs">
<h4>
{criterion.aggType.toUpperCase()}{' '}
{'metric' in criterion ? criterion.metric : undefined}
</h4>
</EuiTitle>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.observability.threshold.rule.alertDetailsAppSection.criterion.subtitle"
defaultMessage="Last {lookback} {timeLabel}"
values={{
lookback: criterion.timeSize,
timeLabel: TIME_LABELS[criterion.timeUnit as keyof typeof TIME_LABELS],
}}
/>
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem style={{ minHeight: 150, minWidth: 160 }} grow={1}>
<Threshold
chartProps={chartProps}
id={`threshold-${generateUniqueKey(criterion)}`}
threshold={criterion.threshold[0]}
value={alert.fields[ALERT_EVALUATION_VALUES]![index]}
valueFormatter={(d) =>
metricValueFormatter(d, 'metric' in criterion ? criterion.metric : undefined)
}
title={i18n.translate(
'xpack.observability.threshold.rule.alertDetailsAppSection.thresholdTitle',
{
defaultMessage: 'Threshold breached',
}
)}
comparator={criterion.comparator}
/>
</EuiFlexItem>
<EuiFlexItem grow={5}>
<ExpressionChart
annotations={annotations}
chartType={MetricsExplorerChartType.line}
derivedIndexPattern={derivedIndexPattern}
expression={criterion}
filterQuery={rule.params.filterQueryText}
groupBy={rule.params.groupBy}
hideTitle
source={source}
timeRange={timeRange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
) : null;
}
// eslint-disable-next-line import/no-default-export
export default withSourceProvider<AppSectionProps>(AlertDetailsAppSection)('default');

View file

@ -0,0 +1,52 @@
/*
* 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, { useCallback, useContext, useMemo } from 'react';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '../../../../common/constants';
import { MetricsExplorerSeries } from '../../../../common/threshold_rule/metrics_explorer';
import { TriggerActionsContext } from './triggers_actions_context';
import { useAlertPrefillContext } from '../helpers/use_alert_prefill';
import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options';
interface Props {
visible?: boolean;
options?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
setVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
export function AlertFlyout(props: Props) {
const { visible, setVisible } = props;
const { triggersActionsUI } = useContext(TriggerActionsContext);
const onCloseFlyout = useCallback(() => setVisible(false), [setVisible]);
const AddAlertFlyout = useMemo(
() =>
triggersActionsUI &&
triggersActionsUI.getAddRuleFlyout({
consumer: 'infrastructure',
onClose: onCloseFlyout,
canChangeTrigger: false,
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
metadata: {
currentOptions: props.options,
series: props.series,
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[triggersActionsUI, onCloseFlyout]
);
return <>{visible && AddAlertFlyout}</>;
}
export function PrefilledThresholdAlertFlyout({ onClose }: { onClose(): void }) {
const { metricThresholdPrefill } = useAlertPrefillContext();
const { groupBy, filterQuery, metrics } = metricThresholdPrefill;
return <AlertFlyout options={{ groupBy, filterQuery, metrics }} visible setVisible={onClose} />;
}

View file

@ -0,0 +1,329 @@
/*
* 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 { EuiFieldSearch, EuiOutsideClickDetector, EuiPanel } from '@elastic/eui';
import React from 'react';
import { QuerySuggestion } from '@kbn/unified-search-plugin/public';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { SuggestionItem } from './suggestion_item';
export type StateUpdater<State, Props = {}> = (
prevState: Readonly<State>,
prevProps: Readonly<Props>
) => State | null;
function composeStateUpdaters<State, Props>(...updaters: Array<StateUpdater<State, Props>>) {
return (state: State, props: Props) =>
updaters.reduce((currentState, updater) => updater(currentState, props) || currentState, state);
}
interface AutocompleteFieldProps {
isLoadingSuggestions: boolean;
isValid: boolean;
loadSuggestions: (value: string, cursorPosition: number, maxCount?: number) => void;
onSubmit?: (value: string) => void;
onChange?: (value: string) => void;
placeholder?: string;
suggestions: QuerySuggestion[];
value: string;
disabled?: boolean;
autoFocus?: boolean;
'aria-label'?: string;
compressed?: boolean;
}
interface AutocompleteFieldState {
areSuggestionsVisible: boolean;
isFocused: boolean;
selectedIndex: number | null;
}
export class AutocompleteField extends React.Component<
AutocompleteFieldProps,
AutocompleteFieldState
> {
public readonly state: AutocompleteFieldState = {
areSuggestionsVisible: false,
isFocused: false,
selectedIndex: null,
};
private inputElement: HTMLInputElement | null = null;
public render() {
const {
suggestions,
isLoadingSuggestions,
isValid,
placeholder,
value,
disabled,
'aria-label': ariaLabel,
compressed,
} = this.props;
const { areSuggestionsVisible, selectedIndex } = this.state;
return (
<EuiOutsideClickDetector onOutsideClick={this.handleBlur}>
<AutocompleteContainer>
<EuiFieldSearch
compressed={compressed}
fullWidth
disabled={disabled}
inputRef={this.handleChangeInputRef}
isLoading={isLoadingSuggestions}
isInvalid={!isValid}
onChange={this.handleChange}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onSearch={this.submit}
placeholder={placeholder}
value={value}
aria-label={ariaLabel}
data-test-subj="thresholdRuleSearchField"
/>
{areSuggestionsVisible && !isLoadingSuggestions && suggestions.length > 0 ? (
<SuggestionsPanel data-test-subj="thresholdRuleSuggestionsPanel">
{suggestions.map((suggestion, suggestionIndex) => (
<SuggestionItem
key={suggestion.text}
suggestion={suggestion}
isSelected={suggestionIndex === selectedIndex}
onMouseEnter={this.selectSuggestionAt(suggestionIndex)}
onClick={this.applySuggestionAt(suggestionIndex)}
/>
))}
</SuggestionsPanel>
) : null}
</AutocompleteContainer>
</EuiOutsideClickDetector>
);
}
public componentDidMount() {
if (this.inputElement && this.props.autoFocus) {
this.inputElement.focus();
}
}
public componentDidUpdate(prevProps: AutocompleteFieldProps) {
const hasNewValue = prevProps.value !== this.props.value;
const hasNewSuggestions = prevProps.suggestions !== this.props.suggestions;
if (hasNewValue) {
this.updateSuggestions();
}
if (hasNewValue && this.props.value === '') {
this.submit();
}
if (hasNewSuggestions && this.state.isFocused) {
this.showSuggestions();
}
}
private handleChangeInputRef = (element: HTMLInputElement | null) => {
this.inputElement = element;
};
private handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
this.changeValue(evt.currentTarget.value);
};
private handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
const { suggestions } = this.props;
switch (evt.key) {
case 'ArrowUp':
evt.preventDefault();
if (suggestions.length > 0) {
this.setState(
composeStateUpdaters(withSuggestionsVisible, withPreviousSuggestionSelected)
);
}
break;
case 'ArrowDown':
evt.preventDefault();
if (suggestions.length > 0) {
this.setState(composeStateUpdaters(withSuggestionsVisible, withNextSuggestionSelected));
} else {
this.updateSuggestions();
}
break;
case 'Enter':
evt.preventDefault();
if (this.state.selectedIndex !== null) {
this.applySelectedSuggestion();
} else {
this.submit();
}
break;
case 'Escape':
evt.preventDefault();
this.setState(withSuggestionsHidden);
break;
}
};
private handleKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>) => {
switch (evt.key) {
case 'ArrowLeft':
case 'ArrowRight':
case 'Home':
case 'End':
this.updateSuggestions();
break;
}
};
private handleFocus = () => {
this.setState(composeStateUpdaters(withSuggestionsVisible, withFocused));
};
private handleBlur = () => {
this.setState(composeStateUpdaters(withSuggestionsHidden, withUnfocused));
};
private selectSuggestionAt = (index: number) => () => {
this.setState(withSuggestionAtIndexSelected(index));
};
private applySelectedSuggestion = () => {
if (this.state.selectedIndex !== null) {
this.applySuggestionAt(this.state.selectedIndex)();
}
};
private applySuggestionAt = (index: number) => () => {
const { value, suggestions } = this.props;
const selectedSuggestion = suggestions[index];
if (!selectedSuggestion) {
return;
}
const newValue =
value.substr(0, selectedSuggestion.start) +
selectedSuggestion.text +
value.substr(selectedSuggestion.end);
this.setState(withSuggestionsHidden);
this.changeValue(newValue);
this.focusInputElement();
};
private changeValue = (value: string) => {
const { onChange } = this.props;
if (onChange) {
onChange(value);
}
};
private focusInputElement = () => {
if (this.inputElement) {
this.inputElement.focus();
}
};
private showSuggestions = () => {
this.setState(withSuggestionsVisible);
};
private submit = () => {
const { isValid, onSubmit, value } = this.props;
if (isValid && onSubmit) {
onSubmit(value);
}
this.setState(withSuggestionsHidden);
};
private updateSuggestions = () => {
const inputCursorPosition = this.inputElement ? this.inputElement.selectionStart || 0 : 0;
this.props.loadSuggestions(this.props.value, inputCursorPosition, 200);
};
}
const withPreviousSuggestionSelected = (
state: AutocompleteFieldState,
props: AutocompleteFieldProps
): AutocompleteFieldState => ({
...state,
selectedIndex:
props.suggestions.length === 0
? null
: state.selectedIndex !== null
? (state.selectedIndex + props.suggestions.length - 1) % props.suggestions.length
: Math.max(props.suggestions.length - 1, 0),
});
const withNextSuggestionSelected = (
state: AutocompleteFieldState,
props: AutocompleteFieldProps
): AutocompleteFieldState => ({
...state,
selectedIndex:
props.suggestions.length === 0
? null
: state.selectedIndex !== null
? (state.selectedIndex + 1) % props.suggestions.length
: 0,
});
const withSuggestionAtIndexSelected =
(suggestionIndex: number) =>
(state: AutocompleteFieldState, props: AutocompleteFieldProps): AutocompleteFieldState => ({
...state,
selectedIndex:
props.suggestions.length === 0
? null
: suggestionIndex >= 0 && suggestionIndex < props.suggestions.length
? suggestionIndex
: 0,
});
const withSuggestionsVisible = (state: AutocompleteFieldState) => ({
...state,
areSuggestionsVisible: true,
});
const withSuggestionsHidden = (state: AutocompleteFieldState) => ({
...state,
areSuggestionsVisible: false,
selectedIndex: null,
});
const withFocused = (state: AutocompleteFieldState) => ({
...state,
isFocused: true,
});
const withUnfocused = (state: AutocompleteFieldState) => ({
...state,
isFocused: false,
});
const AutocompleteContainer = euiStyled.div`
position: relative;
`;
const SuggestionsPanel = euiStyled(EuiPanel).attrs(() => ({
paddingSize: 'none',
hasShadow: true,
}))`
position: absolute;
width: 100%;
margin-top: 2px;
overflow-x: hidden;
overflow-y: scroll;
z-index: ${(props) => props.theme.eui.euiZLevel1};
max-height: 322px;
`;

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 * from './autocomplete_field';

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIcon } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { QuerySuggestion, QuerySuggestionTypes } from '@kbn/unified-search-plugin/public';
import { transparentize } from 'polished';
interface Props {
isSelected?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
suggestion: QuerySuggestion;
}
export function SuggestionItem(props: Props) {
const { isSelected, onClick, onMouseEnter, suggestion } = props;
return (
<SuggestionItemContainer isSelected={isSelected} onClick={onClick} onMouseEnter={onMouseEnter}>
<SuggestionItemIconField suggestionType={suggestion.type}>
<EuiIcon type={getEuiIconType(suggestion.type)} />
</SuggestionItemIconField>
<SuggestionItemTextField>{suggestion.text}</SuggestionItemTextField>
<SuggestionItemDescriptionField>{suggestion.description}</SuggestionItemDescriptionField>
</SuggestionItemContainer>
);
}
SuggestionItem.defaultProps = {
isSelected: false,
};
const SuggestionItemContainer = euiStyled.div<{
isSelected?: boolean;
}>`
display: flex;
flex-direction: row;
font-size: ${(props) => props.theme.eui.euiFontSizeS};
height: ${(props) => props.theme.eui.euiSizeXL};
white-space: nowrap;
background-color: ${(props) =>
props.isSelected ? props.theme.eui.euiColorLightestShade : 'transparent'};
`;
const SuggestionItemField = euiStyled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
height: ${(props) => props.theme.eui.euiSizeXL};
padding: ${(props) => props.theme.eui.euiSizeXS};
`;
const SuggestionItemIconField = euiStyled(SuggestionItemField)<{
suggestionType: QuerySuggestionTypes;
}>`
background-color: ${(props) =>
transparentize(0.9, getEuiIconColor(props.theme, props.suggestionType))};
color: ${(props) => getEuiIconColor(props.theme, props.suggestionType)};
flex: 0 0 auto;
justify-content: center;
width: ${(props) => props.theme.eui.euiSizeXL};
`;
const SuggestionItemTextField = euiStyled(SuggestionItemField)`
flex: 2 0 0;
font-family: ${(props) => props.theme.eui.euiCodeFontFamily};
`;
const SuggestionItemDescriptionField = euiStyled(SuggestionItemField)`
flex: 3 0 0;
p {
display: inline;
span {
font-family: ${(props) => props.theme.eui.euiCodeFontFamily};
}
}
`;
const getEuiIconType = (suggestionType: QuerySuggestionTypes) => {
switch (suggestionType) {
case QuerySuggestionTypes.Field:
return 'kqlField';
case QuerySuggestionTypes.Value:
return 'kqlValue';
case QuerySuggestionTypes.RecentSearch:
return 'search';
case QuerySuggestionTypes.Conjunction:
return 'kqlSelector';
case QuerySuggestionTypes.Operator:
return 'kqlOperand';
default:
return 'empty';
}
};
const getEuiIconColor = (theme: any, suggestionType: QuerySuggestionTypes): string => {
switch (suggestionType) {
case QuerySuggestionTypes.Field:
return theme?.eui.euiColorVis7;
case QuerySuggestionTypes.Value:
return theme?.eui.euiColorVis0;
case QuerySuggestionTypes.Operator:
return theme?.eui.euiColorVis1;
case QuerySuggestionTypes.Conjunction:
return theme?.eui.euiColorVis2;
case QuerySuggestionTypes.RecentSearch:
default:
return theme?.eui.euiColorMediumShade;
}
};

View file

@ -0,0 +1,141 @@
/*
* 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, { useMemo } from 'react';
import { niceTimeFormatter } from '@elastic/charts';
import { Theme, LIGHT_THEME, DARK_THEME } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { EuiLoadingChart, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { sum, min as getMin, max as getMax } from 'lodash';
import { GetLogAlertsChartPreviewDataSuccessResponsePayload } from '../../../../../common/threshold_rule/types';
import { formatNumber } from '../../../../../common/threshold_rule/formatters/number';
type Series = GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series'];
export const NUM_BUCKETS = 20;
export const TIME_LABELS = {
s: i18n.translate('xpack.observability.threshold.rule..timeLabels.seconds', {
defaultMessage: 'seconds',
}),
m: i18n.translate('xpack.observability.threshold.rule..timeLabels.minutes', {
defaultMessage: 'minutes',
}),
h: i18n.translate('xpack.observability.threshold.rule..timeLabels.hours', {
defaultMessage: 'hours',
}),
d: i18n.translate('xpack.observability.threshold.rule..timeLabels.days', {
defaultMessage: 'days',
}),
};
export const useDateFormatter = (xMin?: number, xMax?: number) => {
const dateFormatter = useMemo(() => {
if (typeof xMin === 'number' && typeof xMax === 'number') {
return niceTimeFormatter([xMin, xMax]);
} else {
return (value: number) => `${value}`;
}
}, [xMin, xMax]);
return dateFormatter;
};
export const yAxisFormatter = formatNumber;
export const getDomain = (series: Series, stacked: boolean = false) => {
let min: number | null = null;
let max: number | null = null;
const valuesByTimestamp = series.reduce<{ [timestamp: number]: number[] }>((acc, serie) => {
serie.points.forEach((point) => {
const valuesForTimestamp = acc[point.timestamp] || [];
acc[point.timestamp] = [...valuesForTimestamp, point.value];
});
return acc;
}, {});
const pointValues = Object.values(valuesByTimestamp);
pointValues.forEach((results) => {
const maxResult = stacked ? sum(results) : getMax(results);
const minResult = getMin(results);
if (maxResult && (!max || maxResult > max)) {
max = maxResult;
}
if (minResult && (!min || minResult < min)) {
min = minResult;
}
});
const timestampValues = Object.keys(valuesByTimestamp).map(Number);
const minTimestamp = getMin(timestampValues) || 0;
const maxTimestamp = getMax(timestampValues) || 0;
return { yMin: min || 0, yMax: max || 0, xMin: minTimestamp, xMax: maxTimestamp };
};
// TODO use the EUI charts theme see src/plugins/charts/public/services/theme/README.md
export const getChartTheme = (isDarkMode: boolean): Theme => {
return isDarkMode ? DARK_THEME : LIGHT_THEME;
};
export const EmptyContainer: React.FC = ({ children }) => (
<div
style={{
width: '100%',
height: 150,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{children}
</div>
);
export const ChartContainer: React.FC = ({ children }) => (
<div
style={{
width: '100%',
height: 150,
}}
>
{children}
</div>
);
export function NoDataState() {
return (
<EmptyContainer>
<EuiText color="subdued" data-test-subj="noChartData">
<FormattedMessage
id="xpack.observability.threshold.rule..charts.noDataMessage"
defaultMessage="No chart data available"
/>
</EuiText>
</EmptyContainer>
);
}
export function LoadingState() {
return (
<EmptyContainer>
<EuiText color="subdued" data-test-subj="loadingData">
<EuiLoadingChart size="m" />
</EuiText>
</EmptyContainer>
);
}
export function ErrorState() {
return (
<EmptyContainer>
<EuiText color="subdued" data-test-subj="chartErrorState">
<FormattedMessage
id="xpack.observability.threshold.rule..charts.errorMessage"
defaultMessage="Uh oh, something went wrong"
/>
</EuiText>
</EmptyContainer>
);
}

View file

@ -0,0 +1,150 @@
/*
* 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 { Color } from '../../../../../common/threshold_rule/color_palette';
import { Comparator } from '../../../../../common/threshold_rule/types';
import { shallow } from 'enzyme';
import React from 'react';
import { ThresholdAnnotations } from './threshold_annotations';
jest.mock('@elastic/charts', () => {
const original = jest.requireActual('@elastic/charts');
const mockComponent = (props: {}) => {
return <div {...props} />;
};
return {
...original,
LineAnnotation: mockComponent,
RectAnnotation: mockComponent,
};
});
describe('ThresholdAnnotations', () => {
async function setup(props = {}) {
const defaultProps = {
threshold: [20, 30],
sortedThresholds: [20, 30],
comparator: Comparator.GT,
color: Color.color0,
id: 'testId',
firstTimestamp: 123456789,
lastTimestamp: 987654321,
domain: { min: 10, max: 20 },
};
const wrapper = shallow(<ThresholdAnnotations {...defaultProps} {...props} />);
return wrapper;
}
it('should render a line annotation for each threshold', async () => {
const wrapper = await setup();
const annotation = wrapper.find('[data-test-subj="threshold-line"]');
const expectedValues = [{ dataValue: 20 }, { dataValue: 30 }];
const values = annotation.prop('dataValues');
expect(values).toEqual(expectedValues);
expect(annotation.length).toBe(1);
});
it('should render a rectangular annotation for in between thresholds', async () => {
const wrapper = await setup({ comparator: Comparator.BETWEEN });
const annotation = wrapper.find('[data-test-subj="between-rect"]');
const expectedValues = [
{
coordinates: {
x0: 123456789,
x1: 987654321,
y0: 20,
y1: 30,
},
},
];
const values = annotation.prop('dataValues');
expect(values).toEqual(expectedValues);
});
it('should render an upper rectangular annotation for outside range thresholds', async () => {
const wrapper = await setup({ comparator: Comparator.OUTSIDE_RANGE });
const annotation = wrapper.find('[data-test-subj="outside-range-lower-rect"]');
const expectedValues = [
{
coordinates: {
x0: 123456789,
x1: 987654321,
y0: 10,
y1: 20,
},
},
];
const values = annotation.prop('dataValues');
expect(values).toEqual(expectedValues);
});
it('should render a lower rectangular annotation for outside range thresholds', async () => {
const wrapper = await setup({ comparator: Comparator.OUTSIDE_RANGE });
const annotation = wrapper.find('[data-test-subj="outside-range-upper-rect"]');
const expectedValues = [
{
coordinates: {
x0: 123456789,
x1: 987654321,
y0: 30,
y1: 20,
},
},
];
const values = annotation.prop('dataValues');
expect(values).toEqual(expectedValues);
});
it('should render a rectangular annotation for below thresholds', async () => {
const wrapper = await setup({ comparator: Comparator.LT });
const annotation = wrapper.find('[data-test-subj="below-rect"]');
const expectedValues = [
{
coordinates: {
x0: 123456789,
x1: 987654321,
y0: 10,
y1: 20,
},
},
];
const values = annotation.prop('dataValues');
expect(values).toEqual(expectedValues);
});
it('should render a rectangular annotation for above thresholds', async () => {
const wrapper = await setup({ comparator: Comparator.GT });
const annotation = wrapper.find('[data-test-subj="above-rect"]');
const expectedValues = [
{
coordinates: {
x0: 123456789,
x1: 987654321,
y0: 20,
y1: 20,
},
},
];
const values = annotation.prop('dataValues');
expect(values).toEqual(expectedValues);
});
});

View file

@ -0,0 +1,160 @@
/*
* 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 { AnnotationDomainType, LineAnnotation, RectAnnotation } from '@elastic/charts';
import { first, last } from 'lodash';
import React from 'react';
import { Color, colorTransformer } from '../../../../../common/threshold_rule/color_palette';
import { Comparator } from '../../../../../common/threshold_rule/types';
interface ThresholdAnnotationsProps {
threshold: number[];
sortedThresholds: number[];
comparator: Comparator;
color: Color;
id: string;
firstTimestamp: number;
lastTimestamp: number;
domain: { min: number; max: number };
}
const opacity = 0.3;
export function ThresholdAnnotations({
threshold,
sortedThresholds,
comparator,
color,
id,
firstTimestamp,
lastTimestamp,
domain,
}: ThresholdAnnotationsProps) {
if (!comparator || !threshold) return null;
const isAbove = [Comparator.GT, Comparator.GT_OR_EQ].includes(comparator);
const isBelow = [Comparator.LT, Comparator.LT_OR_EQ].includes(comparator);
return (
<>
<LineAnnotation
id={`${id}-thresholds`}
domainType={AnnotationDomainType.YDomain}
data-test-subj="threshold-line"
dataValues={sortedThresholds.map((t) => ({
dataValue: t,
}))}
style={{
line: {
strokeWidth: 2,
stroke: colorTransformer(color),
opacity: 1,
},
}}
/>
{sortedThresholds.length === 2 && comparator === Comparator.BETWEEN ? (
<>
<RectAnnotation
id={`${id}-lower-threshold`}
data-test-subj="between-rect"
style={{
fill: colorTransformer(color),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: first(threshold),
y1: last(threshold),
},
},
]}
/>
</>
) : null}
{sortedThresholds.length === 2 && comparator === Comparator.OUTSIDE_RANGE ? (
<>
<RectAnnotation
id={`${id}-lower-threshold`}
data-test-subj="outside-range-lower-rect"
style={{
fill: colorTransformer(color),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: domain.min,
y1: first(threshold),
},
},
]}
/>
<RectAnnotation
id={`${id}-upper-threshold`}
data-test-subj="outside-range-upper-rect"
style={{
fill: colorTransformer(color),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: last(threshold),
y1: domain.max,
},
},
]}
/>
</>
) : null}
{isBelow && first(threshold) != null ? (
<RectAnnotation
id={`${id}-upper-threshold`}
data-test-subj="below-rect"
style={{
fill: colorTransformer(color),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: domain.min,
y1: first(threshold),
},
},
]}
/>
) : null}
{isAbove && first(threshold) != null ? (
<RectAnnotation
id={`${id}-upper-threshold`}
data-test-subj="above-rect"
style={{
fill: colorTransformer(color),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: first(threshold),
y1: domain.max,
},
},
]}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,126 @@
/*
* 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 { Meta, Story } from '@storybook/react/types-6-0';
import React, { useCallback, useEffect, useState } from 'react';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { decorateWithGlobalStorybookThemeProviders } from '../../../../test_utils/use_global_storybook_theme';
import {
Aggregators,
Comparator,
MetricExpressionParams,
} from '../../../../../common/threshold_rule/types';
import { TimeUnitChar } from '../../../../../common';
import { CustomEquationEditor, CustomEquationEditorProps } from './custom_equation_editor';
import { aggregationType } from '../expression_row';
import { MetricExpression } from '../../types';
import { validateMetricThreshold } from '../validation';
export default {
title: 'infra/alerting/CustomEquationEditor',
decorators: [
(wrappedStory) => <div style={{ width: 550 }}>{wrappedStory()}</div>,
decorateWithGlobalStorybookThemeProviders,
],
parameters: {
layout: 'padded',
},
argTypes: {
onChange: { action: 'changed' },
},
} as Meta;
const fakeDataView = {
title: 'metricbeat-*',
fields: [
{
name: 'system.cpu.user.pct',
type: 'number',
},
{
name: 'system.cpu.system.pct',
type: 'number',
},
{
name: 'system.cpu.cores',
type: 'number',
},
],
};
const CustomEquationEditorTemplate: Story<CustomEquationEditorProps> = (args) => {
const [expression, setExpression] = useState<MetricExpression>(args.expression);
const [errors, setErrors] = useState<IErrorObject>(args.errors);
const handleExpressionChange = useCallback(
(exp: MetricExpression) => {
setExpression(exp);
args.onChange(exp);
return exp;
},
[args]
);
useEffect(() => {
const validationObject = validateMetricThreshold({
criteria: [expression as MetricExpressionParams],
});
setErrors(validationObject.errors[0]);
}, [expression]);
return (
<CustomEquationEditor
{...args}
errors={errors}
expression={expression}
onChange={handleExpressionChange}
dataView={fakeDataView}
/>
);
};
export const CustomEquationEditorDefault = CustomEquationEditorTemplate.bind({});
export const CustomEquationEditorWithEquationErrors = CustomEquationEditorTemplate.bind({});
export const CustomEquationEditorWithFieldError = CustomEquationEditorTemplate.bind({});
const BASE_ARGS = {
expression: {
aggType: Aggregators.CUSTOM,
timeSize: 1,
timeUnit: 'm' as TimeUnitChar,
threshold: [1],
comparator: Comparator.GT,
},
fields: [
{ name: 'system.cpu.user.pct', normalizedType: 'number' },
{ name: 'system.cpu.system.pct', normalizedType: 'number' },
{ name: 'system.cpu.cores', normalizedType: 'number' },
],
aggregationTypes: aggregationType,
};
CustomEquationEditorDefault.args = {
...BASE_ARGS,
errors: {},
};
CustomEquationEditorWithEquationErrors.args = {
...BASE_ARGS,
expression: {
...BASE_ARGS.expression,
equation: 'Math.round(A / B)',
customMetrics: [
{ name: 'A', aggType: Aggregators.AVERAGE, field: 'system.cpu.user.pct' },
{ name: 'B', aggType: Aggregators.MAX, field: 'system.cpu.cores' },
],
},
errors: {
equation:
'The equation field only supports the following characters: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
},
};

View file

@ -0,0 +1,222 @@
/*
* 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 {
EuiFieldText,
EuiFormRow,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import React, { useState, useCallback, useMemo } from 'react';
import { omit, range, first, xor, debounce } from 'lodash';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataViewBase } from '@kbn/es-query';
import { OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS } from '../../../../../common/threshold_rule/metrics_explorer';
import {
Aggregators,
CustomMetricAggTypes,
MetricExpressionCustomMetric,
} from '../../../../../common/threshold_rule/types';
import { MetricExpression } from '../../types';
import { CustomMetrics, AggregationTypes, NormalizedFields } from './types';
import { MetricRowWithAgg } from './metric_row_with_agg';
import { MetricRowWithCount } from './metric_row_with_count';
import {
CUSTOM_EQUATION,
EQUATION_HELP_MESSAGE,
LABEL_HELP_MESSAGE,
LABEL_LABEL,
} from '../../i18n_strings';
export interface CustomEquationEditorProps {
onChange: (expression: MetricExpression) => void;
expression: MetricExpression;
fields: NormalizedFields;
aggregationTypes: AggregationTypes;
errors: IErrorObject;
dataView: DataViewBase;
}
const NEW_METRIC = { name: 'A', aggType: Aggregators.AVERAGE as CustomMetricAggTypes };
const MAX_VARIABLES = 26;
const CHAR_CODE_FOR_A = 65;
const CHAR_CODE_FOR_Z = CHAR_CODE_FOR_A + MAX_VARIABLES;
const VAR_NAMES = range(CHAR_CODE_FOR_A, CHAR_CODE_FOR_Z).map((c) => String.fromCharCode(c));
export function CustomEquationEditor({
onChange,
expression,
fields,
aggregationTypes,
errors,
dataView,
}: CustomEquationEditorProps) {
const [customMetrics, setCustomMetrics] = useState<CustomMetrics>(
expression?.customMetrics ?? [NEW_METRIC]
);
const [label, setLabel] = useState<string | undefined>(expression?.label || undefined);
const [equation, setEquation] = useState<string | undefined>(expression?.equation || undefined);
const debouncedOnChange = useMemo(() => debounce(onChange, 500), [onChange]);
const handleAddNewRow = useCallback(() => {
setCustomMetrics((previous) => {
const currentVars = previous?.map((m) => m.name) ?? [];
const name = first(xor(VAR_NAMES, currentVars))!;
const nextMetrics = [...(previous || []), { ...NEW_METRIC, name }];
debouncedOnChange({ ...expression, customMetrics: nextMetrics, equation, label });
return nextMetrics;
});
}, [debouncedOnChange, equation, expression, label]);
const handleDelete = useCallback(
(name: string) => {
setCustomMetrics((previous) => {
const nextMetrics = previous?.filter((row) => row.name !== name) ?? [NEW_METRIC];
const finalMetrics = (nextMetrics.length && nextMetrics) || [NEW_METRIC];
debouncedOnChange({ ...expression, customMetrics: finalMetrics, equation, label });
return finalMetrics;
});
},
[equation, expression, debouncedOnChange, label]
);
const handleChange = useCallback(
(metric: MetricExpressionCustomMetric) => {
setCustomMetrics((previous) => {
const nextMetrics = previous?.map((m) => (m.name === metric.name ? metric : m));
debouncedOnChange({ ...expression, customMetrics: nextMetrics, equation, label });
return nextMetrics;
});
},
[equation, expression, debouncedOnChange, label]
);
const handleEquationChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEquation(e.target.value);
debouncedOnChange({ ...expression, customMetrics, equation: e.target.value, label });
},
[debouncedOnChange, expression, customMetrics, label]
);
const handleLabelChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setLabel(e.target.value);
debouncedOnChange({ ...expression, customMetrics, equation, label: e.target.value });
},
[debouncedOnChange, expression, customMetrics, equation]
);
const disableAdd = customMetrics?.length === MAX_VARIABLES;
const disableDelete = customMetrics?.length === 1;
const filteredAggregationTypes = omit(aggregationTypes, OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS);
const metricRows = customMetrics?.map((row) => {
if (row.aggType === Aggregators.COUNT) {
return (
<MetricRowWithCount
key={row.name}
name={row.name}
agg={row.aggType}
filter={row.filter}
onAdd={handleAddNewRow}
onDelete={handleDelete}
disableAdd={disableAdd}
aggregationTypes={filteredAggregationTypes}
disableDelete={disableDelete}
onChange={handleChange}
errors={errors}
dataView={dataView}
/>
);
}
return (
<MetricRowWithAgg
key={row.name}
name={row.name}
aggType={row.aggType}
aggregationTypes={filteredAggregationTypes}
field={row.field}
fields={fields}
onAdd={handleAddNewRow}
onDelete={handleDelete}
disableAdd={disableAdd}
disableDelete={disableDelete}
onChange={handleChange}
errors={errors}
/>
);
});
const placeholder = useMemo(() => {
return customMetrics?.map((row) => row.name).join(' + ');
}, [customMetrics]);
return (
<div style={{ minWidth: '100%' }}>
<EuiSpacer size={'s'} />
{metricRows}
<EuiFlexGroup>
<EuiButtonEmpty
data-test-subj="thresholdRuleCustomEquationEditorAddAggregationFieldButton"
color={'primary'}
flush={'left'}
size="xs"
iconType={'plusInCircleFilled'}
onClick={handleAddNewRow}
isDisabled={disableAdd}
>
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.customEquationEditor.addCustomRow"
defaultMessage="Add aggregation/field"
/>
</EuiButtonEmpty>
</EuiFlexGroup>
<EuiSpacer size={'m'} />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label="Equation"
fullWidth
helpText={EQUATION_HELP_MESSAGE}
isInvalid={errors.equation != null}
error={[errors.equation]}
>
<EuiFieldText
data-test-subj="thresholdRuleCustomEquationEditorFieldText"
isInvalid={errors.equation != null}
compressed
fullWidth
placeholder={placeholder}
onChange={handleEquationChange}
value={equation ?? ''}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'s'} />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow label={LABEL_LABEL} fullWidth helpText={LABEL_HELP_MESSAGE}>
<EuiFieldText
data-test-subj="thresholdRuleCustomEquationEditorFieldText"
compressed
fullWidth
value={label}
placeholder={CUSTOM_EQUATION}
onChange={handleLabelChange}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
}

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 { CustomEquationEditor } from './custom_equation_editor';

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 React from 'react';
import { EuiFlexItem, EuiButtonIcon } from '@elastic/eui';
import { DELETE_LABEL } from '../../i18n_strings';
interface MetricRowControlProps {
onDelete: () => void;
disableDelete: boolean;
}
export function MetricRowControls({ onDelete, disableDelete }: MetricRowControlProps) {
return (
<>
<EuiFlexItem grow={0}>
<EuiButtonIcon
iconType="trash"
color="danger"
style={{ marginBottom: '0.2em' }}
onClick={onDelete}
disabled={disableDelete}
title={DELETE_LABEL}
/>
</EuiFlexItem>
</>
);
}

View file

@ -0,0 +1,141 @@
/*
* 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 {
EuiFormRow,
EuiHorizontalRule,
EuiFlexItem,
EuiFlexGroup,
EuiSelect,
EuiComboBox,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import React, { useMemo, useCallback } from 'react';
import { get } from 'lodash';
import { i18n } from '@kbn/i18n';
import { ValidNormalizedTypes } from '@kbn/triggers-actions-ui-plugin/public';
import { Aggregators, CustomMetricAggTypes } from '../../../../../common/threshold_rule/types';
import { MetricRowControls } from './metric_row_controls';
import { NormalizedFields, MetricRowBaseProps } from './types';
interface MetricRowWithAggProps extends MetricRowBaseProps {
aggType?: CustomMetricAggTypes;
field?: string;
fields: NormalizedFields;
}
export function MetricRowWithAgg({
name,
aggType = Aggregators.AVERAGE,
field,
onDelete,
disableDelete,
fields,
aggregationTypes,
onChange,
errors,
}: MetricRowWithAggProps) {
const handleDelete = useCallback(() => {
onDelete(name);
}, [name, onDelete]);
const fieldOptions = useMemo(
() =>
fields.reduce((acc, fieldValue) => {
if (
aggType &&
aggregationTypes[aggType].validNormalizedTypes.includes(
fieldValue.normalizedType as ValidNormalizedTypes
)
) {
acc.push({ label: fieldValue.name });
}
return acc;
}, [] as Array<{ label: string }>),
[fields, aggregationTypes, aggType]
);
const aggOptions = useMemo(
() =>
Object.values(aggregationTypes).map((a) => ({
text: a.text,
value: a.value,
})),
[aggregationTypes]
);
const handleFieldChange = useCallback(
(selectedOptions: EuiComboBoxOptionOption[]) => {
onChange({
name,
field: (selectedOptions.length && selectedOptions[0].label) || undefined,
aggType,
});
},
[name, aggType, onChange]
);
const handleAggChange = useCallback(
(el: React.ChangeEvent<HTMLSelectElement>) => {
onChange({
name,
field,
aggType: el.target.value as CustomMetricAggTypes,
});
},
[name, field, onChange]
);
const isAggInvalid = get(errors, ['customMetrics', name, 'aggType']) != null;
const isFieldInvalid = get(errors, ['customMetrics', name, 'field']) != null || !field;
return (
<>
<EuiFlexGroup gutterSize="xs" alignItems="flexEnd">
<EuiFlexItem style={{ maxWidth: 145 }}>
<EuiFormRow
label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.aggregationLabel',
{ defaultMessage: 'Aggregation {name}', values: { name } }
)}
isInvalid={isAggInvalid}
>
<EuiSelect
data-test-subj="thresholdRuleMetricRowWithAggSelect"
compressed
options={aggOptions}
value={aggType}
isInvalid={isAggInvalid}
onChange={handleAggChange}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.fieldLabel',
{ defaultMessage: 'Field {name}', values: { name } }
)}
isInvalid={isFieldInvalid}
>
<EuiComboBox
fullWidth
compressed
isInvalid={isFieldInvalid}
singleSelection={{ asPlainText: true }}
options={fieldOptions}
selectedOptions={field ? [{ label: field }] : []}
onChange={handleFieldChange}
/>
</EuiFormRow>
</EuiFlexItem>
<MetricRowControls onDelete={handleDelete} disableDelete={disableDelete} />
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
</>
);
}

View file

@ -0,0 +1,110 @@
/*
* 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 { EuiFormRow, EuiHorizontalRule, EuiFlexItem, EuiFlexGroup, EuiSelect } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { DataViewBase } from '@kbn/es-query';
import { Aggregators, CustomMetricAggTypes } from '../../../../../common/threshold_rule/types';
import { MetricRowControls } from './metric_row_controls';
import { MetricRowBaseProps } from './types';
import { MetricsExplorerKueryBar } from '../kuery_bar';
interface MetricRowWithCountProps extends MetricRowBaseProps {
agg?: Aggregators;
filter?: string;
dataView: DataViewBase;
}
export function MetricRowWithCount({
name,
agg,
filter,
onDelete,
disableDelete,
onChange,
aggregationTypes,
dataView,
}: MetricRowWithCountProps) {
const aggOptions = useMemo(
() =>
Object.values(aggregationTypes)
.filter((aggType) => aggType.value !== Aggregators.CUSTOM)
.map((aggType) => ({
text: aggType.text,
value: aggType.value,
})),
[aggregationTypes]
);
const handleDelete = useCallback(() => {
onDelete(name);
}, [name, onDelete]);
const handleAggChange = useCallback(
(el: React.ChangeEvent<HTMLSelectElement>) => {
onChange({
name,
filter,
aggType: el.target.value as CustomMetricAggTypes,
});
},
[name, filter, onChange]
);
const handleFilterChange = useCallback(
(filterString: string) => {
onChange({
name,
filter: filterString,
aggType: agg as CustomMetricAggTypes,
});
},
[name, agg, onChange]
);
return (
<>
<EuiFlexGroup gutterSize="xs" alignItems="flexEnd">
<EuiFlexItem style={{ maxWidth: 145 }}>
<EuiFormRow
label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.aggregationLabel',
{ defaultMessage: 'Aggregation {name}', values: { name } }
)}
>
<EuiSelect
data-test-subj="thresholdRuleMetricRowWithCountSelect"
compressed
options={aggOptions}
value={agg}
onChange={handleAggChange}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.filterLabel',
{ defaultMessage: 'KQL Filter {name}', values: { name } }
)}
>
<MetricsExplorerKueryBar
placeholder={' '}
compressed
derivedIndexPattern={dataView}
onChange={handleFilterChange}
onSubmit={handleFilterChange}
value={filter}
/>
</EuiFormRow>
</EuiFlexItem>
<MetricRowControls onDelete={handleDelete} disableDelete={disableDelete} />
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
</>
);
}

View file

@ -0,0 +1,33 @@
/*
* 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 { AggregationType, IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { MetricExpressionCustomMetric } from '../../../../../common/threshold_rule/types';
import { MetricExpression } from '../../types';
export type CustomMetrics = MetricExpression['customMetrics'];
export interface AggregationTypes {
[x: string]: AggregationType;
}
export interface NormalizedField {
name: string;
normalizedType: string;
}
export type NormalizedFields = NormalizedField[];
export interface MetricRowBaseProps {
name: string;
onAdd: () => void;
onDelete: (name: string) => void;
disableDelete: boolean;
disableAdd: boolean;
onChange: (metric: MetricExpressionCustomMetric) => void;
aggregationTypes: AggregationTypes;
errors: IErrorObject;
}

View file

@ -0,0 +1,105 @@
/*
* 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 { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import React from 'react';
import { act } from 'react-dom/test-utils';
// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock`
import { coreMock as mockCoreMock } from '@kbn/core/public/mocks';
import { Expressions } from './expression';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer';
import { Comparator } from '../../../../common/threshold_rule/types';
jest.mock('../helpers/source', () => ({
withSourceProvider: () => jest.fn,
useSourceContext: () => ({
source: { id: 'default' },
createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }),
}),
}));
jest.mock('../../../utils/kibana_react', () => ({
useKibana: () => ({
services: mockCoreMock.createStart(),
}),
}));
const dataViewMock = dataViewPluginMocks.createStartContract();
describe('Expression', () => {
async function setup(currentOptions: {
metrics?: MetricsExplorerMetric[];
filterQuery?: string;
groupBy?: string;
}) {
const ruleParams = {
criteria: [],
groupBy: undefined,
filterQueryText: '',
sourceId: 'default',
};
const wrapper = mountWithIntl(
<Expressions
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
ruleParams={ruleParams}
errors={{}}
setRuleParams={(key, value) => Reflect.set(ruleParams, key, value)}
setRuleProperty={() => {}}
metadata={{
currentOptions,
}}
dataViews={dataViewMock}
/>
);
const update = async () =>
await act(async () => {
await nextTick();
wrapper.update();
});
await update();
return { wrapper, update, ruleParams };
}
it('should prefill the alert using the context metadata', async () => {
const currentOptions = {
groupBy: 'host.hostname',
filterQuery: 'foo',
metrics: [
{ aggregation: 'avg', field: 'system.load.1' },
{ aggregation: 'cardinality', field: 'system.cpu.user.pct' },
] as MetricsExplorerMetric[],
};
const { ruleParams } = await setup(currentOptions);
expect(ruleParams.groupBy).toBe('host.hostname');
expect(ruleParams.filterQueryText).toBe('foo');
expect(ruleParams.criteria).toEqual([
{
metric: 'system.load.1',
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
aggType: 'avg',
},
{
metric: 'system.cpu.user.pct',
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
aggType: 'cardinality',
},
]);
});
});

View file

@ -0,0 +1,514 @@
/*
* 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, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiAccordion,
EuiButtonEmpty,
EuiCheckbox,
EuiFieldSearch,
EuiFormRow,
EuiIcon,
EuiLink,
EuiPanel,
EuiSpacer,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { debounce } from 'lodash';
import {
ForLastExpression,
IErrorObject,
RuleTypeParams,
RuleTypeParamsExpressionProps,
} from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '../../../utils/kibana_react';
import { Aggregators, Comparator, QUERY_INVALID } from '../../../../common/threshold_rule/types';
import { TimeUnitChar } from '../../../../common/utils/formatters/duration';
import { AlertContextMeta, AlertParams, MetricExpression } from '../types';
import { ExpressionChart } from './expression_chart';
import { ExpressionRow } from './expression_row';
import { MetricsExplorerKueryBar } from './kuery_bar';
import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options';
import { convertKueryToElasticSearchQuery } from '../helpers/kuery';
import { useSourceContext, withSourceProvider } from '../helpers/source';
import { MetricsExplorerGroupBy } from './group_by';
const FILTER_TYPING_DEBOUNCE_MS = 500;
type Props = Omit<
RuleTypeParamsExpressionProps<RuleTypeParams & AlertParams, AlertContextMeta>,
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData'
>;
export const defaultExpression = {
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
} as MetricExpression;
export function Expressions(props: Props) {
const { setRuleParams, ruleParams, errors, metadata } = props;
const { docLinks } = useKibana().services;
const { source, createDerivedIndexPattern } = useSourceContext();
const [timeSize, setTimeSize] = useState<number | undefined>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnitChar | undefined>('m');
const derivedIndexPattern = useMemo(
() => createDerivedIndexPattern(),
[createDerivedIndexPattern]
);
const options = useMemo<MetricsExplorerOptions>(() => {
if (metadata?.currentOptions?.metrics) {
return metadata.currentOptions as MetricsExplorerOptions;
} else {
return {
metrics: [],
aggregation: 'avg',
};
}
}, [metadata]);
const updateParams = useCallback(
(id, e: MetricExpression) => {
const exp = ruleParams.criteria ? ruleParams.criteria.slice() : [];
exp[id] = e;
setRuleParams('criteria', exp);
},
[setRuleParams, ruleParams.criteria]
);
const addExpression = useCallback(() => {
const exp = ruleParams.criteria?.slice() || [];
exp.push({
...defaultExpression,
timeSize: timeSize ?? defaultExpression.timeSize,
timeUnit: timeUnit ?? defaultExpression.timeUnit,
});
setRuleParams('criteria', exp);
}, [setRuleParams, ruleParams.criteria, timeSize, timeUnit]);
const removeExpression = useCallback(
(id: number) => {
const exp = ruleParams.criteria?.slice() || [];
if (exp.length > 1) {
exp.splice(id, 1);
setRuleParams('criteria', exp);
}
},
[setRuleParams, ruleParams.criteria]
);
const onFilterChange = useCallback(
(filter: any) => {
setRuleParams('filterQueryText', filter);
try {
setRuleParams(
'filterQuery',
convertKueryToElasticSearchQuery(filter, derivedIndexPattern, false) || ''
);
} catch (e) {
setRuleParams('filterQuery', QUERY_INVALID);
}
},
[setRuleParams, derivedIndexPattern]
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const debouncedOnFilterChange = useCallback(debounce(onFilterChange, FILTER_TYPING_DEBOUNCE_MS), [
onFilterChange,
]);
const onGroupByChange = useCallback(
(group: string | null | string[]) => {
setRuleParams('groupBy', group && group.length ? group : '');
},
[setRuleParams]
);
const emptyError = useMemo(() => {
return {
aggField: [],
timeSizeUnit: [],
timeWindowSize: [],
};
}, []);
const updateTimeSize = useCallback(
(ts: number | undefined) => {
const criteria =
ruleParams.criteria?.map((c) => ({
...c,
timeSize: ts,
})) || [];
setTimeSize(ts || undefined);
setRuleParams('criteria', criteria);
},
[ruleParams.criteria, setRuleParams]
);
const updateTimeUnit = useCallback(
(tu: string) => {
const criteria =
ruleParams.criteria?.map((c) => ({
...c,
timeUnit: tu,
})) || [];
setTimeUnit(tu as TimeUnitChar);
setRuleParams('criteria', criteria as AlertParams['criteria']);
},
[ruleParams.criteria, setRuleParams]
);
const preFillAlertCriteria = useCallback(() => {
const md = metadata;
if (md?.currentOptions?.metrics?.length) {
setRuleParams(
'criteria',
md.currentOptions.metrics.map((metric) => ({
metric: metric.field,
comparator: Comparator.GT,
threshold: [],
timeSize,
timeUnit,
aggType: metric.aggregation,
})) as AlertParams['criteria']
);
} else {
setRuleParams('criteria', [defaultExpression]);
}
}, [metadata, setRuleParams, timeSize, timeUnit]);
const preFillAlertFilter = useCallback(() => {
const md = metadata;
if (md && md.currentOptions?.filterQuery) {
setRuleParams('filterQueryText', md.currentOptions.filterQuery);
setRuleParams(
'filterQuery',
convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || ''
);
} else if (md && md.currentOptions?.groupBy && md.series) {
const { groupBy } = md.currentOptions;
const filter = Array.isArray(groupBy)
? groupBy.map((field, index) => `${field}: "${md.series?.keys?.[index]}"`).join(' and ')
: `${groupBy}: "${md.series.id}"`;
setRuleParams('filterQueryText', filter);
setRuleParams(
'filterQuery',
convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || ''
);
}
}, [metadata, derivedIndexPattern, setRuleParams]);
const preFillAlertGroupBy = useCallback(() => {
const md = metadata;
if (md && md.currentOptions?.groupBy && !md.series) {
setRuleParams('groupBy', md.currentOptions.groupBy);
}
}, [metadata, setRuleParams]);
useEffect(() => {
if (ruleParams.criteria && ruleParams.criteria.length) {
setTimeSize(ruleParams.criteria[0].timeSize);
setTimeUnit(ruleParams.criteria[0].timeUnit);
} else {
preFillAlertCriteria();
}
if (!ruleParams.filterQuery) {
preFillAlertFilter();
}
if (!ruleParams.groupBy) {
preFillAlertGroupBy();
}
if (!ruleParams.sourceId) {
setRuleParams('sourceId', source?.id || 'default');
}
if (typeof ruleParams.alertOnNoData === 'undefined') {
setRuleParams('alertOnNoData', true);
}
if (typeof ruleParams.alertOnGroupDisappear === 'undefined') {
setRuleParams('alertOnGroupDisappear', true);
}
}, [metadata, source]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFieldSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => onFilterChange(e.target.value),
[onFilterChange]
);
const hasGroupBy = useMemo(
() => ruleParams.groupBy && ruleParams.groupBy.length > 0,
[ruleParams.groupBy]
);
const disableNoData = useMemo(
() => ruleParams.criteria?.every((c) => c.aggType === Aggregators.COUNT),
[ruleParams.criteria]
);
// Test to see if any of the group fields in groupBy are already filtered down to a single
// group by the filterQuery. If this is the case, then a groupBy is unnecessary, as it would only
// ever produce one group instance
const groupByFilterTestPatterns = useMemo(() => {
if (!ruleParams.groupBy) return null;
const groups = !Array.isArray(ruleParams.groupBy) ? [ruleParams.groupBy] : ruleParams.groupBy;
return groups.map((group: string) => ({
groupName: group,
pattern: new RegExp(`{"match(_phrase)?":{"${group}":"(.*?)"}}`),
}));
}, [ruleParams.groupBy]);
const redundantFilterGroupBy = useMemo(() => {
const { filterQuery } = ruleParams;
if (typeof filterQuery !== 'string' || !groupByFilterTestPatterns) return [];
return groupByFilterTestPatterns
.map(({ groupName, pattern }) => {
if (pattern.test(filterQuery)) {
return groupName;
}
})
.filter((g) => typeof g === 'string') as string[];
}, [ruleParams, groupByFilterTestPatterns]);
return (
<>
<EuiSpacer size={'m'} />
<EuiText size="xs">
<h4>
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.conditions"
defaultMessage="Conditions"
/>
</h4>
</EuiText>
<EuiSpacer size={'xs'} />
{ruleParams.criteria &&
ruleParams.criteria.map((e, idx) => {
return (
<ExpressionRow
canDelete={(ruleParams.criteria && ruleParams.criteria.length > 1) || false}
fields={derivedIndexPattern.fields}
remove={removeExpression}
addExpression={addExpression}
key={idx} // idx's don't usually make good key's but here the index has semantic meaning
expressionId={idx}
setRuleParams={updateParams}
errors={(errors[idx] as IErrorObject) || emptyError}
expression={e || {}}
dataView={derivedIndexPattern}
>
<ExpressionChart
expression={e}
derivedIndexPattern={derivedIndexPattern}
source={source}
filterQuery={ruleParams.filterQueryText}
groupBy={ruleParams.groupBy}
/>
</ExpressionRow>
);
})}
<div style={{ marginLeft: 28 }}>
<ForLastExpression
timeWindowSize={timeSize}
timeWindowUnit={timeUnit}
errors={emptyError}
onChangeWindowSize={updateTimeSize}
onChangeWindowUnit={updateTimeUnit}
/>
</div>
<EuiSpacer size={'m'} />
<div>
<EuiButtonEmpty
data-test-subj="thresholdRuleExpressionsAddConditionButton"
color={'primary'}
iconSide={'left'}
flush={'left'}
iconType={'plusInCircleFilled'}
onClick={addExpression}
>
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.addCondition"
defaultMessage="Add condition"
/>
</EuiButtonEmpty>
</div>
<EuiSpacer size={'m'} />
<EuiAccordion
id="advanced-options-accordion"
buttonContent={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.advancedOptions',
{
defaultMessage: 'Advanced options',
}
)}
>
<EuiPanel color="subdued">
<EuiCheckbox
disabled={disableNoData}
id="metrics-alert-no-data-toggle"
label={
<>
{i18n.translate('xpack.observability.threshold.rule.alertFlyout.alertOnNoData', {
defaultMessage: "Alert me if there's no data",
})}{' '}
<EuiToolTip
content={
(disableNoData ? `${docCountNoDataDisabledHelpText} ` : '') +
i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.noDataHelpText',
{
defaultMessage:
'Enable this to trigger the action if the metric(s) do not report any data over the expected time period, or if the alert fails to query Elasticsearch',
}
)
}
>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
</>
}
checked={ruleParams.alertOnNoData}
onChange={(e) => setRuleParams('alertOnNoData', e.target.checked)}
/>
</EuiPanel>
</EuiAccordion>
<EuiSpacer size={'m'} />
<EuiFormRow
label={i18n.translate('xpack.observability.threshold.rule.alertFlyout.filterLabel', {
defaultMessage: 'Filter (optional)',
})}
helpText={i18n.translate('xpack.observability.threshold.rule.alertFlyout.filterHelpText', {
defaultMessage: 'Use a KQL expression to limit the scope of your alert trigger.',
})}
fullWidth
display="rowCompressed"
>
{(metadata && (
<MetricsExplorerKueryBar
derivedIndexPattern={derivedIndexPattern}
onChange={debouncedOnFilterChange}
onSubmit={onFilterChange}
value={ruleParams.filterQueryText}
/>
)) || (
<EuiFieldSearch
data-test-subj="thresholdRuleExpressionsFieldSearch"
onChange={handleFieldSearchChange}
value={ruleParams.filterQueryText}
fullWidth
/>
)}
</EuiFormRow>
<EuiSpacer size={'m'} />
<EuiFormRow
label={i18n.translate('xpack.observability.threshold.rule.alertFlyout.createAlertPerText', {
defaultMessage: 'Group alerts by (optional)',
})}
helpText={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.createAlertPerHelpText',
{
defaultMessage:
'Create an alert for every unique value. For example: "host.id" or "cloud.region".',
}
)}
fullWidth
display="rowCompressed"
>
<MetricsExplorerGroupBy
onChange={onGroupByChange}
fields={derivedIndexPattern.fields}
options={{
...options,
groupBy: ruleParams.groupBy || undefined,
}}
errorOptions={redundantFilterGroupBy}
/>
</EuiFormRow>
{redundantFilterGroupBy.length > 0 && (
<>
<EuiSpacer size="s" />
<EuiText size="xs" color="danger">
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.alertPerRedundantFilterError"
defaultMessage="This rule may alert on {matchedGroups} less than expected, because the filter query contains a match for {groupCount, plural, one {this field} other {these fields}}. For more information, refer to {filteringAndGroupingLink}."
values={{
matchedGroups: <strong>{redundantFilterGroupBy.join(', ')}</strong>,
groupCount: redundantFilterGroupBy.length,
filteringAndGroupingLink: (
<EuiLink
data-test-subj="thresholdRuleExpressionsTheDocsLink"
href={`${docLinks.links.observability.metricsThreshold}#filtering-and-grouping`}
>
{i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.alertPerRedundantFilterError.docsLink',
{ defaultMessage: 'the docs' }
)}
</EuiLink>
),
}}
/>
</EuiText>
</>
)}
<EuiSpacer size={'s'} />
<EuiCheckbox
id="metrics-alert-group-disappear-toggle"
label={
<>
{i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.alertOnGroupDisappear',
{
defaultMessage: 'Alert me if a group stops reporting data',
}
)}{' '}
<EuiToolTip
content={
(disableNoData ? `${docCountNoDataDisabledHelpText} ` : '') +
i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.groupDisappearHelpText',
{
defaultMessage:
'Enable this to trigger the action if a previously detected group begins to report no results. This is not recommended for dynamically scaling infrastructures that may rapidly start and stop nodes automatically.',
}
)
}
>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
</>
}
disabled={disableNoData || !hasGroupBy}
checked={Boolean(hasGroupBy && ruleParams.alertOnGroupDisappear)}
onChange={(e) => setRuleParams('alertOnGroupDisappear', e.target.checked)}
/>
<EuiSpacer size={'m'} />
</>
);
}
const docCountNoDataDisabledHelpText = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.docCountNoDataDisabledHelpText',
{
defaultMessage: '[This setting is not applicable to the Document Count aggregator.]',
}
);
// required for dynamic import
// eslint-disable-next-line import/no-default-export
export default withSourceProvider<Props>(Expressions)('default');

View file

@ -0,0 +1,107 @@
/*
* 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, { ReactElement } from 'react';
import { act } from 'react-dom/test-utils';
import { LineAnnotation, RectAnnotation } from '@elastic/charts';
import { DataViewBase } from '@kbn/es-query';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock`
import { coreMock as mockCoreMock } from '@kbn/core/public/mocks';
import { MetricExpression } from '../types';
import { ExpressionChart } from './expression_chart';
import {
Aggregators,
Comparator,
MetricsSourceConfiguration,
} from '../../../../common/threshold_rule/types';
const mockStartServices = mockCoreMock.createStart();
jest.mock('../../../utils/kibana_react', () => ({
useKibana: () => ({
services: {
...mockStartServices,
charts: {
activeCursor: jest.fn(),
},
},
}),
}));
const mockResponse = {
pageInfo: {
afterKey: null,
total: 0,
},
series: [{ id: 'Everything', rows: [], columns: [] }],
};
jest.mock('../hooks/use_metrics_explorer_chart_data', () => ({
useMetricsExplorerChartData: () => ({ loading: false, data: { pages: [mockResponse] } }),
}));
describe('ExpressionChart', () => {
async function setup(
expression: MetricExpression,
filterQuery?: string,
groupBy?: string,
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>
) {
const derivedIndexPattern: DataViewBase = {
title: 'metricbeat-*',
fields: [],
};
const source: MetricsSourceConfiguration = {
id: 'default',
origin: 'fallback',
configuration: {
name: 'default',
description: 'The default configuration',
metricAlias: 'metricbeat-*',
inventoryDefaultView: 'host',
metricsExplorerDefaultView: 'host',
anomalyThreshold: 20,
},
};
const wrapper = mountWithIntl(
<ExpressionChart
expression={expression}
derivedIndexPattern={derivedIndexPattern}
source={source}
filterQuery={filterQuery}
groupBy={groupBy}
annotations={annotations}
/>
);
const update = async () =>
await act(async () => {
await nextTick();
wrapper.update();
});
await update();
return { wrapper, update };
}
it('should display no data message', async () => {
const expression: MetricExpression = {
aggType: Aggregators.AVERAGE,
timeSize: 1,
timeUnit: 'm',
sourceId: 'default',
threshold: [1],
comparator: Comparator.GT_OR_EQ,
};
const { wrapper } = await setup(expression);
expect(wrapper.find('[data-test-subj~="noChartData"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,233 @@
/*
* 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, { ReactElement, useRef } from 'react';
import {
Axis,
Chart,
LineAnnotation,
niceTimeFormatter,
Position,
RectAnnotation,
Settings,
} from '@elastic/charts';
import { EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { DataViewBase } from '@kbn/es-query';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { first, last } from 'lodash';
import moment from 'moment';
import { useKibana } from '../../../utils/kibana_react';
import {
MetricsExplorerAggregation,
MetricsExplorerRow,
} from '../../../../common/threshold_rule/metrics_explorer';
import { Color } from '../../../../common/threshold_rule/color_palette';
import {
MetricsExplorerChartType,
MetricsExplorerOptionsMetric,
MetricsSourceConfiguration,
} from '../../../../common/threshold_rule/types';
import { MetricExpression, TimeRange } from '../types';
import { createFormatterForMetric } from '../helpers/create_formatter_for_metric';
import { useMetricsExplorerChartData } from '../hooks/use_metrics_explorer_chart_data';
import {
ChartContainer,
LoadingState,
NoDataState,
TIME_LABELS,
getChartTheme,
} from './criterion_preview_chart/criterion_preview_chart';
import { ThresholdAnnotations } from './criterion_preview_chart/threshold_annotations';
import { CUSTOM_EQUATION } from '../i18n_strings';
import { calculateDomain } from '../helpers/calculate_domain';
import { getMetricId } from '../helpers/get_metric_id';
import { MetricExplorerSeriesChart } from './series_chart';
interface Props {
expression: MetricExpression;
derivedIndexPattern: DataViewBase;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
chartType?: MetricsExplorerChartType;
filterQuery?: string;
groupBy?: string | string[];
hideTitle?: boolean;
source?: MetricsSourceConfiguration;
timeRange?: TimeRange;
}
export function ExpressionChart({
expression,
derivedIndexPattern,
annotations,
chartType = MetricsExplorerChartType.bar,
filterQuery,
groupBy,
hideTitle = false,
source,
timeRange,
}: Props) {
const { charts, uiSettings } = useKibana().services;
const { isLoading, data } = useMetricsExplorerChartData(
expression,
derivedIndexPattern,
source,
filterQuery,
groupBy,
timeRange
);
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(charts.activeCursor, chartRef, {
isDateHistogram: true,
});
if (isLoading) {
return <LoadingState />;
}
if (!data) {
return <NoDataState />;
}
const isDarkMode = uiSettings?.get('theme:darkMode') || false;
const firstSeries = first(first(data.pages)!.series);
// Creating a custom series where the ID is changed to 0
// so that we can get a proper domain
if (!firstSeries || !firstSeries.rows || firstSeries.rows.length === 0) {
return <NoDataState />;
}
const firstTimestamp = first(firstSeries.rows)!.timestamp;
const lastTimestamp = last(firstSeries.rows)!.timestamp;
const metric: MetricsExplorerOptionsMetric = {
field: expression.metric,
aggregation: expression.aggType as MetricsExplorerAggregation,
color: Color.color0,
};
if (metric.aggregation === 'custom') {
metric.label = expression.label || CUSTOM_EQUATION;
}
const dateFormatter =
firstTimestamp == null || lastTimestamp == null
? (value: number) => `${value}`
: niceTimeFormatter([firstTimestamp, lastTimestamp]);
const criticalThresholds = expression.threshold.slice().sort();
const warningThresholds = expression.warningThreshold?.slice().sort() ?? [];
const thresholds = [...criticalThresholds, ...warningThresholds].sort();
const series = {
...firstSeries,
rows: firstSeries.rows.map((row) => {
const newRow: MetricsExplorerRow = { ...row };
thresholds.forEach((thresholdValue, index) => {
newRow[getMetricId(metric, `threshold_${index}`)] = thresholdValue;
});
return newRow;
}),
};
const dataDomain = calculateDomain(series, [metric], false);
const domain = {
max: Math.max(dataDomain.max, last(thresholds) || dataDomain.max) * 1.1,
min: Math.min(dataDomain.min, first(thresholds) || dataDomain.min) * 0.9, // add 10% floor,
};
if (domain.min === first(expression.threshold)) {
domain.min = domain.min * 0.9;
}
const { timeSize, timeUnit } = expression;
const timeLabel = TIME_LABELS[timeUnit as keyof typeof TIME_LABELS];
return (
<>
<ChartContainer>
<Chart ref={chartRef}>
<MetricExplorerSeriesChart
type={chartType}
metric={metric}
id="0"
series={series}
stack={false}
/>
<ThresholdAnnotations
comparator={expression.comparator}
threshold={expression.threshold}
sortedThresholds={criticalThresholds}
color={Color.color1}
id="critical"
firstTimestamp={firstTimestamp}
lastTimestamp={lastTimestamp}
domain={domain}
/>
{expression.warningComparator && expression.warningThreshold && (
<ThresholdAnnotations
comparator={expression.warningComparator}
threshold={expression.warningThreshold}
sortedThresholds={warningThresholds}
color={Color.color5}
id="warning"
firstTimestamp={firstTimestamp}
lastTimestamp={lastTimestamp}
domain={domain}
/>
)}
{annotations}
<Axis
id={'timestamp'}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis
id={'values'}
position={Position.Left}
tickFormat={createFormatterForMetric(metric)}
domain={domain}
/>
<Settings
onPointerUpdate={handleCursorUpdate}
tooltip={{
headerFormatter: ({ value }) =>
moment(value).format(uiSettings.get(UI_SETTINGS.DATE_FORMAT)),
}}
externalPointerEvents={{
tooltip: { visible: true },
}}
theme={getChartTheme(isDarkMode)}
/>
</Chart>
</ChartContainer>
{!hideTitle && (
<div style={{ textAlign: 'center' }}>
{series.id !== 'ALL' ? (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.observability.threshold.rule.alerts.dataTimeRangeLabelWithGrouping"
defaultMessage="Last {lookback} {timeLabel} of data for {id}"
values={{ id: series.id, timeLabel, lookback: timeSize! * 20 }}
/>
</EuiText>
) : (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.observability.threshold.rule.alerts.dataTimeRangeLabel"
defaultMessage="Last {lookback} {timeLabel}"
values={{ timeLabel, lookback: timeSize! * 20 }}
/>
</EuiText>
)}
</div>
)}
</>
);
}

View file

@ -0,0 +1,124 @@
/*
* 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 { Comparator } from '../../../../common/threshold_rule/types';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { MetricExpression } from '../types';
import { ExpressionRow } from './expression_row';
jest.mock('../helpers/source', () => ({
withSourceProvider: () => jest.fn,
useSourceContext: () => ({
source: { id: 'default' },
createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }),
}),
}));
describe('ExpressionRow', () => {
async function setup(expression: MetricExpression) {
const wrapper = mountWithIntl(
<ExpressionRow
canDelete={false}
fields={[
{
name: 'system.cpu.user.pct',
type: 'test',
searchable: true,
aggregatable: true,
displayable: true,
},
{
name: 'system.load.1',
type: 'test',
searchable: true,
aggregatable: true,
displayable: true,
},
]}
remove={() => {}}
addExpression={() => {}}
key={1}
expressionId={1}
setRuleParams={() => {}}
errors={{
aggField: [],
timeSizeUnit: [],
timeWindowSize: [],
}}
expression={expression}
dataView={{ fields: [], title: 'metricbeat-*' }}
/>
);
const update = async () =>
await act(async () => {
await nextTick();
wrapper.update();
});
await update();
return { wrapper, update };
}
it('should display thresholds as a percentage for pct metrics', async () => {
const expression = {
metric: 'system.cpu.user.pct',
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 1,
timeUnit: 'm',
aggType: 'avg',
};
const { wrapper, update } = await setup(expression as MetricExpression);
await update();
const [valueMatch] =
wrapper
.html()
.match('<span class="euiExpression__value css-xlzuv8-euiExpression__value">50</span>') ??
[];
expect(valueMatch).toBeTruthy();
});
it('should display thresholds as a decimal for all other metrics', async () => {
const expression = {
metric: 'system.load.1',
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 1,
timeUnit: 'm',
aggType: 'avg',
};
const { wrapper } = await setup(expression as MetricExpression);
const [valueMatch] =
wrapper
.html()
.match('<span class="euiExpression__value css-xlzuv8-euiExpression__value">0.5</span>') ??
[];
expect(valueMatch).toBeTruthy();
});
it('should render a helpText for the of expression', async () => {
const expression = {
metric: 'system.load.1',
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 1,
timeUnit: 'm',
aggType: 'avg',
} as MetricExpression;
const { wrapper } = await setup(expression as MetricExpression);
const helpText = wrapper.find('[data-test-subj="ofExpression"]').at(0).prop('helpText');
expect(helpText).toMatchSnapshot();
});
});

View file

@ -0,0 +1,508 @@
/*
* 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 {
EuiButtonEmpty,
EuiButtonIcon,
EuiExpression,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiLink,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { omit } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import {
AggregationType,
builtInComparators,
IErrorObject,
OfExpression,
ThresholdExpression,
} from '@kbn/triggers-actions-ui-plugin/public';
import { DataViewBase } from '@kbn/es-query';
import useToggle from 'react-use/lib/useToggle';
import { Aggregators, Comparator } from '../../../../common/threshold_rule/types';
import { AGGREGATION_TYPES, DerivedIndexPattern, MetricExpression } from '../types';
import { CustomEquationEditor } from './custom_equation';
import { CUSTOM_EQUATION } from '../i18n_strings';
import { decimalToPct, pctToDecimal } from '../helpers/corrected_percent_convert';
const customComparators = {
...builtInComparators,
[Comparator.OUTSIDE_RANGE]: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.outsideRangeLabel', {
defaultMessage: 'Is not between',
}),
value: Comparator.OUTSIDE_RANGE,
requiredValues: 2,
},
};
interface ExpressionRowProps {
fields: DerivedIndexPattern['fields'];
expressionId: number;
expression: MetricExpression;
errors: IErrorObject;
canDelete: boolean;
addExpression(): void;
remove(id: number): void;
setRuleParams(id: number, params: MetricExpression): void;
dataView: DataViewBase;
}
const StyledExpressionRow = euiStyled(EuiFlexGroup)`
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 0 -4px;
`;
const StyledExpression = euiStyled.div`
padding: 0 4px;
`;
const StyledHealth = euiStyled(EuiHealth)`
margin-left: 4px;
`;
// eslint-disable-next-line react/function-component-definition
export const ExpressionRow: React.FC<ExpressionRowProps> = (props) => {
const [isExpanded, toggle] = useToggle(true);
const {
dataView,
children,
setRuleParams,
expression,
errors,
expressionId,
remove,
fields,
canDelete,
} = props;
const {
aggType = AGGREGATION_TYPES.MAX,
metric,
comparator = Comparator.GT,
threshold = [],
warningThreshold = [],
warningComparator,
} = expression;
const [displayWarningThreshold, setDisplayWarningThreshold] = useState(
Boolean(warningThreshold?.length)
);
const isMetricPct = useMemo(() => Boolean(metric && metric.endsWith('.pct')), [metric]);
const updateMetric = useCallback(
(m?: MetricExpression['metric']) => {
setRuleParams(expressionId, { ...expression, metric: m });
},
[expressionId, expression, setRuleParams]
);
const updateComparator = useCallback(
(c?: string) => {
setRuleParams(expressionId, { ...expression, comparator: c as Comparator });
},
[expressionId, expression, setRuleParams]
);
const updateWarningComparator = useCallback(
(c?: string) => {
setRuleParams(expressionId, { ...expression, warningComparator: c as Comparator });
},
[expressionId, expression, setRuleParams]
);
const convertThreshold = useCallback(
(enteredThreshold) =>
isMetricPct ? enteredThreshold.map((v: number) => pctToDecimal(v)) : enteredThreshold,
[isMetricPct]
);
const updateThreshold = useCallback(
(enteredThreshold) => {
const t = convertThreshold(enteredThreshold);
if (t.join() !== expression.threshold.join()) {
setRuleParams(expressionId, { ...expression, threshold: t });
}
},
[expressionId, expression, convertThreshold, setRuleParams]
);
const updateWarningThreshold = useCallback(
(enteredThreshold) => {
const t = convertThreshold(enteredThreshold);
if (t.join() !== expression.warningThreshold?.join()) {
setRuleParams(expressionId, { ...expression, warningThreshold: t });
}
},
[expressionId, expression, convertThreshold, setRuleParams]
);
const toggleWarningThreshold = useCallback(() => {
if (!displayWarningThreshold) {
setDisplayWarningThreshold(true);
setRuleParams(expressionId, {
...expression,
warningComparator: comparator,
warningThreshold: [],
});
} else {
setDisplayWarningThreshold(false);
setRuleParams(expressionId, omit(expression, 'warningComparator', 'warningThreshold'));
}
}, [
displayWarningThreshold,
setDisplayWarningThreshold,
setRuleParams,
comparator,
expression,
expressionId,
]);
const handleCustomMetricChange = useCallback(
(exp) => {
setRuleParams(expressionId, exp);
},
[expressionId, setRuleParams]
);
const criticalThresholdExpression = (
<ThresholdElement
comparator={comparator}
threshold={threshold}
updateComparator={updateComparator}
updateThreshold={updateThreshold}
errors={(errors.critical as IErrorObject) ?? {}}
isMetricPct={isMetricPct}
/>
);
const warningThresholdExpression = displayWarningThreshold && (
<ThresholdElement
comparator={warningComparator || comparator}
threshold={warningThreshold}
updateComparator={updateWarningComparator}
updateThreshold={updateWarningThreshold}
errors={(errors.warning as IErrorObject) ?? {}}
isMetricPct={isMetricPct}
/>
);
const normalizedFields = fields.map((f) => ({
normalizedType: f.type,
name: f.name,
}));
// for v8.9 we want to show only the Custom Equation. Use EuiExpression instead of WhenExpression */
// const updateAggType = useCallback(
// (at: string) => {
// setRuleParams(expressionId, {
// ...expression,
// aggType: at as MetricExpression['aggType'],
// metric: ['custom', 'count'].includes(at) ? undefined : expression.metric,
// customMetrics: at === 'custom' ? expression.customMetrics : undefined,
// equation: at === 'custom' ? expression.equation : undefined,
// label: at === 'custom' ? expression.label : undefined,
// });
// },
// [expressionId, expression, setRuleParams]
// );
return (
<>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={isExpanded ? 'arrowDown' : 'arrowRight'}
onClick={toggle}
data-test-subj="expandRow"
aria-label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.expandRowLabel',
{
defaultMessage: 'Expand row.',
}
)}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<StyledExpressionRow style={{ gap: aggType !== 'custom' ? 24 : 12 }}>
<StyledExpression>
{/* for v8.9 we want to show only the Custom Equation. Use EuiExpression instead of WhenExpression */}
{/* <WhenExpression
customAggTypesOptions={aggregationType}
aggType={aggType}
onChangeSelectedAggType={updateAggType}
/> */}
<EuiExpression
data-test-subj="customEquationWhen"
description={i18n.translate(
'xpack.observability.thresholdRule.expressionItems.descriptionLabel',
{
defaultMessage: 'when',
}
)}
value={aggregationType.custom.text}
display={'inline'}
/>
</StyledExpression>
{!['count', 'custom'].includes(aggType) && (
<StyledExpression>
<OfExpression
customAggTypesOptions={aggregationType}
aggField={metric}
fields={normalizedFields}
aggType={aggType}
errors={errors}
onChangeSelectedAggField={updateMetric}
helpText={
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.ofExpression.helpTextDetail"
defaultMessage="Can't find a metric? {documentationLink}."
values={{
documentationLink: (
<EuiLink
data-test-subj="thresholdRuleExpressionRowLearnHowToAddMoreDataLink"
href="https://www.elastic.co/guide/en/observability/current/configure-settings.html"
target="BLANK"
>
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.ofExpression.popoverLinkLabel"
defaultMessage="Learn how to add more data"
/>
</EuiLink>
),
}}
/>
}
data-test-subj="ofExpression"
/>
</StyledExpression>
)}
{!displayWarningThreshold && criticalThresholdExpression}
{!displayWarningThreshold && (
<>
<EuiSpacer size={'xs'} />
<StyledExpressionRow>
<EuiButtonEmpty
data-test-subj="thresholdRuleExpressionRowAddWarningThresholdButton"
color={'primary'}
flush={'left'}
size="xs"
iconType={'plusInCircleFilled'}
onClick={toggleWarningThreshold}
>
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.addWarningThreshold"
defaultMessage="Add warning threshold"
/>
</EuiButtonEmpty>
</StyledExpressionRow>
</>
)}
</StyledExpressionRow>
{displayWarningThreshold && (
<>
<StyledExpressionRow>
{criticalThresholdExpression}
<StyledHealth color="danger">
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.criticalThreshold"
defaultMessage="Alert"
/>
</StyledHealth>
</StyledExpressionRow>
<StyledExpressionRow>
{warningThresholdExpression}
<StyledHealth color="warning">
<FormattedMessage
id="xpack.observability.threshold.rule.alertFlyout.warningThreshold"
defaultMessage="Warning"
/>
</StyledHealth>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.removeWarningThreshold',
{
defaultMessage: 'Remove warningThreshold',
}
)}
iconSize="s"
color="text"
iconType={'minusInCircleFilled'}
onClick={toggleWarningThreshold}
/>
</StyledExpressionRow>
</>
)}
{aggType === Aggregators.CUSTOM && (
<>
<EuiSpacer size={'m'} />
<StyledExpressionRow>
<CustomEquationEditor
expression={expression}
fields={normalizedFields}
aggregationTypes={aggregationType}
onChange={handleCustomMetricChange}
errors={errors}
dataView={dataView}
/>
</StyledExpressionRow>
<EuiSpacer size={'s'} />
</>
)}
</EuiFlexItem>
{canDelete && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.removeCondition',
{
defaultMessage: 'Remove condition',
}
)}
color={'danger'}
iconType={'trash'}
onClick={() => remove(expressionId)}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
{isExpanded ? <div style={{ padding: '0 0 0 28px' }}>{children}</div> : null}
<EuiSpacer size={'s'} />
</>
);
};
const ThresholdElement: React.FC<{
updateComparator: (c?: string) => void;
updateThreshold: (t?: number[]) => void;
threshold: MetricExpression['threshold'];
isMetricPct: boolean;
comparator: MetricExpression['comparator'];
errors: IErrorObject;
// eslint-disable-next-line react/function-component-definition
}> = ({ updateComparator, updateThreshold, threshold, isMetricPct, comparator, errors }) => {
const displayedThreshold = useMemo(() => {
if (isMetricPct) return threshold.map((v) => decimalToPct(v));
return threshold;
}, [threshold, isMetricPct]);
return (
<>
<StyledExpression>
<ThresholdExpression
thresholdComparator={comparator || Comparator.GT}
threshold={displayedThreshold}
customComparators={customComparators}
onChangeSelectedThresholdComparator={updateComparator}
onChangeSelectedThreshold={updateThreshold}
errors={errors}
/>
</StyledExpression>
{isMetricPct && (
<div
style={{
alignSelf: 'center',
}}
>
<EuiText size={'s'}>%</EuiText>
</div>
)}
</>
);
};
export const aggregationType: { [key: string]: AggregationType } = {
avg: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.avg', {
defaultMessage: 'Average',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'histogram'],
value: AGGREGATION_TYPES.AVERAGE,
},
max: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.max', {
defaultMessage: 'Max',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'date', 'histogram'],
value: AGGREGATION_TYPES.MAX,
},
min: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.min', {
defaultMessage: 'Min',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'date', 'histogram'],
value: AGGREGATION_TYPES.MIN,
},
cardinality: {
text: i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.aggregationText.cardinality',
{
defaultMessage: 'Cardinality',
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.CARDINALITY,
validNormalizedTypes: ['number', 'string', 'ip', 'date'],
},
rate: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.rate', {
defaultMessage: 'Rate',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.RATE,
validNormalizedTypes: ['number'],
},
count: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.count', {
defaultMessage: 'Document count',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.COUNT,
validNormalizedTypes: ['number'],
},
sum: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.sum', {
defaultMessage: 'Sum',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.SUM,
validNormalizedTypes: ['number', 'histogram'],
},
p95: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.p95', {
defaultMessage: '95th Percentile',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.P95,
validNormalizedTypes: ['number', 'histogram'],
},
p99: {
text: i18n.translate('xpack.observability.threshold.rule.alertFlyout.aggregationText.p99', {
defaultMessage: '99th Percentile',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.P99,
validNormalizedTypes: ['number', 'histogram'],
},
custom: {
text: CUSTOM_EQUATION,
fieldRequired: false,
value: AGGREGATION_TYPES.CUSTOM,
validNormalizedTypes: ['number', 'histogram'],
},
};

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 { EuiComboBox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback } from 'react';
import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options';
import { DerivedIndexPattern } from '../types';
interface Props {
options: MetricsExplorerOptions;
onChange: (groupBy: string | null | string[]) => void;
fields: DerivedIndexPattern['fields'];
errorOptions?: string[];
}
export function MetricsExplorerGroupBy({ options, onChange, fields, errorOptions }: Props) {
const handleChange = useCallback(
(selectedOptions: Array<{ label: string }>) => {
const groupBy = selectedOptions.map((option) => option.label);
onChange(groupBy);
},
[onChange]
);
const selectedOptions = Array.isArray(options.groupBy)
? options.groupBy.map((field) => ({
label: field,
color: errorOptions?.includes(field) ? 'danger' : undefined,
}))
: options.groupBy
? [
{
label: options.groupBy,
color: errorOptions?.includes(options.groupBy) ? 'danger' : undefined,
},
]
: [];
return (
<EuiComboBox
data-test-subj="metricsExplorer-groupBy"
placeholder={i18n.translate('xpack.observability.threshold.ruleExplorer.groupByLabel', {
defaultMessage: 'Everything',
})}
aria-label={i18n.translate('xpack.observability.threshold.ruleExplorer.groupByAriaLabel', {
defaultMessage: 'Graph per',
})}
fullWidth
singleSelection={false}
selectedOptions={selectedOptions}
options={fields
.filter((f) => f.aggregatable && f.type === 'string')
.map((f) => ({ label: f.name }))}
onChange={handleChange}
isClearable={true}
/>
);
}

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { fromKueryExpression } from '@kbn/es-query';
import React, { useEffect, useState } from 'react';
import { DataViewBase } from '@kbn/es-query';
import { QuerySuggestion } from '@kbn/unified-search-plugin/public';
import { WithKueryAutocompletion } from '../containers/with_kuery_autocompletion';
import { AutocompleteField } from './autocomplete_field';
type LoadSuggestionsFn = (
e: string,
p: number,
m?: number,
transform?: (s: QuerySuggestion[]) => QuerySuggestion[]
) => void;
export type CurryLoadSuggestionsType = (loadSuggestions: LoadSuggestionsFn) => LoadSuggestionsFn;
interface Props {
derivedIndexPattern: DataViewBase;
onSubmit: (query: string) => void;
onChange?: (query: string) => void;
value?: string | null;
placeholder?: string;
curryLoadSuggestions?: CurryLoadSuggestionsType;
compressed?: boolean;
}
function validateQuery(query: string) {
try {
fromKueryExpression(query);
} catch (err) {
return false;
}
return true;
}
export function MetricsExplorerKueryBar({
derivedIndexPattern,
onSubmit,
onChange,
value,
placeholder,
curryLoadSuggestions = defaultCurryLoadSuggestions,
compressed,
}: Props) {
const [draftQuery, setDraftQuery] = useState<string>(value || '');
const [isValid, setValidation] = useState<boolean>(true);
// This ensures that if value changes out side this component it will update.
useEffect(() => {
if (value) {
setDraftQuery(value);
}
}, [value]);
const handleChange = (query: string) => {
setValidation(validateQuery(query));
setDraftQuery(query);
if (onChange) {
onChange(query);
}
};
const filteredDerivedIndexPattern = {
...derivedIndexPattern,
fields: derivedIndexPattern.fields,
};
const defaultPlaceholder = i18n.translate(
'xpack.observability.threshold.rule.homePage.toolbar.kqlSearchFieldPlaceholder',
{
defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)',
}
);
return (
<WithKueryAutocompletion indexPattern={filteredDerivedIndexPattern}>
{({ isLoadingSuggestions, loadSuggestions, suggestions }) => (
<AutocompleteField
compressed={compressed}
aria-label={placeholder}
isLoadingSuggestions={isLoadingSuggestions}
isValid={isValid}
loadSuggestions={curryLoadSuggestions(loadSuggestions)}
onChange={handleChange}
onSubmit={onSubmit}
placeholder={placeholder || defaultPlaceholder}
suggestions={suggestions}
value={draftQuery}
/>
)}
</WithKueryAutocompletion>
);
}
const defaultCurryLoadSuggestions: CurryLoadSuggestionsType =
(loadSuggestions) =>
(...args) =>
loadSuggestions(...args);

View file

@ -0,0 +1,183 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useState, useCallback, useMemo } from 'react';
import {
EuiPopover,
EuiHeaderLink,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { InfraClientStartDeps } from '../types';
import { PrefilledThresholdAlertFlyout } from './alert_flyout';
type VisibleFlyoutType = 'inventory' | 'threshold' | null;
export function MetricsAlertDropdown() {
const [popoverOpen, setPopoverOpen] = useState(false);
const [visibleFlyoutType, setVisibleFlyoutType] = useState<VisibleFlyoutType>(null);
const uiCapabilities = useKibana().services.application?.capabilities;
const {
services: { observability },
} = useKibana<InfraClientStartDeps>();
const canCreateAlerts = useMemo(
() => Boolean(uiCapabilities?.infrastructure?.save),
[uiCapabilities]
);
const closeFlyout = useCallback(() => setVisibleFlyoutType(null), [setVisibleFlyoutType]);
const closePopover = useCallback(() => {
setPopoverOpen(false);
}, [setPopoverOpen]);
const togglePopover = useCallback(() => {
setPopoverOpen(!popoverOpen);
}, [setPopoverOpen, popoverOpen]);
const infrastructureAlertsPanel = useMemo(
() => ({
id: 1,
title: i18n.translate('xpack.observability.threshold.rule.infrastructureDropdownTitle', {
defaultMessage: 'Infrastructure rules',
}),
items: [
{
'data-test-subj': 'inventory-alerts-create-rule',
name: i18n.translate('xpack.observability.threshold.rule.createInventoryRuleButton', {
defaultMessage: 'Create inventory rule',
}),
onClick: () => {
closePopover();
setVisibleFlyoutType('inventory');
},
},
],
}),
[setVisibleFlyoutType, closePopover]
);
const metricsAlertsPanel = useMemo(
() => ({
id: 2,
title: i18n.translate('xpack.observability.threshold.rule.metricsDropdownTitle', {
defaultMessage: 'Metrics rules',
}),
items: [
{
'data-test-subj': 'metrics-threshold-alerts-create-rule',
name: i18n.translate('xpack.observability.threshold.rule.createThresholdRuleButton', {
defaultMessage: 'Create threshold rule',
}),
onClick: () => {
closePopover();
setVisibleFlyoutType('threshold');
},
},
],
}),
[setVisibleFlyoutType, closePopover]
);
const manageRulesLinkProps = observability.useRulesLink();
const manageAlertsMenuItem = useMemo(
() => ({
name: i18n.translate('xpack.observability.threshold.rule.manageRules', {
defaultMessage: 'Manage rules',
}),
icon: 'tableOfContents',
onClick: manageRulesLinkProps.onClick,
}),
[manageRulesLinkProps]
);
const firstPanelMenuItems: EuiContextMenuPanelDescriptor['items'] = useMemo(
() =>
canCreateAlerts
? [
{
'data-test-subj': 'inventory-alerts-menu-option',
name: i18n.translate(
'xpack.observability.threshold.rule.infrastructureDropdownMenu',
{
defaultMessage: 'Infrastructure',
}
),
panel: 1,
},
{
'data-test-subj': 'metrics-threshold-alerts-menu-option',
name: i18n.translate('xpack.observability.threshold.rule.metricsDropdownMenu', {
defaultMessage: 'Metrics',
}),
panel: 2,
},
manageAlertsMenuItem,
]
: [manageAlertsMenuItem],
[canCreateAlerts, manageAlertsMenuItem]
);
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() =>
[
{
id: 0,
title: i18n.translate('xpack.observability.threshold.rule.alertDropdownTitle', {
defaultMessage: 'Alerts and rules',
}),
items: firstPanelMenuItems,
},
].concat(canCreateAlerts ? [infrastructureAlertsPanel, metricsAlertsPanel] : []),
[infrastructureAlertsPanel, metricsAlertsPanel, firstPanelMenuItems, canCreateAlerts]
);
return (
<>
<EuiPopover
panelPaddingSize="none"
anchorPosition="downLeft"
button={
<EuiHeaderLink
color="text"
iconSide={'right'}
iconType={'arrowDown'}
onClick={togglePopover}
data-test-subj="thresholdRulestructure-alerts-and-rules"
>
<FormattedMessage
id="xpack.observability.threshold.rule.alertsButton"
defaultMessage="Alerts and rules"
/>
</EuiHeaderLink>
}
isOpen={popoverOpen}
closePopover={closePopover}
>
<EuiContextMenu initialPanelId={0} panels={panels} data-test-subj="metrics-alert-menu" />
</EuiPopover>
<AlertFlyout visibleFlyoutType={visibleFlyoutType} onClose={closeFlyout} />
</>
);
}
interface AlertFlyoutProps {
visibleFlyoutType: VisibleFlyoutType;
onClose(): void;
}
function AlertFlyout({ visibleFlyoutType, onClose }: AlertFlyoutProps) {
switch (visibleFlyoutType) {
case 'threshold':
return <PrefilledThresholdAlertFlyout onClose={onClose} />;
default:
return null;
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
ScaleType,
AreaSeries,
BarSeries,
RecursivePartial,
AreaSeriesStyle,
BarSeriesStyle,
} from '@elastic/charts';
import { MetricsExplorerSeries } from '../../../../common/threshold_rule/metrics_explorer';
import { Color, colorTransformer } from '../../../../common/threshold_rule/color_palette';
import {
MetricsExplorerChartType,
MetricsExplorerOptionsMetric,
} from '../../../../common/threshold_rule/types';
import { getMetricId } from '../helpers/get_metric_id';
import { useKibanaTimeZoneSetting } from '../hooks/use_kibana_time_zone_setting';
import { createMetricLabel } from '../helpers/create_metric_label';
type NumberOrString = string | number;
interface Props {
metric: MetricsExplorerOptionsMetric;
id: NumberOrString | NumberOrString[];
series: MetricsExplorerSeries;
type: MetricsExplorerChartType;
stack: boolean;
opacity?: number;
}
export function MetricExplorerSeriesChart(props: Props) {
if (MetricsExplorerChartType.bar === props.type) {
return <MetricsExplorerBarChart {...props} />;
}
return <MetricsExplorerAreaChart {...props} />;
}
export function MetricsExplorerAreaChart({ metric, id, series, type, stack, opacity }: Props) {
const timezone = useKibanaTimeZoneSetting();
const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0);
const yAccessors = Array.isArray(id)
? id.map((i) => getMetricId(metric, i)).slice(id.length - 1, id.length)
: [getMetricId(metric, id)];
const y0Accessors =
Array.isArray(id) && id.length > 1
? id.map((i) => getMetricId(metric, i)).slice(0, 1)
: undefined;
const chartId = `series-${series.id}-${yAccessors.join('-')}`;
const seriesAreaStyle: RecursivePartial<AreaSeriesStyle> = {
line: {
strokeWidth: 2,
visible: true,
},
area: {
opacity: opacity || 0.5,
visible: type === MetricsExplorerChartType.area,
},
};
return (
<AreaSeries
id={chartId}
key={chartId}
name={createMetricLabel(metric)}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="timestamp"
yAccessors={yAccessors}
y0Accessors={y0Accessors}
data={series.rows}
stackAccessors={stack ? ['timestamp'] : void 0}
areaSeriesStyle={seriesAreaStyle}
color={color}
timeZone={timezone}
/>
);
}
export function MetricsExplorerBarChart({ metric, id, series, stack }: Props) {
const timezone = useKibanaTimeZoneSetting();
const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0);
const yAccessors = Array.isArray(id)
? id.map((i) => getMetricId(metric, i)).slice(id.length - 1, id.length)
: [getMetricId(metric, id)];
const chartId = `series-${series.id}-${yAccessors.join('-')}`;
const seriesBarStyle: RecursivePartial<BarSeriesStyle> = {
rectBorder: {
stroke: color,
strokeWidth: 1,
visible: true,
},
rect: {
opacity: 1,
},
};
return (
<BarSeries
id={chartId}
key={chartId}
name={createMetricLabel(metric)}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="timestamp"
yAccessors={yAccessors}
data={series.rows}
stackAccessors={stack ? ['timestamp'] : void 0}
barSeriesStyle={seriesBarStyle}
color={color}
timeZone={timezone}
/>
);
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { LIGHT_THEME } from '@elastic/charts';
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { Comparator } from '../../../../common/threshold_rule/types';
import { Props, Threshold as Component } from './threshold';
export default {
component: Component,
title: 'infra/alerting/Threshold',
decorators: [
(Story) => (
<div
style={{
height: '160px',
width: '240px',
}}
>
{Story()}
</div>
),
],
} as ComponentMeta<typeof Component>;
const defaultProps: Props = {
chartProps: { theme: EUI_CHARTS_THEME_LIGHT.theme, baseTheme: LIGHT_THEME },
comparator: Comparator.GT,
id: 'componentId',
threshold: 90,
title: 'Threshold breached',
value: 93,
valueFormatter: (d) => `${d}%`,
};
export const Default = {
args: {
...defaultProps,
},
};

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 { LIGHT_THEME } from '@elastic/charts';
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { render } from '@testing-library/react';
import { Props, Threshold } from './threshold';
import React from 'react';
import { Comparator } from '../../../../common/threshold_rule/types';
describe('Threshold', () => {
const renderComponent = (props: Partial<Props> = {}) => {
const defaultProps: Props = {
chartProps: { theme: EUI_CHARTS_THEME_LIGHT.theme, baseTheme: LIGHT_THEME },
comparator: Comparator.GT,
id: 'componentId',
threshold: 90,
title: 'Threshold breached',
value: 93,
valueFormatter: (d) => `${d}%`,
};
return render(
<div
style={{
height: '160px',
width: '240px',
}}
>
<Threshold {...defaultProps} {...props} />
</div>
);
};
it('shows component', () => {
const component = renderComponent();
expect(component.queryByTestId('threshold-90-93')).toBeTruthy();
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Chart, Metric, Settings } from '@elastic/charts';
import { EuiIcon, EuiPanel, useEuiBackgroundColor } from '@elastic/eui';
import type { PartialTheme, Theme } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { Comparator } from '../../../../common/threshold_rule/types';
export interface ChartProps {
theme: PartialTheme;
baseTheme: Theme;
}
export interface Props {
chartProps: ChartProps;
comparator: Comparator | string;
id: string;
threshold: number;
title: string;
value: number;
valueFormatter: (d: number) => string;
}
export function Threshold({
chartProps: { theme, baseTheme },
comparator,
id,
threshold,
title,
value,
valueFormatter,
}: Props) {
const color = useEuiBackgroundColor('danger');
return (
<EuiPanel
paddingSize="none"
style={{
height: '100%',
overflow: 'hidden',
position: 'relative',
minWidth: '100%',
}}
hasShadow={false}
data-test-subj={`threshold-${threshold}-${value}`}
>
<Chart>
<Settings theme={theme} baseTheme={baseTheme} />
<Metric
id={id}
data={[
[
{
title,
extra: (
<span>
{i18n.translate('xpack.observability.threshold.rule.thresholdExtraTitle', {
values: { comparator, threshold: valueFormatter(threshold) },
defaultMessage: `Alert when {comparator} {threshold}`,
})}
</span>
),
color,
value,
valueFormatter,
icon: ({ width, height, color: iconColor }) => (
<EuiIcon width={width} height={height} color={iconColor} type="alert" />
),
},
],
]}
/>
</Chart>
</EuiPanel>
);
}

View file

@ -0,0 +1,34 @@
/*
* 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 * as React from 'react';
import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
interface ContextProps {
triggersActionsUI: TriggersAndActionsUIPublicPluginStart | null;
}
export const TriggerActionsContext = React.createContext<ContextProps>({
triggersActionsUI: null,
});
interface Props {
triggersActionsUI: TriggersAndActionsUIPublicPluginStart;
children: React.ReactNode;
}
export function TriggersActionsProvider(props: Props) {
return (
<TriggerActionsContext.Provider
value={{
triggersActionsUI: props.triggersActionsUI,
}}
>
{props.children}
</TriggerActionsContext.Provider>
);
}

View file

@ -0,0 +1,33 @@
/*
* 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 { EQUATION_REGEX } from './validation';
describe('Metric Threshold Validation', () => {
describe('valid equations', () => {
const validExpression = [
'(A + B) / 100',
'(A - B) * 100',
'A > 1 ? A : B',
'A <= 1 ? A : B',
'A && B || C',
];
validExpression.forEach((exp) => {
it(exp, () => {
expect(exp.match(EQUATION_REGEX)).toBeFalsy();
});
});
});
describe('invalid equations', () => {
const validExpression = ['Math.round(A + B) / 100', '(A^2 - B) * 100'];
validExpression.forEach((exp) => {
it(exp, () => {
expect(exp.match(EQUATION_REGEX)).toBeTruthy();
});
});
});
});

View file

@ -0,0 +1,220 @@
/*
* 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 { fromKueryExpression } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public';
import { isEmpty } from 'lodash';
import {
Aggregators,
Comparator,
CustomMetricExpressionParams,
FilterQuery,
MetricExpressionParams,
QUERY_INVALID,
} from '../../../../common/threshold_rule/types';
export const EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
const isCustomMetricExpressionParams = (
subject: MetricExpressionParams
): subject is CustomMetricExpressionParams => {
return subject.aggType === Aggregators.CUSTOM;
};
export function validateMetricThreshold({
criteria,
filterQuery,
}: {
criteria: MetricExpressionParams[];
filterQuery?: FilterQuery;
}): ValidationResult {
const validationResult = { errors: {} };
const errors: {
[id: string]: {
aggField: string[];
timeSizeUnit: string[];
timeWindowSize: string[];
critical: {
threshold0: string[];
threshold1: string[];
};
warning: {
threshold0: string[];
threshold1: string[];
};
metric: string[];
customMetricsError?: string;
customMetrics: Record<string, { aggType?: string; field?: string; filter?: string }>;
equation?: string;
};
} & { filterQuery?: string[] } = {};
validationResult.errors = errors;
if (filterQuery === QUERY_INVALID) {
errors.filterQuery = [
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.invalidFilterQuery', {
defaultMessage: 'Filter query is invalid.',
}),
];
}
if (!criteria || !criteria.length) {
return validationResult;
}
criteria.forEach((c, idx) => {
// Create an id for each criteria, so we can map errors to specific criteria.
const id = idx.toString();
errors[id] = errors[id] || {
aggField: [],
timeSizeUnit: [],
timeWindowSize: [],
critical: {
threshold0: [],
threshold1: [],
},
warning: {
threshold0: [],
threshold1: [],
},
metric: [],
filterQuery: [],
customMetrics: {},
};
if (!c.aggType) {
errors[id].aggField.push(
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.aggregationRequired', {
defaultMessage: 'Aggregation is required.',
})
);
}
if (!c.threshold || !c.threshold.length) {
errors[id].critical.threshold0.push(
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.thresholdRequired', {
defaultMessage: 'Threshold is required.',
})
);
}
if (c.warningThreshold && !c.warningThreshold.length) {
errors[id].warning.threshold0.push(
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.thresholdRequired', {
defaultMessage: 'Threshold is required.',
})
);
}
for (const props of [
{ comparator: c.comparator, threshold: c.threshold, type: 'critical' },
{ comparator: c.warningComparator, threshold: c.warningThreshold, type: 'warning' },
]) {
// The Threshold component returns an empty array with a length ([empty]) because it's using delete newThreshold[i].
// We need to use [...c.threshold] to convert it to an array with an undefined value ([undefined]) so we can test each element.
const { comparator, threshold, type } = props as {
comparator?: Comparator;
threshold?: number[];
type: 'critical' | 'warning';
};
if (threshold && threshold.length && ![...threshold].every(isNumber)) {
[...threshold].forEach((v, i) => {
if (!isNumber(v)) {
const key = i === 0 ? 'threshold0' : 'threshold1';
errors[id][type][key].push(
i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.error.thresholdTypeRequired',
{
defaultMessage: 'Thresholds must contain a valid number.',
}
)
);
}
});
}
if (comparator === Comparator.BETWEEN && (!threshold || threshold.length < 2)) {
errors[id][type].threshold1.push(
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.thresholdRequired', {
defaultMessage: 'Threshold is required.',
})
);
}
}
if (!c.timeSize) {
errors[id].timeWindowSize.push(
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.timeRequred', {
defaultMessage: 'Time size is Required.',
})
);
}
if (c.aggType !== 'count' && c.aggType !== 'custom' && !c.metric) {
errors[id].metric.push(
i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.metricRequired', {
defaultMessage: 'Metric is required.',
})
);
}
if (isCustomMetricExpressionParams(c)) {
if (!c.customMetrics || (c.customMetrics && c.customMetrics.length < 1)) {
errors[id].customMetricsError = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.error.customMetricsError',
{
defaultMessage: 'You must define at least 1 custom metric',
}
);
} else {
c.customMetrics.forEach((metric) => {
const customMetricErrors: { aggType?: string; field?: string; filter?: string } = {};
if (!metric.aggType) {
customMetricErrors.aggType = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.error.customMetrics.aggTypeRequired',
{
defaultMessage: 'Aggregation is required',
}
);
}
if (metric.aggType !== 'count' && !metric.field) {
customMetricErrors.field = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.error.customMetrics.fieldRequired',
{
defaultMessage: 'Field is required',
}
);
}
if (metric.aggType === 'count' && metric.filter) {
try {
fromKueryExpression(metric.filter);
} catch (e) {
customMetricErrors.filter = e.message;
}
}
if (!isEmpty(customMetricErrors)) {
errors[id].customMetrics[metric.name] = customMetricErrors;
}
});
}
if (c.equation && c.equation.match(EQUATION_REGEX)) {
errors[id].equation = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.error.equation.invalidCharacters',
{
defaultMessage:
'The equation field only supports the following characters: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
);
}
}
});
return validationResult;
}
const isNumber = (value: unknown): value is number => typeof value === 'number';

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { DataViewBase } from '@kbn/es-query';
import {
withKibana,
KibanaReactContextValue,
KibanaServices,
} from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { QuerySuggestion } from '@kbn/unified-search-plugin/public';
import { InfraClientStartDeps, RendererFunction } from '../types';
interface WithKueryAutocompletionLifecycleProps {
kibana: KibanaReactContextValue<InfraClientStartDeps & KibanaServices>;
children: RendererFunction<{
isLoadingSuggestions: boolean;
loadSuggestions: (expression: string, cursorPosition: number, maxSuggestions?: number) => void;
suggestions: QuerySuggestion[];
}>;
indexPattern: DataViewBase;
}
interface WithKueryAutocompletionLifecycleState {
// lacking cancellation support in the autocompletion api,
// this is used to keep older, slower requests from clobbering newer ones
currentRequest: {
expression: string;
cursorPosition: number;
} | null;
suggestions: QuerySuggestion[];
}
class WithKueryAutocompletionComponent extends React.Component<
WithKueryAutocompletionLifecycleProps,
WithKueryAutocompletionLifecycleState
> {
public readonly state: WithKueryAutocompletionLifecycleState = {
currentRequest: null,
suggestions: [],
};
public render() {
const { currentRequest, suggestions } = this.state;
return this.props.children({
isLoadingSuggestions: currentRequest !== null,
loadSuggestions: this.loadSuggestions,
suggestions,
});
}
private loadSuggestions = async (
expression: string,
cursorPosition: number,
maxSuggestions?: number,
transformSuggestions?: (s: QuerySuggestion[]) => QuerySuggestion[]
) => {
const { indexPattern } = this.props;
const language = 'kuery';
const hasQuerySuggestions =
this.props.kibana.services.unifiedSearch.autocomplete.hasQuerySuggestions(language);
if (!hasQuerySuggestions) {
return;
}
this.setState({
currentRequest: {
expression,
cursorPosition,
},
suggestions: [],
});
const suggestions =
(await this.props.kibana.services.unifiedSearch.autocomplete.getQuerySuggestions({
language,
query: expression,
selectionStart: cursorPosition,
selectionEnd: cursorPosition,
indexPatterns: [indexPattern as DataView],
boolFilter: [],
})) || [];
const transformedSuggestions = transformSuggestions
? transformSuggestions(suggestions)
: suggestions;
this.setState((state) =>
state.currentRequest &&
state.currentRequest.expression !== expression &&
state.currentRequest.cursorPosition !== cursorPosition
? state // ignore this result, since a newer request is in flight
: {
...state,
currentRequest: null,
suggestions: maxSuggestions
? transformedSuggestions.slice(0, maxSuggestions)
: transformedSuggestions,
}
);
};
}
export const WithKueryAutocompletion = withKibana<WithKueryAutocompletionLifecycleProps>(
WithKueryAutocompletionComponent
);

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 { min, max, sum, isNumber } from 'lodash';
import { MetricsExplorerSeries } from '../../../../common/threshold_rule/metrics_explorer';
import { MetricsExplorerOptionsMetric } from '../../../../common/threshold_rule/types';
import { getMetricId } from './get_metric_id';
const getMin = (values: Array<number | null>) => {
const minValue = min(values);
return isNumber(minValue) && Number.isFinite(minValue) ? minValue : undefined;
};
const getMax = (values: Array<number | null>) => {
const maxValue = max(values);
return isNumber(maxValue) && Number.isFinite(maxValue) ? maxValue : undefined;
};
export const calculateDomain = (
series: MetricsExplorerSeries,
metrics: MetricsExplorerOptionsMetric[],
stacked = false
): { min: number; max: number } => {
const values = series.rows
.reduce((acc, row) => {
const rowValues = metrics
.map((m, index) => {
return (row[getMetricId(m, index)] as number) || null;
})
.filter((v) => isNumber(v));
const minValue = getMin(rowValues);
// For stacked domains we want to add 10% head room so the charts have
// enough room to draw the 2 pixel line as well.
const maxValue = stacked ? sum(rowValues) * 1.1 : getMax(rowValues);
return acc.concat([minValue || null, maxValue || null]);
}, [] as Array<number | null>)
.filter((v) => isNumber(v));
return { min: getMin(values) || 0, max: getMax(values) || 0 };
};

View file

@ -0,0 +1,53 @@
/*
* 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 { decimalToPct, pctToDecimal } from './corrected_percent_convert';
describe('decimalToPct', () => {
test('should retain correct floating point precision up to 10 decimal places', () => {
// Most of these cases would still work fine just doing x * 100 instead of passing it through
// decimalToPct, but the function still needs to work regardless
expect(decimalToPct(0)).toBe(0);
expect(decimalToPct(0.1)).toBe(10);
expect(decimalToPct(0.01)).toBe(1);
expect(decimalToPct(0.014)).toBe(1.4);
expect(decimalToPct(0.0141)).toBe(1.41);
expect(decimalToPct(0.01414)).toBe(1.414);
// This case is known to fail without decimalToPct; vanilla JS 0.014141 * 100 === 1.4141000000000001
expect(decimalToPct(0.014141)).toBe(1.4141);
expect(decimalToPct(0.0141414)).toBe(1.41414);
expect(decimalToPct(0.01414141)).toBe(1.414141);
expect(decimalToPct(0.014141414)).toBe(1.4141414);
});
test('should also work with values greater than 1', () => {
expect(decimalToPct(2)).toBe(200);
expect(decimalToPct(2.1)).toBe(210);
expect(decimalToPct(2.14)).toBe(214);
expect(decimalToPct(2.14141414)).toBe(214.141414);
});
});
describe('pctToDecimal', () => {
test('should retain correct floating point precision up to 10 decimal places', () => {
expect(pctToDecimal(0)).toBe(0);
expect(pctToDecimal(10)).toBe(0.1);
expect(pctToDecimal(1)).toBe(0.01);
expect(pctToDecimal(1.4)).toBe(0.014);
expect(pctToDecimal(1.41)).toBe(0.0141);
expect(pctToDecimal(1.414)).toBe(0.01414);
expect(pctToDecimal(1.4141)).toBe(0.014141);
expect(pctToDecimal(1.41414)).toBe(0.0141414);
expect(pctToDecimal(1.414141)).toBe(0.01414141);
expect(pctToDecimal(1.4141414)).toBe(0.014141414);
});
test('should also work with values greater than 100%', () => {
expect(pctToDecimal(200)).toBe(2);
expect(pctToDecimal(210)).toBe(2.1);
expect(pctToDecimal(214)).toBe(2.14);
expect(pctToDecimal(214.141414)).toBe(2.14141414);
});
});

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
const correctedPctConvert = (v: number, decimalToPct: boolean) => {
// Correct floating point precision
const replacementPattern = decimalToPct ? new RegExp(/0?\./) : '.';
const numberOfDigits = String(v).replace(replacementPattern, '').length;
const multipliedValue = decimalToPct ? v * 100 : v / 100;
return parseFloat(multipliedValue.toPrecision(numberOfDigits));
};
export const decimalToPct = (v: number) => correctedPctConvert(v, true);
export const pctToDecimal = (v: number) => correctedPctConvert(v, false);

View file

@ -0,0 +1,26 @@
/*
* 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 numeral from '@elastic/numeral';
import { InfraFormatterType } from '../../../../common/threshold_rule/types';
import { createFormatter } from '../../../../common/threshold_rule/formatters';
import { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer';
import { metricToFormat } from './metric_to_format';
export const createFormatterForMetric = (metric?: MetricsExplorerMetric) => {
if (metric?.aggregation === 'custom') {
return (input: number) => numeral(input).format('0.[0000]');
}
if (metric && metric.field) {
const format = metricToFormat(metric);
if (format === InfraFormatterType.bits && metric.aggregation === 'rate') {
return createFormatter(InfraFormatterType.bits, '{{value}}/s');
}
return createFormatter(format);
}
return createFormatter(InfraFormatterType.number);
};

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 { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer';
import { createFormatterForMetric } from './create_formatter_for_metric';
describe('createFormatterForMetric()', () => {
it('should just work for count', () => {
const metric: MetricsExplorerMetric = { aggregation: 'count' };
const format = createFormatterForMetric(metric);
expect(format(1291929)).toBe('1,291,929');
});
it('should just work for numerics', () => {
const metric: MetricsExplorerMetric = { aggregation: 'avg', field: 'system.load.1' };
const format = createFormatterForMetric(metric);
expect(format(1000.2)).toBe('1,000.2');
});
it('should just work for percents', () => {
const metric: MetricsExplorerMetric = { aggregation: 'avg', field: 'system.cpu.total.pct' };
const format = createFormatterForMetric(metric);
expect(format(0.349)).toBe('34.9%');
});
it('should just work for rates', () => {
const metric: MetricsExplorerMetric = {
aggregation: 'rate',
field: 'host.network.egress.bytes',
};
const format = createFormatterForMetric(metric);
expect(format(103929292)).toBe('831.4 Mbit/s');
});
it('should just work for bytes', () => {
const metric: MetricsExplorerMetric = {
aggregation: 'avg',
field: 'host.network.egress.bytes',
};
const format = createFormatterForMetric(metric);
expect(format(103929292)).toBe('103.9 MB');
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer';
import { createMetricLabel } from './create_metric_label';
describe('createMetricLabel()', () => {
it('should work with metrics with fields', () => {
const metric: MetricsExplorerMetric = { aggregation: 'avg', field: 'system.load.1' };
expect(createMetricLabel(metric)).toBe('avg(system.load.1)');
});
it('should work with document count', () => {
const metric: MetricsExplorerMetric = { aggregation: 'count' };
expect(createMetricLabel(metric)).toBe('count()');
});
});

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 { MetricsExplorerOptionsMetric } from '../hooks/use_metrics_explorer_options';
export const createMetricLabel = (metric: MetricsExplorerOptionsMetric) => {
if (metric.label) {
return metric.label;
}
return `${metric.aggregation}(${metric.field || ''})`;
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { MetricsExplorerOptionsMetric } from '../../../../common/threshold_rule/types';
export const getMetricId = (metric: MetricsExplorerOptionsMetric, index: string | number) => {
return `metric_${index}`;
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { DataViewBase } from '@kbn/es-query';
export const convertKueryToElasticSearchQuery = (
kueryExpression: string,
indexPattern: DataViewBase,
swallowErrors: boolean = true
) => {
try {
return kueryExpression
? JSON.stringify(toElasticsearchQuery(fromKueryExpression(kueryExpression), indexPattern))
: '';
} catch (err) {
if (swallowErrors) {
return '';
} else throw err;
}
};

View file

@ -0,0 +1,26 @@
/*
* 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 { last } from 'lodash';
import { InfraFormatterType } from '../../../../common/threshold_rule/types';
import { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer';
export const metricToFormat = (metric?: MetricsExplorerMetric) => {
if (metric && metric.field) {
const suffix = last(metric.field.split(/\./));
if (suffix === 'pct') {
return InfraFormatterType.percent;
}
if (suffix === 'bytes' && metric.aggregation === 'rate') {
return InfraFormatterType.bits;
}
if (suffix === 'bytes') {
return InfraFormatterType.bytes;
}
}
return InfraFormatterType.number;
};

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
export const useSourceNotifier = () => {
const { notifications } = useKibana();
const updateFailure = (message?: string) => {
notifications.toasts.danger({
toastLifeTimeMs: 3000,
title: i18n.translate(
'xpack.observability.threshold.rule.sourceConfiguration.updateFailureTitle',
{
defaultMessage: 'Configuration update failed',
}
),
body: [
i18n.translate('xpack.observability.threshold.rule.sourceConfiguration.updateFailureBody', {
defaultMessage:
"We couldn't apply the changes to the Metrics configuration. Try again later.",
}),
message,
]
.filter(Boolean)
.join(' '),
});
};
const updateSuccess = () => {
notifications.toasts.success({
toastLifeTimeMs: 3000,
title: i18n.translate(
'xpack.observability.threshold.rule.sourceConfiguration.updateSuccessTitle',
{
defaultMessage: 'Metrics settings successfully updated',
}
),
});
};
return {
updateFailure,
updateSuccess,
};
};

View file

@ -0,0 +1,69 @@
/*
* 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 { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { Context, Errors, IntersectionType, Type, UnionType, ValidationError } from 'io-ts';
import type { RouteValidationFunction } from '@kbn/core/server';
type ErrorFactory = (message: string) => Error;
const getErrorPath = ([first, ...rest]: Context): string[] => {
if (typeof first === 'undefined') {
return [];
} else if (first.type instanceof IntersectionType) {
const [, ...next] = rest;
return getErrorPath(next);
} else if (first.type instanceof UnionType) {
const [, ...next] = rest;
return [first.key, ...getErrorPath(next)];
}
return [first.key, ...getErrorPath(rest)];
};
const getErrorType = ({ context }: ValidationError) =>
context[context.length - 1]?.type?.name ?? 'unknown';
const formatError = (error: ValidationError) =>
error.message ??
`in ${getErrorPath(error.context).join('/')}: ${JSON.stringify(
error.value
)} does not match expected type ${getErrorType(error)}`;
export const formatErrors = (errors: ValidationError[]) =>
`Failed to validate: \n${errors.map((error) => ` ${formatError(error)}`).join('\n')}`;
export const createPlainError = (message: string) => new Error(message);
export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => {
throw createError(formatErrors(errors));
};
export const decodeOrThrow =
<DecodedValue, EncodedValue, InputValue>(
runtimeType: Type<DecodedValue, EncodedValue, InputValue>,
createError: ErrorFactory = createPlainError
) =>
(inputValue: InputValue) =>
pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity));
type ValdidationResult<Value> = ReturnType<RouteValidationFunction<Value>>;
export const createValidationFunction =
<DecodedValue, EncodedValue, InputValue>(
runtimeType: Type<DecodedValue, EncodedValue, InputValue>
): RouteValidationFunction<DecodedValue> =>
(inputValue, { badRequest, ok }) =>
pipe(
runtimeType.decode(inputValue),
fold<Errors, DecodedValue, ValdidationResult<DecodedValue>>(
(errors: Errors) => badRequest(formatErrors(errors)),
(result: DecodedValue) => ok(result)
)
);

View file

@ -0,0 +1,142 @@
/*
* 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 createContainer from 'constate';
import React, { useEffect, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { IHttpFetchError } from '@kbn/core-http-browser';
import {
MetricsSourceConfiguration,
MetricsSourceConfigurationResponse,
PartialMetricsSourceConfigurationProperties,
} from '../../../../common/threshold_rule/types';
import { MissingHttpClientException } from './source_errors';
import { useTrackedPromise } from '../hooks/use_tracked_promise';
import { useSourceNotifier } from './notifications';
export const pickIndexPattern = (
source: MetricsSourceConfiguration | undefined,
type: 'metrics'
) => {
if (!source) {
return 'unknown-index';
}
if (type === 'metrics') {
return source.configuration.metricAlias;
}
return `${source.configuration.metricAlias}`;
};
export const useSource = ({ sourceId }: { sourceId: string }) => {
const { services } = useKibana();
const notify = useSourceNotifier();
const fetchService = services.http;
const API_URL = `/api/metrics/source/${sourceId}`;
const [source, setSource] = useState<MetricsSourceConfiguration | undefined>(undefined);
const [loadSourceRequest, loadSource] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: () => {
if (!fetchService) {
throw new MissingHttpClientException();
}
return fetchService.fetch<MetricsSourceConfigurationResponse>(API_URL, { method: 'GET' });
},
onResolve: (response) => {
if (response) {
setSource(response.source);
}
},
},
[fetchService, sourceId]
);
const [createSourceConfigurationRequest, createSourceConfiguration] = useTrackedPromise(
{
createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => {
if (!fetchService) {
throw new MissingHttpClientException();
}
return await fetchService.patch<MetricsSourceConfigurationResponse>(API_URL, {
method: 'PATCH',
body: JSON.stringify(sourceProperties),
});
},
onResolve: (response) => {
if (response) {
notify.updateSuccess();
setSource(response.source);
}
},
onReject: (error) => {
notify.updateFailure((error as IHttpFetchError<{ message: string }>).body?.message);
},
},
[fetchService, sourceId]
);
useEffect(() => {
loadSource();
}, [loadSource, sourceId]);
const createDerivedIndexPattern = () => {
return {
fields: source?.status ? source.status.indexFields : [],
title: pickIndexPattern(source, 'metrics'),
};
};
const hasFailedLoadingSource = loadSourceRequest.state === 'rejected';
const isUninitialized = loadSourceRequest.state === 'uninitialized';
const isLoadingSource = loadSourceRequest.state === 'pending';
const isLoading = isLoadingSource || createSourceConfigurationRequest.state === 'pending';
const sourceExists = source ? !!source.version : undefined;
const metricIndicesExist = Boolean(source?.status?.metricIndicesExist);
const version = source?.version;
return {
createSourceConfiguration,
createDerivedIndexPattern,
isLoading,
isLoadingSource,
isUninitialized,
hasFailedLoadingSource,
loadSource,
loadSourceRequest,
loadSourceFailureMessage: hasFailedLoadingSource ? `${loadSourceRequest.value}` : undefined,
metricIndicesExist,
source,
sourceExists,
sourceId,
updateSourceConfiguration: createSourceConfiguration,
version,
};
};
export const [SourceProvider, useSourceContext] = createContainer(useSource);
export const withSourceProvider =
<ComponentProps,>(Component: React.FunctionComponent<ComponentProps>) =>
(sourceId = 'default') => {
// eslint-disable-next-line react/function-component-definition
return function ComponentWithSourceProvider(props: ComponentProps) {
return (
<SourceProvider sourceId={sourceId}>
<Component {...props} />
</SourceProvider>
);
};
};

View file

@ -0,0 +1,26 @@
/*
* 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';
const missingHttpMessage = i18n.translate(
'xpack.observability.threshold.rule.sourceConfiguration.missingHttp',
{
defaultMessage: 'Failed to load source: No HTTP client available.',
}
);
/**
* Errors
*/
export class MissingHttpClientException extends Error {
constructor() {
super(missingHttpMessage);
Object.setPrototypeOf(this, new.target.prototype);
this.name = 'MissingHttpClientException';
}
}

View file

@ -0,0 +1,16 @@
/*
* 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 createContainer from 'constate';
import { useMetricThresholdAlertPrefill } from '../hooks/use_metric_threshold_alert_prefill';
const useAlertPrefill = () => {
const metricThresholdPrefill = useMetricThresholdAlertPrefill();
return { metricThresholdPrefill };
};
export const [AlertPrefillProvider, useAlertPrefillContext] = createContainer(useAlertPrefill);

View file

@ -0,0 +1,20 @@
/*
* 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-timezone';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
export function useKibanaTimeZoneSetting() {
const [kibanaTimeZone] = useUiSetting$<string>(UI_SETTINGS.DATEFORMAT_TZ);
if (!kibanaTimeZone || kibanaTimeZone === 'Browser') {
return moment.tz.guess();
}
return kibanaTimeZone;
}

View file

@ -0,0 +1,77 @@
/*
* 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 } from 'react';
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import useMount from 'react-use/lib/useMount';
import type { TimeRange } from '@kbn/es-query';
import { TimefilterContract } from '@kbn/data-plugin/public';
import { useKibana } from '../../../utils/kibana_react';
export const useKibanaTimefilterTime = ({
from: fromDefault,
to: toDefault,
}: TimeRange): [typeof getTime, TimefilterContract['setTime']] => {
const { services } = useKibana();
const getTime = useCallback(() => {
const timefilterService = services.data.query.timefilter.timefilter;
return timefilterService.isTimeTouched()
? timefilterService.getTime()
: { from: fromDefault, to: toDefault };
}, [services.data.query.timefilter.timefilter, fromDefault, toDefault]);
return [getTime, services.data.query.timefilter.timefilter.setTime];
};
/**
* Handles one or two way syncing with the Kibana time filter service.
*
* For one way syncing the time range will be synced back to the time filter service
* on mount *if* it differs from the defaults, e.g. a URL param.
* Future updates, after mount, will also be synced back to the time filter service.
*
* For two way syncing, in addition to the above, changes *from* the time filter service
* will be sycned to the solution, e.g. there might be an embeddable on the page that
* fires an action that hooks into the time filter service.
*/
export const useSyncKibanaTimeFilterTime = (
defaults: TimeRange,
currentTimeRange: TimeRange,
setTimeRange?: (timeRange: TimeRange) => void
) => {
const { services } = useKibana();
const [getTime, setTime] = useKibanaTimefilterTime(defaults);
// On first mount we only want to sync time with Kibana if the derived currentTimeRange (e.g. from URL params)
// differs from our defaults.
useMount(() => {
if (defaults.from !== currentTimeRange.from || defaults.to !== currentTimeRange.to) {
setTime({ from: currentTimeRange.from, to: currentTimeRange.to });
}
});
// Sync explicit changes *after* mount from the solution back to Kibana
useUpdateEffect(() => {
setTime({ from: currentTimeRange.from, to: currentTimeRange.to });
}, [currentTimeRange.from, currentTimeRange.to, setTime]);
// *Optionally* sync time filter service changes back to the solution.
// For example, an embeddable might have a time range action that hooks into
// the time filter service.
useEffect(() => {
const sub = services.data.query.timefilter.timefilter.getTimeUpdate$().subscribe(() => {
if (setTimeRange) {
const timeRange = getTime();
setTimeRange(timeRange);
}
});
return () => sub.unsubscribe();
}, [getTime, setTimeRange, services.data.query.timefilter.timefilter]);
};

View file

@ -0,0 +1,35 @@
/*
* 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 { isEqual } from 'lodash';
import { useState } from 'react';
import { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer';
export interface MetricThresholdPrefillOptions {
groupBy: string | string[] | undefined;
filterQuery: string | undefined;
metrics: MetricsExplorerMetric[];
}
export const useMetricThresholdAlertPrefill = () => {
const [prefillOptionsState, setPrefillOptionsState] = useState<MetricThresholdPrefillOptions>({
groupBy: undefined,
filterQuery: undefined,
metrics: [],
});
const { groupBy, filterQuery, metrics } = prefillOptionsState;
return {
groupBy,
filterQuery,
metrics,
setPrefillOptions(newState: MetricThresholdPrefillOptions) {
if (!isEqual(newState, prefillOptionsState)) setPrefillOptionsState(newState);
},
};
};

View file

@ -0,0 +1,94 @@
/*
* 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 DateMath from '@kbn/datemath';
import { DataViewBase } from '@kbn/es-query';
import { useMemo } from 'react';
import { MetricExplorerCustomMetricAggregations } from '../../../../common/threshold_rule/metrics_explorer';
import {
MetricExpressionCustomMetric,
MetricsSourceConfiguration,
} from '../../../../common/threshold_rule/types';
import { MetricExpression, TimeRange } from '../types';
import { useMetricsExplorerData } from './use_metrics_explorer_data';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
const DEFAULT_TIME_RANGE = {};
export const useMetricsExplorerChartData = (
expression: MetricExpression,
derivedIndexPattern: DataViewBase,
source?: MetricsSourceConfiguration,
filterQuery?: string,
groupBy?: string | string[],
timeRange: TimeRange = DEFAULT_TIME_RANGE
) => {
const { timeSize, timeUnit } = expression || { timeSize: 1, timeUnit: 'm' };
const options: MetricsExplorerOptions = useMemo(
() => ({
limit: 1,
forceInterval: true,
dropLastBucket: false,
groupBy,
filterQuery,
metrics: [
expression.aggType === 'custom'
? {
aggregation: 'custom',
custom_metrics:
expression?.customMetrics?.map(mapMetricThresholdMetricToMetricsExplorerMetric) ??
[],
equation: expression.equation,
}
: { field: expression.metric, aggregation: expression.aggType },
],
aggregation: expression.aggType || 'avg',
}),
[
expression.aggType,
expression.equation,
expression.metric,
expression.customMetrics,
filterQuery,
groupBy,
]
);
const timestamps: MetricsExplorerTimestampsRT = useMemo(() => {
const from = timeRange.from ?? `now-${(timeSize || 1) * 20}${timeUnit}`;
const to = timeRange.to ?? 'now';
const fromTimestamp = DateMath.parse(from)!.valueOf();
const toTimestamp = DateMath.parse(to, { roundUp: true })!.valueOf();
return {
interval: `>=${timeSize || 1}${timeUnit}`,
fromTimestamp,
toTimestamp,
};
}, [timeRange, timeSize, timeUnit]);
return useMetricsExplorerData(options, source?.configuration, derivedIndexPattern, timestamps);
};
const mapMetricThresholdMetricToMetricsExplorerMetric = (metric: MetricExpressionCustomMetric) => {
if (metric.aggType === 'count') {
return {
name: metric.name,
aggregation: 'count' as MetricExplorerCustomMetricAggregations,
filter: metric.filter,
};
}
return {
name: metric.name,
aggregation: metric.aggType as MetricExplorerCustomMetricAggregations,
field: metric.field,
};
};

View file

@ -0,0 +1,199 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useMetricsExplorerData } from './use_metrics_explorer_data';
import { renderHook } from '@testing-library/react-hooks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
import { DataViewBase } from '@kbn/es-query';
import { MetricsSourceConfigurationProperties } from '../../../../common/threshold_rule/types';
import {
createSeries,
derivedIndexPattern,
options,
resp,
source,
timestamps,
} from '../../../utils/metrics_explorer';
const mockedFetch = jest.fn();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const renderUseMetricsExplorerDataHook = () => {
const wrapper: React.FC = ({ children }) => {
const services = {
http: {
post: mockedFetch,
},
};
return (
<QueryClientProvider client={queryClient}>
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
</QueryClientProvider>
);
};
return renderHook(
(props: {
options: MetricsExplorerOptions;
source: MetricsSourceConfigurationProperties | undefined;
derivedIndexPattern: DataViewBase;
timestamps: MetricsExplorerTimestampsRT;
}) =>
useMetricsExplorerData(
props.options,
props.source,
props.derivedIndexPattern,
props.timestamps
),
{
initialProps: {
options,
source,
derivedIndexPattern,
timestamps,
},
wrapper,
}
);
};
jest.mock('../helpers/kuery', () => {
return {
convertKueryToElasticSearchQuery: (query: string) => query,
};
});
describe('useMetricsExplorerData Hook', () => {
afterEach(() => {
queryClient.clear();
});
it('should just work', async () => {
mockedFetch.mockResolvedValue(resp);
const { result, waitForNextUpdate } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBeUndefined();
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
const { series } = result.current.data!.pages[0];
expect(series).toBeDefined();
expect(series.length).toBe(3);
});
it('should paginate', async () => {
mockedFetch.mockResolvedValue(resp);
const { result, waitForNextUpdate } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBeUndefined();
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
const { series } = result.current.data!.pages[0];
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.mockResolvedValue({
pageInfo: { total: 10, afterKey: 'host-06' },
series: [createSeries('host-04'), createSeries('host-05'), createSeries('host-06')],
} as any);
result.current.fetchNextPage();
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
const { series: nextSeries } = result.current.data!.pages[1];
expect(nextSeries).toBeDefined();
expect(nextSeries.length).toBe(3);
});
it('should reset error upon recovery', async () => {
const error = new Error('Network Error');
mockedFetch.mockRejectedValue(error);
const { result, waitForNextUpdate } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(null);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(error);
expect(result.current.isLoading).toBe(false);
mockedFetch.mockResolvedValue(resp as any);
result.current.refetch();
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should not paginate on option change', async () => {
mockedFetch.mockResolvedValue(resp as any);
const { result, waitForNextUpdate, rerender } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBeUndefined();
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
const { series } = result.current.data!.pages[0];
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.mockResolvedValue(resp as any);
rerender({
options: {
...options,
aggregation: 'count',
metrics: [{ aggregation: 'count' }],
},
source,
derivedIndexPattern,
timestamps,
});
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
});
it('should not paginate on time change', async () => {
mockedFetch.mockResolvedValue(resp as any);
const { result, waitForNextUpdate, rerender } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBeUndefined();
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
const { series } = result.current.data!.pages[0];
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.mockResolvedValue(resp as any);
rerender({
options,
source,
derivedIndexPattern,
timestamps: { fromTimestamp: 1678378092225, toTimestamp: 1678381693477, interval: '>=10s' },
});
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.isLoading).toBe(false);
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 { DataViewBase } from '@kbn/es-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { MetricsSourceConfigurationProperties } from '../../../../common/threshold_rule/types';
import {
MetricsExplorerResponse,
metricsExplorerResponseRT,
} from '../../../../common/threshold_rule/metrics_explorer';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
import { convertKueryToElasticSearchQuery } from '../helpers/kuery';
import { decodeOrThrow } from '../helpers/runtime_types';
export function useMetricsExplorerData(
options: MetricsExplorerOptions,
source: MetricsSourceConfigurationProperties | undefined,
derivedIndexPattern: DataViewBase,
{ fromTimestamp, toTimestamp, interval }: MetricsExplorerTimestampsRT,
enabled = true
) {
const { http } = useKibana().services;
const { isLoading, data, error, refetch, fetchNextPage } = useInfiniteQuery<
MetricsExplorerResponse,
Error
>({
queryKey: ['metricExplorer', options, fromTimestamp, toTimestamp],
queryFn: async ({ signal, pageParam = { afterKey: null } }) => {
if (!fromTimestamp || !toTimestamp) {
throw new Error('Unable to parse timerange');
}
if (!http) {
throw new Error('HTTP service is unavailable');
}
if (!source) {
throw new Error('Source is unavailable');
}
const { afterKey } = pageParam;
const response = await http.post<MetricsExplorerResponse>('/api/infra/metrics_explorer', {
method: 'POST',
body: JSON.stringify({
forceInterval: options.forceInterval,
dropLastBucket: options.dropLastBucket != null ? options.dropLastBucket : true,
metrics: options.aggregation === 'count' ? [{ aggregation: 'count' }] : options.metrics,
groupBy: options.groupBy,
afterKey,
limit: options.limit,
indexPattern: source.metricAlias,
filterQuery:
(options.filterQuery &&
convertKueryToElasticSearchQuery(options.filterQuery, derivedIndexPattern)) ||
void 0,
timerange: {
interval,
from: fromTimestamp,
to: toTimestamp,
},
}),
signal,
});
return decodeOrThrow(metricsExplorerResponseRT)(response);
},
getNextPageParam: (lastPage) => lastPage.pageInfo,
enabled: enabled && !!fromTimestamp && !!toTimestamp && !!http && !!source,
refetchOnWindowFocus: false,
});
return {
data,
error,
fetchNextPage,
isLoading,
refetch,
};
}

View file

@ -0,0 +1,127 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import {
useMetricsExplorerOptions,
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
DEFAULT_OPTIONS,
DEFAULT_TIMERANGE,
} from './use_metrics_explorer_options';
let PREFILL: Record<string, any> = {};
jest.mock('../helpers/use_alert_prefill', () => ({
useAlertPrefillContext: () => ({
metricThresholdPrefill: {
setPrefillOptions(opts: Record<string, any>) {
PREFILL = opts;
},
},
}),
}));
jest.mock('./use_kibana_timefilter_time', () => ({
useKibanaTimefilterTime: (defaults: { from: string; to: string }) => [() => defaults],
useSyncKibanaTimeFilterTime: () => [() => {}],
}));
const renderUseMetricsExplorerOptionsHook = () => renderHook(() => useMetricsExplorerOptions());
interface LocalStore {
[key: string]: string;
}
interface LocalStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}
const STORE: LocalStore = {};
const localStorageMock: LocalStorage = {
getItem: (key: string) => {
return STORE[key] || null;
},
setItem: (key: string, value: any) => {
STORE[key] = value.toString();
},
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('useMetricExplorerOptions', () => {
beforeEach(() => {
delete STORE.MetricsExplorerOptions;
delete STORE.MetricsExplorerTimeRange;
PREFILL = {};
});
it('should just work', () => {
const { result } = renderUseMetricsExplorerOptionsHook();
expect(result.current.options).toEqual(DEFAULT_OPTIONS);
expect(result.current.timeRange).toEqual(DEFAULT_TIMERANGE);
expect(result.current.isAutoReloading).toEqual(false);
expect(STORE.MetricsExplorerOptions).toEqual(JSON.stringify(DEFAULT_OPTIONS));
});
it('should change the store when options update', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'count' }],
};
act(() => {
result.current.setOptions(newOptions);
});
rerender();
expect(result.current.options).toEqual(newOptions);
expect(STORE.MetricsExplorerOptions).toEqual(JSON.stringify(newOptions));
});
it('should change the store when timerange update', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newTimeRange: MetricsExplorerTimeOptions = {
...DEFAULT_TIMERANGE,
from: 'now-15m',
};
act(() => {
result.current.setTimeRange(newTimeRange);
});
rerender();
expect(result.current.timeRange).toEqual(newTimeRange);
});
it('should load from store when available', () => {
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'avg', field: 'system.load.1' }],
};
STORE.MetricsExplorerOptions = JSON.stringify(newOptions);
const { result } = renderUseMetricsExplorerOptionsHook();
expect(result.current.options).toEqual(newOptions);
});
it('should sync the options to the threshold alert preview context', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'count' }],
filterQuery: 'foo',
groupBy: 'host.hostname',
};
act(() => {
result.current.setOptions(newOptions);
});
rerender();
expect(PREFILL.metrics).toEqual(newOptions.metrics);
expect(PREFILL.groupBy).toEqual(newOptions.groupBy);
expect(PREFILL.filterQuery).toEqual(newOptions.filterQuery);
});
});

View file

@ -0,0 +1,234 @@
/*
* 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 DateMath from '@kbn/datemath';
import * as t from 'io-ts';
import { values } from 'lodash';
import createContainer from 'constate';
import type { TimeRange } from '@kbn/es-query';
import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react';
import { metricsExplorerMetricRT } from '../../../../common/threshold_rule/metrics_explorer';
import { Color } from '../../../../common/threshold_rule/color_palette';
import { useAlertPrefillContext } from '../helpers/use_alert_prefill';
import { useKibanaTimefilterTime, useSyncKibanaTimeFilterTime } from './use_kibana_timefilter_time';
const metricsExplorerOptionsMetricRT = t.intersection([
metricsExplorerMetricRT,
t.partial({
rate: t.boolean,
color: t.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record<Color, null>),
label: t.string,
}),
]);
export type MetricsExplorerOptionsMetric = t.TypeOf<typeof metricsExplorerOptionsMetricRT>;
export enum MetricsExplorerChartType {
line = 'line',
area = 'area',
bar = 'bar',
}
export enum MetricsExplorerYAxisMode {
fromZero = 'fromZero',
auto = 'auto',
}
export const metricsExplorerChartOptionsRT = t.type({
yAxisMode: t.keyof(
Object.fromEntries(values(MetricsExplorerYAxisMode).map((v) => [v, null])) as Record<
MetricsExplorerYAxisMode,
null
>
),
type: t.keyof(
Object.fromEntries(values(MetricsExplorerChartType).map((v) => [v, null])) as Record<
MetricsExplorerChartType,
null
>
),
stack: t.boolean,
});
export type MetricsExplorerChartOptions = t.TypeOf<typeof metricsExplorerChartOptionsRT>;
const metricExplorerOptionsRequiredRT = t.type({
aggregation: t.string,
metrics: t.array(metricsExplorerOptionsMetricRT),
});
const metricExplorerOptionsOptionalRT = t.partial({
limit: t.number,
groupBy: t.union([t.string, t.array(t.string)]),
filterQuery: t.string,
source: t.string,
forceInterval: t.boolean,
dropLastBucket: t.boolean,
});
export const metricExplorerOptionsRT = t.intersection([
metricExplorerOptionsRequiredRT,
metricExplorerOptionsOptionalRT,
]);
export type MetricsExplorerOptions = t.TypeOf<typeof metricExplorerOptionsRT>;
export const metricsExplorerTimestampsRT = t.type({
fromTimestamp: t.number,
toTimestamp: t.number,
interval: t.string,
});
export type MetricsExplorerTimestampsRT = t.TypeOf<typeof metricsExplorerTimestampsRT>;
export const metricsExplorerTimeOptionsRT = t.type({
from: t.string,
to: t.string,
interval: t.string,
});
export type MetricsExplorerTimeOptions = t.TypeOf<typeof metricsExplorerTimeOptionsRT>;
export const DEFAULT_TIMERANGE: MetricsExplorerTimeOptions = {
from: 'now-1h',
to: 'now',
interval: '>=10s',
};
export const DEFAULT_CHART_OPTIONS: MetricsExplorerChartOptions = {
type: MetricsExplorerChartType.line,
yAxisMode: MetricsExplorerYAxisMode.fromZero,
stack: false,
};
export const DEFAULT_METRICS: MetricsExplorerOptionsMetric[] = [
{
aggregation: 'avg',
field: 'system.cpu.total.norm.pct',
color: Color.color0,
},
{
aggregation: 'avg',
field: 'kubernetes.pod.cpu.usage.node.pct',
color: Color.color1,
},
{
aggregation: 'avg',
field: 'docker.cpu.total.pct',
color: Color.color2,
},
];
export const DEFAULT_OPTIONS: MetricsExplorerOptions = {
aggregation: 'avg',
metrics: DEFAULT_METRICS,
source: 'default',
};
export const DEFAULT_METRICS_EXPLORER_VIEW_STATE = {
options: DEFAULT_OPTIONS,
chartOptions: DEFAULT_CHART_OPTIONS,
currentTimerange: DEFAULT_TIMERANGE,
};
function parseJsonOrDefault<Obj>(value: string | null, defaultValue: Obj): Obj {
if (!value) {
return defaultValue;
}
try {
return JSON.parse(value) as Obj;
} catch (e) {
return defaultValue;
}
}
function useStateWithLocalStorage<State>(
key: string,
defaultState: State
): [State, Dispatch<SetStateAction<State>>] {
const storageState = localStorage.getItem(key);
const [state, setState] = useState<State>(parseJsonOrDefault<State>(storageState, defaultState));
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
const getDefaultTimeRange = ({ from, to }: TimeRange) => {
const fromTimestamp = DateMath.parse(from)!.valueOf();
const toTimestamp = DateMath.parse(to, { roundUp: true })!.valueOf();
return {
fromTimestamp,
toTimestamp,
interval: DEFAULT_TIMERANGE.interval,
};
};
export const useMetricsExplorerOptions = () => {
const TIME_DEFAULTS = { from: 'now-1h', to: 'now' };
const [getTime] = useKibanaTimefilterTime(TIME_DEFAULTS);
const { from, to } = getTime();
const [options, setOptions] = useStateWithLocalStorage<MetricsExplorerOptions>(
'MetricsExplorerOptions',
DEFAULT_OPTIONS
);
const [timeRange, setTimeRange] = useState<MetricsExplorerTimeOptions>({
from,
to,
interval: DEFAULT_TIMERANGE.interval,
});
const [timestamps, setTimestamps] = useState<MetricsExplorerTimestampsRT>(
getDefaultTimeRange({ from, to })
);
useSyncKibanaTimeFilterTime(TIME_DEFAULTS, {
from: timeRange.from,
to: timeRange.to,
});
const [chartOptions, setChartOptions] = useStateWithLocalStorage<MetricsExplorerChartOptions>(
'MetricsExplorerChartOptions',
DEFAULT_CHART_OPTIONS
);
const [isAutoReloading, setAutoReloading] = useState<boolean>(false);
const { metricThresholdPrefill } = useAlertPrefillContext();
// For Jest compatibility; including metricThresholdPrefill as a dep in useEffect causes an
// infinite loop in test environment
const prefillContext = useMemo(() => metricThresholdPrefill, [metricThresholdPrefill]);
useEffect(() => {
if (prefillContext) {
const { setPrefillOptions } = prefillContext;
const { metrics, groupBy, filterQuery } = options;
setPrefillOptions({ metrics, groupBy, filterQuery });
}
}, [options, prefillContext]);
return {
defaultViewState: {
options: DEFAULT_OPTIONS,
chartOptions: DEFAULT_CHART_OPTIONS,
currentTimerange: timeRange,
},
options,
chartOptions,
setChartOptions,
timeRange,
isAutoReloading,
setOptions,
setTimeRange,
startAutoReload: () => setAutoReloading(true),
stopAutoReload: () => setAutoReloading(false),
timestamps,
setTimestamps,
};
};
export const [MetricsExplorerOptionsContainer, useMetricsExplorerOptionsContainerContext] =
createContainer(useMetricsExplorerOptions);

View file

@ -0,0 +1,299 @@
/*
* 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.
*/
/* eslint-disable max-classes-per-file */
import { DependencyList, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
interface UseTrackedPromiseArgs<Arguments extends any[], Result> {
createPromise: (...args: Arguments) => Promise<Result>;
onResolve?: (result: Result) => void;
onReject?: (value: unknown) => void;
cancelPreviousOn?: 'creation' | 'settlement' | 'resolution' | 'rejection' | 'never';
triggerOrThrow?: 'always' | 'whenMounted';
}
/**
* This hook manages a Promise factory and can create new Promises from it. The
* state of these Promises is tracked and they can be canceled when superseded
* to avoid race conditions.
*
* ```
* const [requestState, performRequest] = useTrackedPromise(
* {
* cancelPreviousOn: 'resolution',
* createPromise: async (url: string) => {
* return await fetchSomething(url)
* },
* onResolve: response => {
* setSomeState(response.data);
* },
* onReject: response => {
* setSomeError(response);
* },
* },
* [fetchSomething]
* );
* ```
*
* The `onResolve` and `onReject` handlers are registered separately, because
* the hook will inject a rejection when in case of a canellation. The
* `cancelPreviousOn` attribute can be used to indicate when the preceding
* pending promises should be canceled:
*
* 'never': No preceding promises will be canceled.
*
* 'creation': Any preceding promises will be canceled as soon as a new one is
* created.
*
* 'settlement': Any preceding promise will be canceled when a newer promise is
* resolved or rejected.
*
* 'resolution': Any preceding promise will be canceled when a newer promise is
* resolved.
*
* 'rejection': Any preceding promise will be canceled when a newer promise is
* rejected.
*
* Any pending promises will be canceled when the component using the hook is
* unmounted, but their status will not be tracked to avoid React warnings
* about memory leaks.
*
* The last argument is a normal React hook dependency list that indicates
* under which conditions a new reference to the configuration object should be
* used.
*
* The `onResolve`, `onReject` and possible uncatched errors are only triggered
* if the underlying component is mounted. To ensure they always trigger (i.e.
* if the promise is called in a `useLayoutEffect`) use the `triggerOrThrow`
* attribute:
*
* 'whenMounted': (default) they are called only if the component is mounted.
*
* 'always': they always call. The consumer is then responsible of ensuring no
* side effects happen if the underlying component is not mounted.
*/
export const useTrackedPromise = <Arguments extends any[], Result>(
{
createPromise,
onResolve = noOp,
onReject = noOp,
cancelPreviousOn = 'never',
triggerOrThrow = 'whenMounted',
}: UseTrackedPromiseArgs<Arguments, Result>,
dependencies: DependencyList
) => {
const isComponentMounted = useMountedState();
const shouldTriggerOrThrow = useCallback(() => {
switch (triggerOrThrow) {
case 'always':
return true;
case 'whenMounted':
return isComponentMounted();
}
}, [isComponentMounted, triggerOrThrow]);
/**
* If a promise is currently pending, this holds a reference to it and its
* cancellation function.
*/
const pendingPromises = useRef<ReadonlyArray<CancelablePromise<Result>>>([]);
/**
* The state of the promise most recently created by the `createPromise`
* factory. It could be uninitialized, pending, resolved or rejected.
*/
const [promiseState, setPromiseState] = useState<PromiseState<Result>>({
state: 'uninitialized',
});
const reset = useCallback(() => {
setPromiseState({
state: 'uninitialized',
});
}, []);
const execute = useMemo(
() =>
(...args: Arguments) => {
let rejectCancellationPromise!: (value: any) => void;
const cancellationPromise = new Promise<any>((_, reject) => {
rejectCancellationPromise = reject;
});
// remember the list of prior pending promises for cancellation
const previousPendingPromises = pendingPromises.current;
const cancelPreviousPendingPromises = () => {
previousPendingPromises.forEach((promise) => promise.cancel());
};
const newPromise = createPromise(...args);
const newCancelablePromise = Promise.race([newPromise, cancellationPromise]);
// track this new state
setPromiseState({
state: 'pending',
promise: newCancelablePromise,
});
if (cancelPreviousOn === 'creation') {
cancelPreviousPendingPromises();
}
const newPendingPromise: CancelablePromise<Result> = {
cancel: () => {
rejectCancellationPromise(new CanceledPromiseError());
},
cancelSilently: () => {
rejectCancellationPromise(new SilentCanceledPromiseError());
},
promise: newCancelablePromise.then(
(value) => {
if (['settlement', 'resolution'].includes(cancelPreviousOn)) {
cancelPreviousPendingPromises();
}
// remove itself from the list of pending promises
pendingPromises.current = pendingPromises.current.filter(
(pendingPromise) => pendingPromise.promise !== newPendingPromise.promise
);
if (onResolve && shouldTriggerOrThrow()) {
onResolve(value);
}
setPromiseState((previousPromiseState) =>
previousPromiseState.state === 'pending' &&
previousPromiseState.promise === newCancelablePromise
? {
state: 'resolved',
promise: newPendingPromise.promise,
value,
}
: previousPromiseState
);
return value;
},
(value) => {
if (!(value instanceof SilentCanceledPromiseError)) {
if (['settlement', 'rejection'].includes(cancelPreviousOn)) {
cancelPreviousPendingPromises();
}
// remove itself from the list of pending promises
pendingPromises.current = pendingPromises.current.filter(
(pendingPromise) => pendingPromise.promise !== newPendingPromise.promise
);
if (shouldTriggerOrThrow()) {
if (onReject) {
onReject(value);
} else {
throw value;
}
}
setPromiseState((previousPromiseState) =>
previousPromiseState.state === 'pending' &&
previousPromiseState.promise === newCancelablePromise
? {
state: 'rejected',
promise: newCancelablePromise,
value,
}
: previousPromiseState
);
}
}
),
};
// add the new promise to the list of pending promises
pendingPromises.current = [...pendingPromises.current, newPendingPromise];
// silence "unhandled rejection" warnings
newPendingPromise.promise.catch(noOp);
return newPendingPromise.promise;
},
// the dependencies are managed by the caller
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencies
);
/**
* Cancel any pending promises silently to avoid memory leaks and race
* conditions.
*/
useEffect(
() => () => {
pendingPromises.current.forEach((promise) => promise.cancelSilently());
},
[]
);
return [promiseState, execute, reset] as [typeof promiseState, typeof execute, typeof reset];
};
export interface UninitializedPromiseState {
state: 'uninitialized';
}
export interface PendingPromiseState<ResolvedValue> {
state: 'pending';
promise: Promise<ResolvedValue>;
}
export interface ResolvedPromiseState<ResolvedValue> {
state: 'resolved';
promise: Promise<ResolvedValue>;
value: ResolvedValue;
}
export interface RejectedPromiseState<ResolvedValue, RejectedValue> {
state: 'rejected';
promise: Promise<ResolvedValue>;
value: RejectedValue;
}
export type SettledPromiseState<ResolvedValue, RejectedValue> =
| ResolvedPromiseState<ResolvedValue>
| RejectedPromiseState<ResolvedValue, RejectedValue>;
export type PromiseState<ResolvedValue, RejectedValue = unknown> =
| UninitializedPromiseState
| PendingPromiseState<ResolvedValue>
| SettledPromiseState<ResolvedValue, RejectedValue>;
export const isRejectedPromiseState = (
promiseState: PromiseState<any, any>
): promiseState is RejectedPromiseState<any, any> => promiseState.state === 'rejected';
interface CancelablePromise<ResolvedValue> {
// reject the promise prematurely with a CanceledPromiseError
cancel: () => void;
// reject the promise prematurely with a SilentCanceledPromiseError
cancelSilently: () => void;
// the tracked promise
promise: Promise<ResolvedValue>;
}
export class CanceledPromiseError extends Error {
public isCanceled = true;
constructor(message?: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class SilentCanceledPromiseError extends CanceledPromiseError {}
const noOp = () => undefined;

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const EQUATION_HELP_MESSAGE = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.equationHelpMessage',
{ defaultMessage: 'Supports basic math expressions' }
);
export const LABEL_LABEL = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.labelLabel',
{ defaultMessage: 'Label (optional)' }
);
export const LABEL_HELP_MESSAGE = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.labelHelpMessage',
{
defaultMessage: 'Custom label will show on the alert chart and in reason/alert title',
}
);
export const CUSTOM_EQUATION = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquation',
{
defaultMessage: 'Custom equation',
}
);
export const DELETE_LABEL = i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.deleteRowButton',
{ defaultMessage: 'Delete' }
);
export const AGGREGATION_LABEL = (name: string) =>
i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.customEquationEditor.aggregationLabel',
{
defaultMessage: 'Aggregation {name}',
values: { name },
}
);

View file

@ -0,0 +1,51 @@
/*
* 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 { Aggregators, Comparator } from '../../../../common/threshold_rule/types';
import { MetricExpression } from '../types';
import { generateUniqueKey } from './generate_unique_key';
describe('generateUniqueKey', () => {
const mockedCriteria: Array<[MetricExpression, string]> = [
[
{
aggType: Aggregators.COUNT,
comparator: Comparator.LT,
threshold: [2000, 5000],
timeSize: 15,
timeUnit: 'm',
},
'count<2000,5000',
],
[
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT_OR_EQ,
threshold: [30],
timeSize: 15,
timeUnit: 'm',
},
'custom>=30',
],
[
{
aggType: Aggregators.AVERAGE,
comparator: Comparator.LT_OR_EQ,
threshold: [500],
timeSize: 15,
timeUnit: 'm',
metric: 'metric',
},
'avg(metric)<=500',
],
];
it.each(mockedCriteria)('unique key of %p is %s', (input, output) => {
const uniqueKey = generateUniqueKey(input);
expect(uniqueKey).toBe(output);
});
});

View file

@ -0,0 +1,14 @@
/*
* 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 { MetricExpression } from '../types';
export const generateUniqueKey = (criterion: MetricExpression) => {
const metric = criterion.metric ? `(${criterion.metric})` : '';
return criterion.aggType + metric + criterion.comparator + criterion.threshold.join(',');
};

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 { first } from 'lodash';
import { MetricsExplorerResponse } from '../../../../common/threshold_rule/metrics_explorer';
import { MetricThresholdAlertParams, ExpressionChartSeries } from '../types';
export const transformMetricsExplorerData = (
params: MetricThresholdAlertParams,
data: MetricsExplorerResponse | null
) => {
const { criteria } = params;
const firstSeries = first(data?.series);
if (criteria && firstSeries) {
const series = firstSeries.rows.reduce((acc, row) => {
const { timestamp } = row;
criteria.forEach((item, index) => {
if (!acc[index]) {
acc[index] = [];
}
const value = (row[`metric_${index}`] as number) || 0;
acc[index].push({ timestamp, value });
});
return acc;
}, [] as ExpressionChartSeries);
return { id: firstSeries.id, series };
}
};

View file

@ -0,0 +1,190 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { Aggregators, Comparator } from '../../../../common/threshold_rule/types';
import { MetricThresholdAlert, MetricThresholdRule } from '../components/alert_details_app_section';
export const buildMetricThresholdRule = (
rule: Partial<MetricThresholdRule> = {}
): MetricThresholdRule => {
return {
alertTypeId: 'metrics.alert.threshold',
createdBy: 'admin',
updatedBy: 'admin',
createdAt: new Date('2023-02-20T15:25:32.125Z'),
updatedAt: new Date('2023-03-02T16:24:41.177Z'),
apiKey: 'apiKey',
apiKeyOwner: 'admin',
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
snoozeSchedule: [],
executionStatus: {
lastExecutionDate: new Date('2023-03-10T12:58:07.823Z'),
lastDuration: 3882,
status: 'ok',
},
actions: [],
scheduledTaskId: 'cfd9c4f0-b132-11ed-88f2-77e0607bce49',
isSnoozedUntil: null,
lastRun: {
outcomeMsg: null,
outcomeOrder: 0,
alertsCount: {
new: 0,
ignored: 0,
recovered: 0,
active: 0,
},
warning: null,
outcome: 'succeeded',
},
nextRun: new Date('2023-03-10T12:59:07.592Z'),
id: uuidv4(),
consumer: 'alerts',
tags: [],
name: 'Monitoring hosts',
enabled: true,
throttle: null,
running: false,
schedule: {
interval: '1m',
},
params: {
criteria: [
{
aggType: Aggregators.COUNT,
comparator: Comparator.GT,
threshold: [2000],
timeSize: 15,
timeUnit: 'm',
},
{
aggType: Aggregators.MAX,
comparator: Comparator.GT,
threshold: [4],
timeSize: 15,
timeUnit: 'm',
metric: 'system.cpu.user.pct',
warningComparator: Comparator.GT,
warningThreshold: [2.2],
},
{
aggType: Aggregators.MIN,
comparator: Comparator.GT,
threshold: [0.8],
timeSize: 15,
timeUnit: 'm',
metric: 'system.memory.used.pct',
},
],
filterQuery:
'{"bool":{"filter":[{"bool":{"should":[{"term":{"host.hostname":{"value":"Users-System.local"}}}],"minimum_should_match":1}},{"bool":{"should":[{"term":{"service.type":{"value":"system"}}}],"minimum_should_match":1}}]}}',
groupBy: ['host.hostname'],
},
monitoring: {
run: {
history: [
{
duration: 4433,
success: true,
timestamp: 1678375661786,
},
],
calculated_metrics: {
success_ratio: 1,
p99: 7745,
p50: 4909.5,
p95: 6319,
},
last_run: {
timestamp: '2023-03-10T12:58:07.823Z',
metrics: {
total_search_duration_ms: null,
total_indexing_duration_ms: null,
total_alerts_detected: null,
total_alerts_created: null,
gap_duration_s: null,
duration: 3882,
},
},
},
},
revision: 1,
...rule,
};
};
export const buildMetricThresholdAlert = (
alert: Partial<MetricThresholdAlert> = {}
): MetricThresholdAlert => {
return {
link: '/app/metrics/explorer',
reason: 'system.cpu.user.pct reported no data in the last 1m for ',
fields: {
'kibana.alert.rule.parameters': {
criteria: [
{
aggType: Aggregators.AVERAGE,
comparator: Comparator.GT,
threshold: [2000],
timeSize: 15,
timeUnit: 'm',
metric: 'system.cpu.user.pct',
},
{
aggType: Aggregators.MAX,
comparator: Comparator.GT,
threshold: [4],
timeSize: 15,
timeUnit: 'm',
metric: 'system.cpu.user.pct',
warningComparator: Comparator.GT,
warningThreshold: [2.2],
},
],
sourceId: 'default',
alertOnNoData: true,
alertOnGroupDisappear: true,
},
'kibana.alert.evaluation.values': [2500, 5],
'kibana.alert.rule.category': 'Metric threshold',
'kibana.alert.rule.consumer': 'alerts',
'kibana.alert.rule.execution.uuid': '62dd07ef-ead9-4b1f-a415-7c83d03925f7',
'kibana.alert.rule.name': 'One condition',
'kibana.alert.rule.producer': 'infrastructure',
'kibana.alert.rule.rule_type_id': 'metrics.alert.threshold',
'kibana.alert.rule.uuid': '3a1ed8c0-c1a8-11ed-9249-ed6d75986bdc',
'kibana.space_ids': ['default'],
'kibana.alert.rule.tags': [],
'@timestamp': '2023-03-28T14:40:00.000Z',
'kibana.alert.reason': 'system.cpu.user.pct reported no data in the last 1m for ',
'kibana.alert.action_group': 'metrics.threshold.nodata',
tags: [],
'kibana.alert.duration.us': 248391946000,
'kibana.alert.time_range': {
gte: '2023-03-13T14:06:23.695Z',
},
'kibana.alert.instance.id': '*',
'kibana.alert.start': '2023-03-28T13:40:00.000Z',
'kibana.alert.uuid': '50faddcd-c0a0-4122-a068-c204f4a7ec87',
'kibana.alert.status': 'active',
'kibana.alert.workflow_status': 'open',
'event.kind': 'signal',
'event.action': 'active',
'kibana.version': '8.8.0',
'kibana.alert.flapping': false,
'kibana.alert.rule.revision': 1,
},
active: true,
start: 1678716383695,
lastUpdated: 1678964775641,
...alert,
};
};

View file

@ -0,0 +1,21 @@
/*
* 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 { ALERT_REASON } from '@kbn/rule-data-utils';
import { ObservabilityRuleTypeFormatter } from '../..';
// TODO: change
export const LINK_TO_METRICS_EXPLORER = '/app/metrics/explorer';
export const formatReason: ObservabilityRuleTypeFormatter = ({ fields }) => {
const reason = fields[ALERT_REASON] ?? '-';
const link = LINK_TO_METRICS_EXPLORER; // TODO https://github.com/elastic/kibana/issues/106497 & https://github.com/elastic/kibana/issues/106958
return {
reason,
link,
};
};

View file

@ -0,0 +1,177 @@
/*
* 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 * as rt from 'io-ts';
import { CasesUiStart } from '@kbn/cases-plugin/public';
import { ChartsPluginStart } from '@kbn/charts-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DiscoverStart } from '@kbn/discover-plugin/public';
import { EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { LensPublicStart } from '@kbn/lens-plugin/public';
import { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public';
import { SharePluginStart } from '@kbn/share-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import {
RuleTypeParams,
TriggersAndActionsUIPublicPluginStart,
} from '@kbn/triggers-actions-ui-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { TimeUnitChar } from '../../../common/utils/formatters';
import { MetricsExplorerSeries } from '../../../common/threshold_rule/metrics_explorer';
import {
Comparator,
CustomMetricExpressionParams,
FilterQuery,
MetricExpressionParams,
MetricsSourceStatus,
NonCountMetricExpressionParams,
SnapshotCustomMetricInput,
} from '../../../common/threshold_rule/types';
import { ObservabilityPublicStart } from '../../plugin';
import { MetricsExplorerOptions } from './hooks/use_metrics_explorer_options';
export interface AlertContextMeta {
currentOptions?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
}
export type MetricExpression = Omit<
MetricExpressionParams,
'metric' | 'timeSize' | 'timeUnit' | 'metrics' | 'equation' | 'customMetrics'
> & {
metric?: NonCountMetricExpressionParams['metric'];
customMetrics?: CustomMetricExpressionParams['customMetrics'];
label?: CustomMetricExpressionParams['label'];
equation?: CustomMetricExpressionParams['equation'];
timeSize?: MetricExpressionParams['timeSize'];
timeUnit?: MetricExpressionParams['timeUnit'];
};
export enum AGGREGATION_TYPES {
COUNT = 'count',
AVERAGE = 'avg',
SUM = 'sum',
MIN = 'min',
MAX = 'max',
RATE = 'rate',
CARDINALITY = 'cardinality',
P95 = 'p95',
P99 = 'p99',
CUSTOM = 'custom',
}
export interface MetricThresholdAlertParams {
criteria?: MetricExpression[];
groupBy?: string | string[];
filterQuery?: string;
sourceId?: string;
}
export interface ExpressionChartRow {
timestamp: number;
value: number;
}
export type ExpressionChartSeries = ExpressionChartRow[][];
export interface TimeRange {
from?: string;
to?: string;
}
export interface AlertParams {
criteria: MetricExpression[];
groupBy?: string | string[];
filterQuery?: FilterQuery;
sourceId: string;
filterQueryText?: string;
alertOnNoData?: boolean;
alertOnGroupDisappear?: boolean;
shouldDropPartialBuckets?: boolean;
}
export interface InfraClientStartDeps {
cases: CasesUiStart;
charts: ChartsPluginStart;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
discover: DiscoverStart;
embeddable?: EmbeddableStart;
lens: LensPublicStart;
// TODO:: check if needed => https://github.com/elastic/kibana/issues/159340
// ml: MlPluginStart;
observability: ObservabilityPublicStart;
observabilityShared: ObservabilitySharedPluginStart;
osquery?: unknown; // OsqueryPluginStart;
share: SharePluginStart;
spaces: SpacesPluginStart;
storage: IStorageWrapper;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
uiActions: UiActionsStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
usageCollection: UsageCollectionStart;
// TODO:: check if needed => https://github.com/elastic/kibana/issues/159340
// telemetry: ITelemetryClient;
}
export type RendererResult = React.ReactElement<any> | null;
export type RendererFunction<RenderArgs, Result = RendererResult> = (args: RenderArgs) => Result;
export interface DerivedIndexPattern {
fields: MetricsSourceStatus['indexFields'];
title: string;
}
export const SnapshotMetricTypeKeys = {
count: null,
cpu: null,
diskLatency: null,
load: null,
memory: null,
memoryTotal: null,
tx: null,
rx: null,
logRate: null,
diskIOReadBytes: null,
diskIOWriteBytes: null,
s3TotalRequests: null,
s3NumberOfObjects: null,
s3BucketSize: null,
s3DownloadBytes: null,
s3UploadBytes: null,
rdsConnections: null,
rdsQueriesExecuted: null,
rdsActiveTransactions: null,
rdsLatency: null,
sqsMessagesVisible: null,
sqsMessagesDelayed: null,
sqsMessagesSent: null,
sqsMessagesEmpty: null,
sqsOldestMessage: null,
custom: null,
};
export const SnapshotMetricTypeRT = rt.keyof(SnapshotMetricTypeKeys);
export type SnapshotMetricType = rt.TypeOf<typeof SnapshotMetricTypeRT>;
export interface InventoryMetricConditions {
metric: SnapshotMetricType;
timeSize: number;
timeUnit: TimeUnitChar;
sourceId?: string;
threshold: number[];
comparator: Comparator;
customMetric?: SnapshotCustomMetricInput;
warningThreshold?: number[];
warningComparator?: Comparator;
}
export interface MetricThresholdRuleTypeParams extends RuleTypeParams {
criteria: MetricExpressionParams[];
}

View file

@ -76,6 +76,9 @@ export interface ConfigSchema {
enabled: boolean;
};
};
thresholdRule: {
enabled: boolean;
};
};
coPilot?: {
enabled?: boolean;

View file

@ -11,15 +11,20 @@ import { ALERT_REASON } from '@kbn/rule-data-utils';
import { SLO_ID_FIELD } from '../../common/field_names/infra_metrics';
import { ConfigSchema } from '../plugin';
import { ObservabilityRuleTypeRegistry } from './create_observability_rule_type_registry';
import { SLO_BURN_RATE_RULE_ID } from '../../common/constants';
import {
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
SLO_BURN_RATE_RULE_TYPE_ID,
} from '../../common/constants';
import { validateBurnRateRule } from '../components/burn_rate_rule_editor/validation';
import { validateMetricThreshold } from '../pages/threshold/components/validation';
import { formatReason } from '../pages/threshold/rule_data_formatters';
export const registerObservabilityRuleTypes = (
config: ConfigSchema,
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry
) => {
observabilityRuleTypeRegistry.register({
id: SLO_BURN_RATE_RULE_ID,
id: SLO_BURN_RATE_RULE_TYPE_ID,
description: i18n.translate('xpack.observability.slo.rules.burnRate.description', {
defaultMessage: 'Alert when your SLO burn rate is too high over a defined period of time.',
}),
@ -48,4 +53,36 @@ export const registerObservabilityRuleTypes = (
}
),
});
if (config.unsafe.thresholdRule.enabled) {
observabilityRuleTypeRegistry.register({
id: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
description: i18n.translate(
'xpack.observability.threshold.rule.alertFlyout.alertDescription',
{
defaultMessage: 'Alert when threshold breached.',
}
),
iconClass: 'bell',
documentationUrl(docLinks) {
return `${docLinks.links.observability.metricsThreshold}`;
},
ruleParamsExpression: lazy(() => import('../pages/threshold/components/expression')),
validate: validateMetricThreshold,
defaultActionMessage: i18n.translate(
'xpack.observability.threshold.rule.alerting.threshold.defaultActionMessage',
{
defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\} is in a state of \\{\\{context.alertState\\}\\}
Reason:
\\{\\{context.reason\\}\\}
`,
}
),
requiresAppContext: false,
format: formatReason,
alertDetailsAppSection: lazy(
() => import('../pages/threshold/components/alert_details_app_section')
),
});
}
};

View file

@ -0,0 +1,62 @@
/*
* 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 { DecoratorFn } from '@storybook/react';
import React, { useEffect, useMemo, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import type { CoreTheme } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
type StoryContext = Parameters<DecoratorFn>[1];
export const useGlobalStorybookTheme = ({ globals: { euiTheme } }: StoryContext) => {
const theme = useMemo(() => euiThemeFromId(euiTheme), [euiTheme]);
const [theme$] = useState(() => new BehaviorSubject(theme));
useEffect(() => {
theme$.next(theme);
}, [theme$, theme]);
return {
theme,
theme$,
};
};
export function GlobalStorybookThemeProviders({
children,
storyContext,
}: {
storyContext: StoryContext;
children: React.ReactChild;
}) {
const { theme, theme$ } = useGlobalStorybookTheme(storyContext);
return (
<KibanaThemeProvider theme$={theme$}>
<EuiThemeProvider darkMode={theme.darkMode}>{children}</EuiThemeProvider>
</KibanaThemeProvider>
);
}
export const decorateWithGlobalStorybookThemeProviders: DecoratorFn = (
wrappedStory,
storyContext
) => (
<GlobalStorybookThemeProviders storyContext={storyContext}>
{wrappedStory()}
</GlobalStorybookThemeProviders>
);
const euiThemeFromId = (themeId: string): CoreTheme => {
switch (themeId) {
case 'v8.dark':
return { darkMode: true };
default:
return { darkMode: false };
}
};

View file

@ -32,6 +32,7 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) {
metrics: { enabled: false },
uptime: { enabled: false },
},
thresholdRule: { enabled: false },
},
coPilot: {
enabled: false,

Some files were not shown because too many files have changed in this diff Show more