diff --git a/x-pack/plugins/infra/common/alerting/logs/types.ts b/x-pack/plugins/infra/common/alerting/logs/types.ts index 884a813d74c86..1b736f52aa7e2 100644 --- a/x-pack/plugins/infra/common/alerting/logs/types.ts +++ b/x-pack/plugins/infra/common/alerting/logs/types.ts @@ -96,40 +96,64 @@ const DocumentCountRT = rt.type({ export type DocumentCount = rt.TypeOf; -const CriterionRT = rt.type({ +export const CriterionRT = rt.type({ field: rt.string, comparator: ComparatorRT, value: rt.union([rt.string, rt.number]), }); export type Criterion = rt.TypeOf; +export const criteriaRT = rt.array(CriterionRT); -const TimeUnitRT = rt.union([rt.literal('s'), rt.literal('m'), rt.literal('h'), rt.literal('d')]); +export const TimeUnitRT = rt.union([ + rt.literal('s'), + rt.literal('m'), + rt.literal('h'), + rt.literal('d'), +]); export type TimeUnit = rt.TypeOf; +export const timeSizeRT = rt.number; +export const groupByRT = rt.array(rt.string); + export const LogDocumentCountAlertParamsRT = rt.intersection([ rt.type({ count: DocumentCountRT, - criteria: rt.array(CriterionRT), + criteria: criteriaRT, timeUnit: TimeUnitRT, - timeSize: rt.number, + timeSize: timeSizeRT, }), rt.partial({ - groupBy: rt.array(rt.string), + groupBy: groupByRT, }), ]); export type LogDocumentCountAlertParams = rt.TypeOf; +const chartPreviewHistogramBucket = rt.type({ + key: rt.number, + doc_count: rt.number, +}); + export const UngroupedSearchQueryResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, - rt.type({ - hits: rt.type({ - total: rt.type({ - value: rt.number, + rt.intersection([ + rt.type({ + hits: rt.type({ + total: rt.type({ + value: rt.number, + }), }), }), - }), + // Chart preview buckets + rt.partial({ + aggregations: rt.type({ + histogramBuckets: rt.type({ + buckets: rt.array(chartPreviewHistogramBucket), + }), + }), + }), + ]), ]); export type UngroupedSearchQueryResponse = rt.TypeOf; @@ -144,9 +168,17 @@ export const GroupedSearchQueryResponseRT = rt.intersection([ rt.type({ key: rt.record(rt.string, rt.string), doc_count: rt.number, - filtered_results: rt.type({ - doc_count: rt.number, - }), + filtered_results: rt.intersection([ + rt.type({ + doc_count: rt.number, + }), + // Chart preview buckets + rt.partial({ + histogramBuckets: rt.type({ + buckets: rt.array(chartPreviewHistogramBucket), + }), + }), + ]), }) ), }), diff --git a/x-pack/plugins/infra/common/color_palette.test.ts b/x-pack/plugins/infra/common/color_palette.test.ts index ced45c39c710c..1e814d6f67fec 100644 --- a/x-pack/plugins/infra/common/color_palette.test.ts +++ b/x-pack/plugins/infra/common/color_palette.test.ts @@ -4,35 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sampleColor, MetricsExplorerColor, colorTransformer } from './color_palette'; +import { sampleColor, Color, colorTransformer } from './color_palette'; describe('Color Palette', () => { describe('sampleColor()', () => { it('should just work', () => { - const usedColors = [MetricsExplorerColor.color0]; + const usedColors = [Color.color0]; const color = sampleColor(usedColors); - expect(color).toBe(MetricsExplorerColor.color1); + expect(color).toBe(Color.color1); }); it('should return color0 when nothing is available', () => { const usedColors = [ - MetricsExplorerColor.color0, - MetricsExplorerColor.color1, - MetricsExplorerColor.color2, - MetricsExplorerColor.color3, - MetricsExplorerColor.color4, - MetricsExplorerColor.color5, - MetricsExplorerColor.color6, - MetricsExplorerColor.color7, - MetricsExplorerColor.color8, - MetricsExplorerColor.color9, + Color.color0, + Color.color1, + Color.color2, + Color.color3, + Color.color4, + Color.color5, + Color.color6, + Color.color7, + Color.color8, + Color.color9, ]; const color = sampleColor(usedColors); - expect(color).toBe(MetricsExplorerColor.color0); + expect(color).toBe(Color.color0); }); }); describe('colorTransformer()', () => { it('should just work', () => { - expect(colorTransformer(MetricsExplorerColor.color0)).toBe('#6092C0'); + expect(colorTransformer(Color.color0)).toBe('#6092C0'); }); }); }); diff --git a/x-pack/plugins/infra/common/color_palette.ts b/x-pack/plugins/infra/common/color_palette.ts index 51962150d8424..2b72b3f0c1dfa 100644 --- a/x-pack/plugins/infra/common/color_palette.ts +++ b/x-pack/plugins/infra/common/color_palette.ts @@ -6,7 +6,7 @@ import { difference, first, values } from 'lodash'; import { euiPaletteColorBlind } from '@elastic/eui'; -export enum MetricsExplorerColor { +export enum Color { color0 = 'color0', color1 = 'color1', color2 = 'color2', @@ -19,41 +19,30 @@ export enum MetricsExplorerColor { color9 = 'color9', } -export interface MetricsExplorerPalette { - [MetricsExplorerColor.color0]: string; - [MetricsExplorerColor.color1]: string; - [MetricsExplorerColor.color2]: string; - [MetricsExplorerColor.color3]: string; - [MetricsExplorerColor.color4]: string; - [MetricsExplorerColor.color5]: string; - [MetricsExplorerColor.color6]: string; - [MetricsExplorerColor.color7]: string; - [MetricsExplorerColor.color8]: string; - [MetricsExplorerColor.color9]: string; -} +export type Palette = { + [K in keyof typeof Color]: string; +}; const euiPalette = euiPaletteColorBlind(); -export const defaultPalette: MetricsExplorerPalette = { - [MetricsExplorerColor.color0]: euiPalette[1], // (blue) - [MetricsExplorerColor.color1]: euiPalette[2], // (pink) - [MetricsExplorerColor.color2]: euiPalette[0], // (green-ish) - [MetricsExplorerColor.color3]: euiPalette[3], // (purple) - [MetricsExplorerColor.color4]: euiPalette[4], // (light pink) - [MetricsExplorerColor.color5]: euiPalette[5], // (yellow) - [MetricsExplorerColor.color6]: euiPalette[6], // (tan) - [MetricsExplorerColor.color7]: euiPalette[7], // (orange) - [MetricsExplorerColor.color8]: euiPalette[8], // (brown) - [MetricsExplorerColor.color9]: euiPalette[9], // (red) +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: MetricsExplorerPalette) => ( - color: MetricsExplorerColor -) => palette[color]; +export const createPaletteTransformer = (palette: Palette) => (color: Color) => palette[color]; export const colorTransformer = createPaletteTransformer(defaultPalette); -export const sampleColor = (usedColors: MetricsExplorerColor[] = []): MetricsExplorerColor => { - const available = difference(values(MetricsExplorerColor) as MetricsExplorerColor[], usedColors); - return first(available) || MetricsExplorerColor.color0; +export const sampleColor = (usedColors: Color[] = []): Color => { + const available = difference(values(Color) as Color[], usedColors); + return first(available) || Color.color0; }; diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 9ec8bf5231066..818009417fb1c 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -9,3 +9,4 @@ export * from './metadata_api'; export * from './log_entries'; export * from './metrics_explorer'; export * from './metrics_api'; +export * from './log_alerts'; diff --git a/x-pack/plugins/infra/common/http_api/log_alerts/chart_preview_data.ts b/x-pack/plugins/infra/common/http_api/log_alerts/chart_preview_data.ts new file mode 100644 index 0000000000000..15914bd1b2209 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_alerts/chart_preview_data.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { criteriaRT, TimeUnitRT, timeSizeRT, groupByRT } from '../../alerting/logs/types'; + +export const LOG_ALERTS_CHART_PREVIEW_DATA_PATH = '/api/infra/log_alerts/chart_preview_data'; + +const pointRT = rt.type({ + timestamp: rt.number, + value: rt.number, +}); + +export type Point = rt.TypeOf; + +const serieRT = rt.type({ + id: rt.string, + points: rt.array(pointRT), +}); + +const seriesRT = rt.array(serieRT); + +export type Series = rt.TypeOf; + +export const getLogAlertsChartPreviewDataSuccessResponsePayloadRT = rt.type({ + data: rt.type({ + series: seriesRT, + }), +}); + +export type GetLogAlertsChartPreviewDataSuccessResponsePayload = rt.TypeOf< + typeof getLogAlertsChartPreviewDataSuccessResponsePayloadRT +>; + +export const getLogAlertsChartPreviewDataAlertParamsSubsetRT = rt.intersection([ + rt.type({ + criteria: criteriaRT, + timeUnit: TimeUnitRT, + timeSize: timeSizeRT, + }), + rt.partial({ + groupBy: groupByRT, + }), +]); + +export type GetLogAlertsChartPreviewDataAlertParamsSubset = rt.TypeOf< + typeof getLogAlertsChartPreviewDataAlertParamsSubsetRT +>; + +export const getLogAlertsChartPreviewDataRequestPayloadRT = rt.type({ + data: rt.type({ + sourceId: rt.string, + alertParams: getLogAlertsChartPreviewDataAlertParamsSubsetRT, + buckets: rt.number, + }), +}); + +export type GetLogAlertsChartPreviewDataRequestPayload = rt.TypeOf< + typeof getLogAlertsChartPreviewDataRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_alerts/index.ts b/x-pack/plugins/infra/common/http_api/log_alerts/index.ts new file mode 100644 index 0000000000000..5634fda043a52 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_alerts/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './chart_preview_data'; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index c90c534193fdc..94ad074b72e9c 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -28,7 +28,7 @@ import { Comparator, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../server/lib/alerting/metric_threshold/types'; -import { MetricsExplorerColor, colorTransformer } from '../../../../common/color_palette'; +import { Color, colorTransformer } from '../../../../common/color_palette'; import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; import { MetricExpression, AlertContextMeta } from '../types'; @@ -80,7 +80,7 @@ export const ExpressionChart: React.FC = ({ const metric = { field: expression.metric, aggregation: expression.aggType as MetricsExplorerAggregation, - color: MetricsExplorerColor.color0, + color: Color.color0, }; const isDarkMode = context.uiSettings?.get('theme:darkMode') || false; const dateFormatter = useMemo(() => { @@ -176,7 +176,7 @@ export const ExpressionChart: React.FC = ({ style={{ line: { strokeWidth: 2, - stroke: colorTransformer(MetricsExplorerColor.color1), + stroke: colorTransformer(Color.color1), opacity: 1, }, }} @@ -186,7 +186,7 @@ export const ExpressionChart: React.FC = ({ = ({ = ({ = ({ = ({ ) => void; removeCriterion: (idx: number) => void; errors: IErrorObject; + alertParams: Partial; + context: AlertsContext; + sourceId: string; } export const Criteria: React.FC = ({ @@ -29,6 +34,9 @@ export const Criteria: React.FC = ({ updateCriterion, removeCriterion, errors, + alertParams, + context, + sourceId, }) => { if (!criteria) return null; return ( @@ -36,16 +44,23 @@ export const Criteria: React.FC = ({ {criteria.map((criterion, idx) => { return ( - 1} - errors={errors[idx.toString()] as IErrorObject} - /> + + 1} + errors={errors[idx.toString()] as IErrorObject} + /> + + ); })} diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion_preview_chart.tsx new file mode 100644 index 0000000000000..31f9a64015c07 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion_preview_chart.tsx @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { useDebounce } from 'react-use'; +import { + ScaleType, + AnnotationDomainTypes, + Position, + Axis, + BarSeries, + Chart, + Settings, + RectAnnotation, + LineAnnotation, +} from '@elastic/charts'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ChartContainer, + LoadingState, + NoDataState, + ErrorState, + TIME_LABELS, + getDomain, + tooltipProps, + useDateFormatter, + getChartTheme, + yAxisFormatter, + NUM_BUCKETS, +} from '../../shared/criterion_preview_chart/criterion_preview_chart'; +import { + LogDocumentCountAlertParams, + Criterion, + Comparator, +} from '../../../../../common/alerting/logs/types'; +import { Color, colorTransformer } from '../../../../../common/color_palette'; +import { + GetLogAlertsChartPreviewDataAlertParamsSubset, + getLogAlertsChartPreviewDataAlertParamsSubsetRT, +} from '../../../../../common/http_api/log_alerts/'; +import { AlertsContext } from './editor'; +import { useChartPreviewData } from './hooks/use_chart_preview_data'; +import { decodeOrThrow } from '../../../../../common/runtime_types'; + +const GROUP_LIMIT = 5; + +interface Props { + alertParams: Partial; + context: AlertsContext; + chartCriterion: Partial; + sourceId: string; +} + +export const CriterionPreview: React.FC = ({ + alertParams, + context, + chartCriterion, + sourceId, +}) => { + const chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset | null = useMemo(() => { + const { field, comparator, value } = chartCriterion; + const criteria = field && comparator && value ? [{ field, comparator, value }] : []; + const params = { + criteria, + timeSize: alertParams.timeSize, + timeUnit: alertParams.timeUnit, + groupBy: alertParams.groupBy, + }; + + try { + return decodeOrThrow(getLogAlertsChartPreviewDataAlertParamsSubsetRT)(params); + } catch (error) { + return null; + } + }, [alertParams.timeSize, alertParams.timeUnit, alertParams.groupBy, chartCriterion]); + + // Check for the existence of properties that are necessary for a meaningful chart. + if (chartAlertParams === null || chartAlertParams.criteria.length === 0) return null; + + return ( + + ); +}; + +interface ChartProps { + buckets: number; + context: AlertsContext; + sourceId: string; + threshold?: LogDocumentCountAlertParams['count']; + chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset; +} + +const CriterionPreviewChart: React.FC = ({ + buckets, + context, + sourceId, + threshold, + chartAlertParams, +}) => { + const isDarkMode = context.uiSettings?.get('theme:darkMode') || false; + + const { + getChartPreviewData, + isLoading, + hasError, + chartPreviewData: series, + } = useChartPreviewData({ + context, + sourceId, + alertParams: chartAlertParams, + buckets, + }); + + useDebounce( + () => { + getChartPreviewData(); + }, + 500, + [getChartPreviewData] + ); + + const isStacked = false; + + const { timeSize, timeUnit, groupBy } = chartAlertParams; + + const isGrouped = groupBy && groupBy.length > 0 ? true : false; + + const isAbove = + threshold && threshold.comparator + ? [Comparator.GT, Comparator.GT_OR_EQ].includes(threshold.comparator) + : false; + + const isBelow = + threshold && threshold.comparator + ? [Comparator.LT, Comparator.LT_OR_EQ].includes(threshold.comparator) + : false; + + // For grouped scenarios we want to limit the groups displayed, for "isAbove" thresholds we'll show + // groups with the highest doc counts. And for "isBelow" thresholds we'll show groups with the lowest doc counts. + const filteredSeries = useMemo(() => { + if (!isGrouped) { + return series; + } + + const sortedByMax = series.sort((a, b) => { + const aMax = Math.max(...a.points.map((point) => point.value)); + const bMax = Math.max(...b.points.map((point) => point.value)); + return bMax - aMax; + }); + const sortedSeries = (!isAbove && !isBelow) || isAbove ? sortedByMax : sortedByMax.reverse(); + return sortedSeries.slice(0, GROUP_LIMIT); + }, [series, isGrouped, isAbove, isBelow]); + + const barSeries = useMemo(() => { + return filteredSeries.reduce>( + (acc, serie) => { + const barPoints = serie.points.reduce< + Array<{ timestamp: number; value: number; groupBy: string }> + >((pointAcc, point) => { + return [...pointAcc, { ...point, groupBy: serie.id }]; + }, []); + return [...acc, ...barPoints]; + }, + [] + ); + }, [filteredSeries]); + + const lookback = timeSize * buckets; + const hasData = series.length > 0; + const { yMin, yMax, xMin, xMax } = getDomain(filteredSeries, isStacked); + const chartDomain = { + max: threshold && threshold.value ? Math.max(yMax, threshold.value) * 1.1 : yMax * 1.1, // Add 10% headroom. + min: threshold && threshold.value ? Math.min(yMin, threshold.value) : yMin, + }; + + if (threshold && threshold.value && chartDomain.min === threshold.value) { + chartDomain.min = chartDomain.min * 0.9; // Allow some padding so the threshold annotation has better visibility + } + + const THRESHOLD_OPACITY = 0.3; + const groupByLabel = groupBy && groupBy.length > 0 ? groupBy.join(', ') : null; + const dateFormatter = useDateFormatter(xMin, xMax); + const timeLabel = TIME_LABELS[timeUnit as keyof typeof TIME_LABELS]; + + if (isLoading) { + return ; + } else if (hasError) { + return ; + } else if (!hasData) { + return ; + } + + return ( + <> + + + + {threshold && threshold.value ? ( + + ) : null} + {threshold && threshold.value && isBelow ? ( + + ) : null} + {threshold && threshold.value && isAbove ? ( + + ) : null} + + + + + +
+ {groupByLabel != null ? ( + + + + ) : ( + + + + )} +
+ + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx index 295e60552cce5..e063b880ab843 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -34,12 +34,14 @@ interface LogsContextMeta { isInternal?: boolean; } +export type AlertsContext = AlertsContextValue; interface Props { errors: IErrorObject; alertParams: Partial; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; - alertsContext: AlertsContextValue; + alertsContext: AlertsContext; + sourceId: string; } const DEFAULT_CRITERIA = { field: 'log.level', comparator: Comparator.EQ, value: 'error' }; @@ -62,12 +64,12 @@ export const ExpressionEditor: React.FC = (props) => { <> {isInternal ? ( - + ) : ( - + )} @@ -119,7 +121,7 @@ export const SourceStatusWrapper: React.FC = (props) => { }; export const Editor: React.FC = (props) => { - const { setAlertParams, alertParams, errors } = props; + const { setAlertParams, alertParams, errors, alertsContext, sourceId } = props; const [hasSetDefaults, setHasSetDefaults] = useState(false); const { sourceStatus } = useLogSourceContext(); useMount(() => { @@ -227,6 +229,9 @@ export const Editor: React.FC = (props) => { updateCriterion={updateCriterion} removeCriterion={removeCriterion} errors={errors.criteria as IErrorObject} + alertParams={alertParams} + context={alertsContext} + sourceId={sourceId} /> { + const [chartPreviewData, setChartPreviewData] = useState< + GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series'] + >([]); + const [hasError, setHasError] = useState(false); + const [getChartPreviewDataRequest, getChartPreviewData] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + setHasError(false); + return await callGetChartPreviewDataAPI(sourceId, context.http.fetch, alertParams, buckets); + }, + onResolve: ({ data: { series } }) => { + setHasError(false); + setChartPreviewData(series); + }, + onReject: (error) => { + setHasError(true); + }, + }, + [sourceId, context.http.fetch, alertParams, buckets] + ); + + const isLoading = useMemo(() => getChartPreviewDataRequest.state === 'pending', [ + getChartPreviewDataRequest.state, + ]); + + return { + chartPreviewData, + hasError, + isLoading, + getChartPreviewData, + }; +}; + +export const callGetChartPreviewDataAPI = async ( + sourceId: string, + fetch: AlertsContext['http']['fetch'], + alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset, + buckets: number +) => { + const response = await fetch(LOG_ALERTS_CHART_PREVIEW_DATA_PATH, { + method: 'POST', + body: JSON.stringify( + getLogAlertsChartPreviewDataRequestPayloadRT.encode({ + data: { + sourceId, + alertParams, + buckets, + }, + }) + ), + }); + + return decodeOrThrow(getLogAlertsChartPreviewDataSuccessResponsePayloadRT)(response); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/shared/criterion_preview_chart/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/components/alerting/shared/criterion_preview_chart/criterion_preview_chart.tsx new file mode 100644 index 0000000000000..239afd93a7a1f --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/shared/criterion_preview_chart/criterion_preview_chart.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo } from 'react'; +import { niceTimeFormatter, TooltipValue } from '@elastic/charts'; +import { Theme, LIGHT_THEME, DARK_THEME } from '@elastic/charts'; +import { sum, min as getMin, max as getMax } from 'lodash'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { formatNumber } from '../../../../../common/formatters/number'; +import { GetLogAlertsChartPreviewDataSuccessResponsePayload } from '../../../../../common/http_api'; + +type Series = GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series']; + +export const tooltipProps = { + headerFormatter: (tooltipValue: TooltipValue) => + moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss'), +}; + +export const NUM_BUCKETS = 20; + +export const TIME_LABELS = { + s: i18n.translate('xpack.infra.alerts.timeLabels.seconds', { defaultMessage: 'seconds' }), + m: i18n.translate('xpack.infra.alerts.timeLabels.minutes', { defaultMessage: 'minutes' }), + h: i18n.translate('xpack.infra.alerts.timeLabels.hours', { defaultMessage: 'hours' }), + d: i18n.translate('xpack.infra.alerts.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 }; +}; + +export const getChartTheme = (isDarkMode: boolean): Theme => { + return isDarkMode ? DARK_THEME : LIGHT_THEME; +}; + +export const EmptyContainer: React.FC = ({ children }) => ( +
+ {children} +
+); + +export const ChartContainer: React.FC = ({ children }) => ( +
+ {children} +
+); + +export const NoDataState = () => { + return ( + + + + + + ); +}; + +export const LoadingState = () => { + return ( + + + + + + ); +}; + +export const ErrorState = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts index f94c6b6156ae4..d706d598058bd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domian.test.ts @@ -7,7 +7,7 @@ import { calculateDomain } from './calculate_domain'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options'; -import { MetricsExplorerColor } from '../../../../../../common/color_palette'; +import { Color } from '../../../../../../common/color_palette'; describe('calculateDomain()', () => { const series: MetricsExplorerSeries = { id: 'test-01', @@ -29,12 +29,12 @@ describe('calculateDomain()', () => { { aggregation: 'avg', field: 'system.memory.free', - color: MetricsExplorerColor.color0, + color: Color.color0, }, { aggregation: 'avg', field: 'system.memory.used.bytes', - color: MetricsExplorerColor.color1, + color: Color.color1, }, ]; it('should return the min and max across 2 metrics', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index afddaf6621f10..15ed28c095199 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -7,7 +7,7 @@ import { encode } from 'rison-node'; import uuid from 'uuid'; import { set } from '@elastic/safer-lodash-set'; -import { colorTransformer, MetricsExplorerColor } from '../../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../../common/color_palette'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, @@ -91,9 +91,7 @@ const mapMetricToSeries = (chartOptions: MetricsExplorerChartOptions) => ( label: createMetricLabel(metric), axis_position: 'right', chart_type: 'line', - color: - (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0), + color: (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0), fill: chartOptions.type === MetricsExplorerChartType.area ? 0.5 : 0, formatter: format === InfraFormatterType.bits ? InfraFormatterType.bytes : format, value_template: 'rate' === metric.aggregation ? '{{value}}/s' : '{{value}}', diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx index 8be03a7096f08..b81a905b4aa87 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/metrics.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; import { IFieldType } from 'src/plugins/data/public'; -import { colorTransformer, MetricsExplorerColor } from '../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../common/color_palette'; import { MetricsExplorerMetric } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options'; @@ -26,7 +26,7 @@ interface SelectedOption { } export const MetricsExplorerMetrics = ({ options, onChange, fields, autoFocus = false }: Props) => { - const colors = Object.keys(MetricsExplorerColor) as MetricsExplorerColor[]; + const colors = Object.keys(Color) as Array; const [shouldFocus, setShouldFocus] = useState(autoFocus); // the EuiCombobox forwards the ref to an input element @@ -59,7 +59,7 @@ export const MetricsExplorerMetrics = ({ options, onChange, fields, autoFocus = .map((metric) => ({ label: metric.field || '', value: metric.field || '', - color: colorTransformer(metric.color || MetricsExplorerColor.color0), + color: colorTransformer(metric.color || Color.color0), })); const placeholderText = i18n.translate('xpack.infra.metricsExplorer.metricComboBoxPlaceholder', { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx index 9b594ef5e630f..a621dca1e0c51 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx @@ -14,7 +14,7 @@ import { BarSeriesStyle, } from '@elastic/charts'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; -import { colorTransformer, MetricsExplorerColor } from '../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../common/color_palette'; import { createMetricLabel } from './helpers/create_metric_label'; import { MetricsExplorerOptionsMetric, @@ -41,9 +41,7 @@ export const MetricExplorerSeriesChart = (props: Props) => { }; export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opacity }: Props) => { - const color = - (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0); + 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) @@ -84,9 +82,7 @@ export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opac }; export const MetricsExplorerBarChart = ({ metric, id, series, stack }: Props) => { - const color = - (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0); + 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) diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 299231f1821f0..d54cb758188c6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -9,19 +9,14 @@ import { values } from 'lodash'; import createContainer from 'constate'; import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react'; import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; -import { MetricsExplorerColor } from '../../../../../common/color_palette'; +import { Color } from '../../../../../common/color_palette'; import { metricsExplorerMetricRT } from '../../../../../common/http_api/metrics_explorer'; const metricsExplorerOptionsMetricRT = t.intersection([ metricsExplorerMetricRT, t.partial({ rate: t.boolean, - color: t.keyof( - Object.fromEntries(values(MetricsExplorerColor).map((c) => [c, null])) as Record< - MetricsExplorerColor, - null - > - ), + color: t.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record), label: t.string, }), ]); @@ -100,17 +95,17 @@ export const DEFAULT_METRICS: MetricsExplorerOptionsMetric[] = [ { aggregation: 'avg', field: 'system.cpu.user.pct', - color: MetricsExplorerColor.color0, + color: Color.color0, }, { aggregation: 'avg', field: 'kubernetes.pod.cpu.usage.node.pct', - color: MetricsExplorerColor.color1, + color: Color.color1, }, { aggregation: 'avg', field: 'docker.cpu.total.pct', - color: MetricsExplorerColor.color2, + color: Color.color2, }, ]; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index c080618f2a563..a72e40e25b479 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -36,6 +36,7 @@ import { initInventoryMetaRoute } from './routes/inventory_metadata'; import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources'; import { initSourceRoute } from './routes/source'; import { initAlertPreviewRoute } from './routes/alerting'; +import { initGetLogAlertsChartPreviewDataRoute } from './routes/log_alerts'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ @@ -72,4 +73,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initLogSourceConfigurationRoutes(libs); initLogSourceStatusRoutes(libs); initAlertPreviewRoute(libs); + initGetLogAlertsChartPreviewDataRoute(libs); }; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts new file mode 100644 index 0000000000000..026f003463ef2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RequestHandlerContext } from 'src/core/server'; +import { InfraSource } from '../../sources'; +import { KibanaFramework } from '../../adapters/framework/kibana_framework_adapter'; +import { + GetLogAlertsChartPreviewDataAlertParamsSubset, + Series, + Point, +} from '../../../../common/http_api/log_alerts'; +import { + getGroupedESQuery, + getUngroupedESQuery, + buildFiltersFromCriteria, +} from './log_threshold_executor'; +import { + UngroupedSearchQueryResponseRT, + UngroupedSearchQueryResponse, + GroupedSearchQueryResponse, + GroupedSearchQueryResponseRT, +} from '../../../../common/alerting/logs/types'; +import { decodeOrThrow } from '../../../../common/runtime_types'; + +const COMPOSITE_GROUP_SIZE = 40; + +export async function getChartPreviewData( + requestContext: RequestHandlerContext, + sourceConfiguration: InfraSource, + callWithRequest: KibanaFramework['callWithRequest'], + alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset, + buckets: number +) { + const indexPattern = sourceConfiguration.configuration.logAlias; + const timestampField = sourceConfiguration.configuration.fields.timestamp; + + const { groupBy, timeSize, timeUnit } = alertParams; + const isGrouped = groupBy && groupBy.length > 0 ? true : false; + + // Charts will use an expanded time range + const expandedAlertParams = { + ...alertParams, + timeSize: timeSize * buckets, + }; + + const { rangeFilter } = buildFiltersFromCriteria(expandedAlertParams, timestampField); + + const query = isGrouped + ? getGroupedESQuery(expandedAlertParams, sourceConfiguration.configuration, indexPattern) + : getUngroupedESQuery(expandedAlertParams, sourceConfiguration.configuration, indexPattern); + + if (!query) { + throw new Error('ES query could not be built from the provided alert params'); + } + + const expandedQuery = addHistogramAggregationToQuery( + query, + rangeFilter, + `${timeSize}${timeUnit}`, + timestampField, + isGrouped + ); + + const series = isGrouped + ? processGroupedResults(await getGroupedResults(expandedQuery, requestContext, callWithRequest)) + : processUngroupedResults( + await getUngroupedResults(expandedQuery, requestContext, callWithRequest) + ); + + return { series }; +} + +// Expand the same query that powers the executor with a date histogram aggregation +const addHistogramAggregationToQuery = ( + query: any, + rangeFilter: any, + interval: string, + timestampField: string, + isGrouped: boolean +) => { + const histogramAggregation = { + histogramBuckets: { + date_histogram: { + field: timestampField, + fixed_interval: interval, + // Utilise extended bounds to make sure we get a full set of buckets even if there are empty buckets + // at the start and / or end of the range. + extended_bounds: { + min: rangeFilter.range[timestampField].gte, + max: rangeFilter.range[timestampField].lte, + }, + }, + }, + }; + + if (isGrouped) { + query.body.aggregations.groups.aggregations.filtered_results = { + ...query.body.aggregations.groups.aggregations.filtered_results, + aggregations: histogramAggregation, + }; + } else { + query.body = { + ...query.body, + aggregations: histogramAggregation, + }; + } + + return query; +}; + +const getUngroupedResults = async ( + query: object, + requestContext: RequestHandlerContext, + callWithRequest: KibanaFramework['callWithRequest'] +) => { + return decodeOrThrow(UngroupedSearchQueryResponseRT)( + await callWithRequest(requestContext, 'search', query) + ); +}; + +const getGroupedResults = async ( + query: object, + requestContext: RequestHandlerContext, + callWithRequest: KibanaFramework['callWithRequest'] +) => { + let compositeGroupBuckets: GroupedSearchQueryResponse['aggregations']['groups']['buckets'] = []; + let lastAfterKey: GroupedSearchQueryResponse['aggregations']['groups']['after_key'] | undefined; + + while (true) { + const queryWithAfterKey: any = { ...query }; + queryWithAfterKey.body.aggregations.groups.composite.after = lastAfterKey; + const groupResponse: GroupedSearchQueryResponse = decodeOrThrow(GroupedSearchQueryResponseRT)( + await callWithRequest(requestContext, 'search', queryWithAfterKey) + ); + compositeGroupBuckets = [ + ...compositeGroupBuckets, + ...groupResponse.aggregations.groups.buckets, + ]; + lastAfterKey = groupResponse.aggregations.groups.after_key; + if (groupResponse.aggregations.groups.buckets.length < COMPOSITE_GROUP_SIZE) { + break; + } + } + + return compositeGroupBuckets; +}; + +const processGroupedResults = ( + results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'] +): Series => { + return results.reduce((series, group) => { + if (!group.filtered_results.histogramBuckets) return series; + const groupName = Object.values(group.key).join(', '); + const points = group.filtered_results.histogramBuckets.buckets.reduce( + (pointsAcc, bucket) => { + const { key, doc_count: count } = bucket; + return [...pointsAcc, { timestamp: key, value: count }]; + }, + [] + ); + return [...series, { id: groupName, points }]; + }, []); +}; + +const processUngroupedResults = (results: UngroupedSearchQueryResponse): Series => { + if (!results.aggregations?.histogramBuckets) return []; + const points = results.aggregations.histogramBuckets.buckets.reduce( + (pointsAcc, bucket) => { + const { key, doc_count: count } = bucket; + return [...pointsAcc, { timestamp: key, value: count }]; + }, + [] + ); + return [{ id: everythingSeriesName, points }]; +}; + +const everythingSeriesName = i18n.translate( + 'xpack.infra.logs.alerting.threshold.everythingSeriesName', + { + defaultMessage: 'Log entries', + } +); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index 85bb18e199192..db76e955f0073 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -145,7 +145,10 @@ const processGroupByResults = ( }); }; -const buildFiltersFromCriteria = (params: LogDocumentCountAlertParams, timestampField: string) => { +export const buildFiltersFromCriteria = ( + params: Omit, + timestampField: string +) => { const { timeSize, timeUnit, criteria } = params; const interval = `${timeSize}${timeUnit}`; const intervalAsSeconds = getIntervalInSeconds(interval); @@ -193,8 +196,8 @@ const buildFiltersFromCriteria = (params: LogDocumentCountAlertParams, timestamp return { rangeFilter, groupedRangeFilter, mustFilters, mustNotFilters }; }; -const getGroupedESQuery = ( - params: LogDocumentCountAlertParams, +export const getGroupedESQuery = ( + params: Omit, sourceConfiguration: InfraSource['configuration'], index: string ): object | undefined => { @@ -253,8 +256,8 @@ const getGroupedESQuery = ( }; }; -const getUngroupedESQuery = ( - params: LogDocumentCountAlertParams, +export const getUngroupedESQuery = ( + params: Omit, sourceConfiguration: InfraSource['configuration'], index: string ): object => { diff --git a/x-pack/plugins/infra/server/routes/log_alerts/chart_preview_data.ts b/x-pack/plugins/infra/server/routes/log_alerts/chart_preview_data.ts new file mode 100644 index 0000000000000..95389e14acdb8 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_alerts/chart_preview_data.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { InfraBackendLibs } from '../../lib/infra_types'; +import { + LOG_ALERTS_CHART_PREVIEW_DATA_PATH, + getLogAlertsChartPreviewDataSuccessResponsePayloadRT, + getLogAlertsChartPreviewDataRequestPayloadRT, +} from '../../../common/http_api/log_alerts/chart_preview_data'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { getChartPreviewData } from '../../lib/alerting/log_threshold/log_threshold_chart_preview'; + +export const initGetLogAlertsChartPreviewDataRoute = ({ framework, sources }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ALERTS_CHART_PREVIEW_DATA_PATH, + validate: { + body: createValidationFunction(getLogAlertsChartPreviewDataRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { sourceId, buckets, alertParams }, + } = request.body; + + const sourceConfiguration = await sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); + + try { + const { series } = await getChartPreviewData( + requestContext, + sourceConfiguration, + framework.callWithRequest, + alertParams, + buckets + ); + + return response.ok({ + body: getLogAlertsChartPreviewDataSuccessResponsePayloadRT.encode({ + data: { series }, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/infra/server/routes/log_alerts/index.ts b/x-pack/plugins/infra/server/routes/log_alerts/index.ts new file mode 100644 index 0000000000000..5634fda043a52 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_alerts/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './chart_preview_data';