mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
e325d4102b
commit
2d4f19e2ac
133 changed files with 13494 additions and 51 deletions
|
@ -2164,6 +2164,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"threshold-explorer-view": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
},
|
||||
"observability-onboarding-state": {
|
||||
"properties": {
|
||||
"state": {
|
||||
|
|
|
@ -94,7 +94,7 @@ pageLoadAssetSize:
|
|||
monitoring: 80000
|
||||
navigation: 37269
|
||||
newsfeed: 42228
|
||||
observability: 95000
|
||||
observability: 100000
|
||||
observabilityOnboarding: 19573
|
||||
observabilityShared: 52256
|
||||
osquery: 107090
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -123,6 +123,7 @@ const previouslyRegisteredTypes = [
|
|||
'telemetry',
|
||||
'timelion-sheet',
|
||||
'tsvb-validation-telemetry',
|
||||
'threshold-explorer-view',
|
||||
'ui-counter',
|
||||
'ui-metric',
|
||||
'upgrade-assistant-ml-upgrade-operation',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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]}`;
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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)}%`;
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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>;
|
372
x-pack/plugins/observability/common/threshold_rule/types.ts
Normal file
372
x-pack/plugins/observability/common/threshold_rule/types.ts
Normal 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>;
|
|
@ -14,4 +14,5 @@ export const observabilityAlertFeatureIds: ValidFeatureId[] = [
|
|||
AlertConsumers.LOGS,
|
||||
AlertConsumers.UPTIME,
|
||||
AlertConsumers.SLO,
|
||||
AlertConsumers.OBSERVABILITY,
|
||||
];
|
||||
|
|
|
@ -85,6 +85,7 @@ const withCore = makeDecorator({
|
|||
metrics: { enabled: false },
|
||||
uptime: { enabled: false },
|
||||
},
|
||||
thresholdRule: { enabled: false },
|
||||
},
|
||||
coPilot: {
|
||||
enabled: false,
|
||||
|
|
|
@ -41,6 +41,7 @@ jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({
|
|||
metrics: { enabled: false },
|
||||
uptime: { enabled: false },
|
||||
},
|
||||
thresholdRule: { enabled: false },
|
||||
},
|
||||
coPilot: {
|
||||
enabled: false,
|
||||
|
|
|
@ -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 } }}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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 {},
|
||||
]
|
||||
`;
|
|
@ -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>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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');
|
|
@ -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} />;
|
||||
}
|
|
@ -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;
|
||||
`;
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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');
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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'],
|
||||
},
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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
|
||||
);
|
|
@ -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 };
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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()');
|
||||
});
|
||||
});
|
|
@ -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 || ''})`;
|
||||
};
|
|
@ -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}`;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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)
|
||||
)
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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]);
|
||||
};
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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;
|
|
@ -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 },
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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(',');
|
||||
};
|
|
@ -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 };
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
177
x-pack/plugins/observability/public/pages/threshold/types.ts
Normal file
177
x-pack/plugins/observability/public/pages/threshold/types.ts
Normal 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[];
|
||||
}
|
|
@ -76,6 +76,9 @@ export interface ConfigSchema {
|
|||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
thresholdRule: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
coPilot?: {
|
||||
enabled?: boolean;
|
||||
|
|
|
@ -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')
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
};
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue