Skip to content

Commit

Permalink
[AO] Add threshold information to the metric threshold alert details …
Browse files Browse the repository at this point in the history
…page (#155493)

Closes #153740, closes #153833, closes #155593

This PR adds threshold information and rule name to the metric threshold
alert details page.


![image](https://user-images.githubusercontent.com/12370520/233968325-8a66166b-2534-4b9b-9054-9085270db5f6.png)


## 🧪 How to test
- Add xpack.observability.unsafe.alertDetails.metrics.enabled: true to
the Kibana config
- Create a metric threshold rule with multiple conditions that generates
an alert
- Go to the alert details page and check threshold information
- Click on the rule link; it should send you to the rule page
  • Loading branch information
maryam-saeidi authored Apr 25, 2023
1 parent 0ecb2cb commit dfea483
Showing 8 changed files with 225 additions and 49 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
);
});
Original file line number Diff line number Diff line change
@@ -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 { i18n } from '@kbn/i18n';
import { createFormatter } from '../../formatters';

export const metricValueFormatter = (value: number | null, metric: string = '') => {
const noDataValue = i18n.translate('xpack.infra.metrics.alerting.noDataFormattedValue', {
defaultMessage: '[NO DATA]',
});

const formatter = metric.endsWith('.pct')
? createFormatter('percent')
: createFormatter('highPrecision');

return value == null ? noDataValue : formatter(value);
};

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -6,6 +6,8 @@
*/

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';
@@ -17,6 +19,8 @@ import {
import { AlertDetailsAppSection } from './alert_details_app_section';
import { ExpressionChart } from './expression_chart';

const mockedChartStartContract = chartPluginMock.createStartContract();

jest.mock('@kbn/observability-alert-details', () => ({
AlertAnnotation: () => {},
AlertActiveTimeRangeAnnotation: () => {},
@@ -32,7 +36,10 @@ jest.mock('./expression_chart', () => ({

jest.mock('../../../hooks/use_kibana', () => ({
useKibanaContextForPlugin: () => ({
services: mockCoreMock.createStart(),
services: {
...mockCoreMock.createStart(),
charts: mockedChartStartContract,
},
}),
}));

@@ -46,23 +53,48 @@ jest.mock('../../../containers/metrics_source/source', () => ({

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

it('should render rule data', async () => {
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 () => {
Original file line number Diff line number Diff line change
@@ -5,17 +5,31 @@
* 2.0.
*/

import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useMemo } from 'react';
import moment from 'moment';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, useEuiTheme } from '@elastic/eui';
import { TopAlert } from '@kbn/observability-plugin/public';
import { ALERT_END, ALERT_START } from '@kbn/rule-data-utils';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { AlertSummaryField, TopAlert } from '@kbn/observability-plugin/public';
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 { metricValueFormatter } from '../../../../common/alerting/metrics/metric_value_formatter';
import { TIME_LABELS } from '../../common/criterion_preview_chart/criterion_preview_chart';
import { Threshold } from '../../common/components/threshold';
import { useSourceContext, withSourceProvider } from '../../../containers/metrics_source';
import { generateUniqueKey } from '../lib/generate_unique_key';
import { MetricsExplorerChartType } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
@@ -37,19 +51,30 @@ const ALERT_START_ANNOTATION_ID = 'alert_start_annotation';
const ALERT_TIME_RANGE_ANNOTATION_ID = 'alert_time_range_annotation';

interface AppSectionProps {
rule: MetricThresholdRule;
alert: MetricThresholdAlert;
rule: MetricThresholdRule;
ruleLink: string;
setAlertSummaryFields: React.Dispatch<React.SetStateAction<AlertSummaryField[] | undefined>>;
}

export function AlertDetailsAppSection({ alert, rule }: AppSectionProps) {
const { uiSettings } = useKibanaContextForPlugin().services;
export function AlertDetailsAppSection({
alert,
rule,
ruleLink,
setAlertSummaryFields,
}: AppSectionProps) {
const { uiSettings, charts } = useKibanaContextForPlugin().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 = [
@@ -68,22 +93,76 @@ export function AlertDetailsAppSection({ alert, rule }: AppSectionProps) {
key={ALERT_TIME_RANGE_ANNOTATION_ID}
/>,
];
useEffect(() => {
setAlertSummaryFields([
{
label: i18n.translate('xpack.infra.metrics.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) => (
{rule.params.criteria.map((criterion, index) => (
<EuiFlexItem key={generateUniqueKey(criterion)}>
<EuiPanel hasBorder hasShadow={false}>
<ExpressionChart
expression={criterion}
derivedIndexPattern={derivedIndexPattern}
source={source}
filterQuery={rule.params.filterQueryText}
groupBy={rule.params.groupBy}
chartType={MetricsExplorerChartType.line}
timeRange={timeRange}
annotations={annotations}
/>
<EuiTitle size="xs">
<h4>
{criterion.aggType.toUpperCase()}{' '}
{'metric' in criterion ? criterion.metric : undefined}
</h4>
</EuiTitle>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.infra.metrics.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.infra.metrics.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>
))}
Original file line number Diff line number Diff line change
@@ -49,23 +49,25 @@ import { CUSTOM_EQUATION } from '../i18n_strings';
interface Props {
expression: MetricExpression;
derivedIndexPattern: DataViewBase;
source?: MetricsSourceConfiguration;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
chartType?: MetricsExplorerChartType;
filterQuery?: string;
groupBy?: string | string[];
chartType?: MetricsExplorerChartType;
hideTitle?: boolean;
source?: MetricsSourceConfiguration;
timeRange?: TimeRange;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
}

export const ExpressionChart: React.FC<Props> = ({
expression,
derivedIndexPattern,
source,
annotations,
chartType = MetricsExplorerChartType.bar,
filterQuery,
groupBy,
chartType = MetricsExplorerChartType.bar,
hideTitle = false,
source,
timeRange,
annotations,
}) => {
const { uiSettings, charts } = useKibanaContextForPlugin().services;

@@ -200,25 +202,27 @@ export const ExpressionChart: React.FC<Props> = ({
/>
</Chart>
</ChartContainer>
<div style={{ textAlign: 'center' }}>
{series.id !== 'ALL' ? (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.infra.metrics.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.infra.metrics.alerts.dataTimeRangeLabel"
defaultMessage="Last {lookback} {timeLabel}"
values={{ timeLabel, lookback: timeSize! * 20 }}
/>
</EuiText>
)}
</div>
{!hideTitle && (
<div style={{ textAlign: 'center' }}>
{series.id !== 'ALL' ? (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.infra.metrics.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.infra.metrics.alerts.dataTimeRangeLabel"
defaultMessage="Last {lookback} {timeLabel}"
values={{ timeLabel, lookback: timeSize! * 20 }}
/>
</EuiText>
)}
</div>
)}
</>
);
};
Loading

0 comments on commit dfea483

Please sign in to comment.