[AO] - Add Logs history chart to the Logs Alert Details page (#153930)

## Summary

It closes #150854 by

- Add optional annotations to the prereview chart
- Add the Logs history chart 

<img width="941" alt="Screenshot 2023-03-29 at 17 09 36"
src="https://user-images.githubusercontent.com/6838659/228584016-f73efef0-03e6-4777-b2df-17f13166c77b.png">

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
This commit is contained in:
Faisal Kanout 2023-04-03 17:03:10 +02:00 committed by GitHub
parent 662507b91e
commit 64d53bde6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 416 additions and 6 deletions

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts';
import moment from 'moment';
import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana';
export function AlertAnnotation({ alertStarted }: { alertStarted: number }) {
const { uiSettings } = useKibanaContextForPlugin().services;
return (
<LineAnnotation
id="annotation_alert_started"
domainType={AnnotationDomainType.XDomain}
dataValues={[
{
dataValue: alertStarted,
header: moment(alertStarted).format(uiSettings.get(UI_SETTINGS.DATE_FORMAT)),
details: i18n.translate('xpack.infra.logs.alertDetails.chartAnnotation.alertStarted', {
defaultMessage: 'Alert started',
}),
},
]}
style={{
line: {
strokeWidth: 3,
stroke: euiThemeVars.euiColorDangerText,
opacity: 1,
},
}}
marker={<EuiIcon type="warning" color="danger" />}
markerPosition={Position.Top}
/>
);
}

View file

@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import React from 'react';
import { Rule } from '@kbn/alerting-plugin/common';
import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { convertTo, TopAlert } from '@kbn/observability-plugin/public';
import { AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts';
import { EuiIcon, EuiBadge } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { AlertConsumers } from '@kbn/rule-data-utils';
import DateMath from '@kbn/datemath';
import { useAlertsHistory } from '../../../../../hooks/use_alerts_history';
import { type PartialCriterion } from '../../../../../../common/alerting/logs/log_threshold';
import { CriterionPreview } from '../../expression_editor/criterion_preview_chart';
import { PartialRuleParams } from '../../../../../../common/alerting/logs/log_threshold';
const LogsHistoryChart = ({
rule,
alert,
}: {
rule: Rule<PartialRuleParams>;
alert: TopAlert<Record<string, any>>;
}) => {
// Show the Logs History Chart ONLY if we have one criteria
// So always pull the first criteria
const criteria = rule.params.criteria[0];
const dateRange = {
from: 'now-30d',
to: 'now',
};
const executionTimeRange = {
gte: DateMath.parse(dateRange.from)!.valueOf(),
lte: DateMath.parse(dateRange.to, { roundUp: true })!.valueOf(),
};
const { alertsHistory } = useAlertsHistory({
featureIds: [AlertConsumers.LOGS],
ruleId: rule.id,
dateRange,
});
const alertHistoryAnnotations =
alertsHistory?.histogramTriggeredAlerts
.filter((annotation) => annotation.doc_count > 0)
.map((annotation) => {
return {
dataValue: annotation.key,
header: String(annotation.doc_count),
// Only the date(without time) is needed here, uiSettings don't provide that
details: moment(annotation.key_as_string).format('yyyy-MM-DD'),
};
}) || [];
return (
<EuiPanel hasBorder={true} data-test-subj="logsHistoryChartAlertDetails">
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.infra.logs.alertDetails.chartHistory.chartTitle', {
defaultMessage: 'Logs threshold alerts history',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.translate('xpack.infra.logs.alertDetails.chartHistory.last30days', {
defaultMessage: 'Last 30 days',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="l">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem grow={false}>
<EuiText color="danger">
<EuiTitle size="s">
<h3>{alertsHistory?.totalTriggeredAlerts || '-'}</h3>
</EuiTitle>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.translate('xpack.infra.logs.alertDetails.chartHistory.alertsTriggered', {
defaultMessage: 'Alerts triggered',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem grow={false}>
<EuiText>
<EuiTitle size="s">
<h3>
{alertsHistory?.avgTimeToRecoverUS
? convertTo({
unit: 'minutes',
microseconds: alertsHistory?.avgTimeToRecoverUS,
extended: true,
}).formatted
: '-'}
</h3>
</EuiTitle>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.translate('xpack.infra.logs.alertDetails.chartHistory.avgTimeToRecover', {
defaultMessage: 'Avg time to recover',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
<EuiSpacer size="s" />
<CriterionPreview
annotations={[
<LineAnnotation
id="annotations"
key={'annotationsAlertHistory'}
domainType={AnnotationDomainType.XDomain}
dataValues={alertHistoryAnnotations}
style={{
line: {
strokeWidth: 3,
stroke: euiThemeVars.euiColorDangerText,
opacity: 1,
},
}}
marker={<EuiIcon type="warning" color="danger" />}
markerBody={(annotationData) => (
<>
<EuiBadge color="danger">
<EuiText size="xs" color="white">
{annotationData.header}
</EuiText>
</EuiBadge>
<EuiSpacer size="xs" />
</>
)}
markerPosition={Position.Top}
/>,
]}
ruleParams={rule.params}
logViewReference={rule.params.logView}
chartCriterion={criteria as PartialCriterion}
showThreshold={true}
executionTimeRange={executionTimeRange}
/>
</EuiPanel>
);
};
// eslint-disable-next-line import/no-default-export
export default LogsHistoryChart;

View file

@ -10,8 +10,11 @@ import moment from 'moment';
import React from 'react';
import { type PartialCriterion } from '../../../../../common/alerting/logs/log_threshold';
import { CriterionPreview } from '../expression_editor/criterion_preview_chart';
import { AlertAnnotation } from './components/alert_annotation';
import { AlertDetailsAppSectionProps } from './types';
const LogsHistoryChart = React.lazy(() => import('./components/logs_history_chart'));
const AlertDetailsAppSection = ({ rule, alert }: AlertDetailsAppSectionProps) => {
const ruleWindowSizeMS = moment
.duration(rule.params.timeSize, rule.params.timeUnit)
@ -34,13 +37,12 @@ const AlertDetailsAppSection = ({ rule, alert }: AlertDetailsAppSectionProps) =>
return (
// Create a chart per-criteria
<EuiFlexGroup>
{rule.params.criteria.map((criteria) => {
<EuiFlexGroup direction="column">
{rule.params.criteria.map((criteria, idx) => {
const chartCriterion = criteria as PartialCriterion;
return (
<EuiFlexItem>
<EuiFlexItem key={`${chartCriterion.field}${idx}`}>
<CriterionPreview
key={chartCriterion.field}
ruleParams={rule.params}
logViewReference={{
type: 'log-view-reference',
@ -49,10 +51,17 @@ const AlertDetailsAppSection = ({ rule, alert }: AlertDetailsAppSectionProps) =>
chartCriterion={chartCriterion}
showThreshold={true}
executionTimeRange={{ gte: rangeFrom, lte: rangeTo }}
annotations={[<AlertAnnotation alertStarted={alert.start} />]}
/>
</EuiFlexItem>
);
})}
{/* For now we show the history chart only if we have one criteria */}
{rule.params.criteria.length === 1 && (
<EuiFlexItem>
<LogsHistoryChart alert={alert} rule={rule} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { ReactElement, useMemo } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import {
ScaleType,
@ -59,6 +59,7 @@ interface Props {
logViewReference: PersistedLogViewReference;
showThreshold: boolean;
executionTimeRange?: ExecutionTimeRange;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
}
export const CriterionPreview: React.FC<Props> = ({
@ -67,6 +68,7 @@ export const CriterionPreview: React.FC<Props> = ({
logViewReference,
showThreshold,
executionTimeRange,
annotations,
}) => {
const chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset | null = useMemo(() => {
const { field, comparator, value } = chartCriterion;
@ -111,6 +113,7 @@ export const CriterionPreview: React.FC<Props> = ({
chartAlertParams={chartAlertParams}
showThreshold={showThreshold}
executionTimeRange={executionTimeRange}
annotations={annotations}
/>
);
};
@ -122,6 +125,7 @@ interface ChartProps {
chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset;
showThreshold: boolean;
executionTimeRange?: ExecutionTimeRange;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
}
const CriterionPreviewChart: React.FC<ChartProps> = ({
@ -131,6 +135,7 @@ const CriterionPreviewChart: React.FC<ChartProps> = ({
chartAlertParams,
showThreshold,
executionTimeRange,
annotations,
}) => {
const { uiSettings } = useKibana().services;
const isDarkMode = uiSettings?.get('theme:darkMode') || false;
@ -287,6 +292,7 @@ const CriterionPreviewChart: React.FC<ChartProps> = ({
]}
/>
) : null}
{annotations}
{showThreshold && threshold && isAbove ? (
<RectAnnotation
id="above-threshold"

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { AsApiContract } from '@kbn/actions-plugin/common';
import { HttpSetup } from '@kbn/core/public';
import {
ALERT_DURATION,
ALERT_RULE_UUID,
ALERT_START,
ALERT_STATUS,
ALERT_TIME_RANGE,
ValidFeatureId,
} from '@kbn/rule-data-utils';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
interface Props {
featureIds: ValidFeatureId[];
ruleId: string;
dateRange: {
from: string;
to: string;
};
}
interface FetchAlertsHistory {
totalTriggeredAlerts: number;
histogramTriggeredAlerts: Array<{
key_as_string: string;
key: number;
doc_count: number;
}>;
error?: string;
avgTimeToRecoverUS: number;
}
interface AlertsHistory {
isLoadingAlertsHistory: boolean;
errorAlertHistory?: string;
alertsHistory?: FetchAlertsHistory;
}
export function useAlertsHistory({ featureIds, ruleId, dateRange }: Props) {
const { http } = useKibana().services;
const [triggeredAlertsHistory, setTriggeredAlertsHistory] = useState<AlertsHistory>({
isLoadingAlertsHistory: true,
});
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
const loadRuleAlertsAgg = useCallback(async () => {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
try {
if (!http) throw new Error('No http client');
if (!featureIds || !featureIds.length) throw new Error('No featureIds');
const { totalTriggeredAlerts, histogramTriggeredAlerts, error, avgTimeToRecoverUS } =
await fetchTriggeredAlertsHistory({
featureIds,
http,
ruleId,
signal: abortCtrlRef.current.signal,
dateRange,
});
if (error) throw error;
if (!isCancelledRef.current) {
setTriggeredAlertsHistory((oldState: AlertsHistory) => ({
...oldState,
alertsHistory: {
totalTriggeredAlerts,
histogramTriggeredAlerts,
avgTimeToRecoverUS,
},
isLoadingAlertsHistory: false,
}));
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
setTriggeredAlertsHistory((oldState: AlertsHistory) => ({
...oldState,
isLoadingAlertsHistory: false,
errorAlertHistory: error,
alertsHistory: undefined,
}));
}
}
}
}, [dateRange, featureIds, http, ruleId]);
useEffect(() => {
loadRuleAlertsAgg();
}, [loadRuleAlertsAgg]);
return triggeredAlertsHistory;
}
export async function fetchTriggeredAlertsHistory({
featureIds,
http,
ruleId,
signal,
dateRange,
}: {
featureIds: ValidFeatureId[];
http: HttpSetup;
ruleId: string;
signal: AbortSignal;
dateRange: {
from: string;
to: string;
};
}): Promise<FetchAlertsHistory> {
try {
const res = await http.post<AsApiContract<any>>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
signal,
body: JSON.stringify({
size: 0,
feature_ids: featureIds,
query: {
bool: {
must: [
{
term: {
[ALERT_RULE_UUID]: ruleId,
},
},
{
range: {
[ALERT_TIME_RANGE]: dateRange,
},
},
],
},
},
aggs: {
histogramTriggeredAlerts: {
date_histogram: {
field: ALERT_START,
fixed_interval: '1d',
extended_bounds: {
min: dateRange.from,
max: dateRange.to,
},
},
},
avgTimeToRecoverUS: {
filter: {
term: {
[ALERT_STATUS]: 'recovered',
},
},
aggs: {
recoveryTime: {
avg: {
field: ALERT_DURATION,
},
},
},
},
},
}),
});
const totalTriggeredAlerts = res?.hits.total.value;
const histogramTriggeredAlerts = res?.aggregations?.histogramTriggeredAlerts.buckets;
const avgTimeToRecoverUS = res?.aggregations?.avgTimeToRecoverUS.recoveryTime.value;
return {
totalTriggeredAlerts,
histogramTriggeredAlerts,
avgTimeToRecoverUS,
};
} catch (error) {
return {
error,
totalTriggeredAlerts: 0,
histogramTriggeredAlerts: [],
avgTimeToRecoverUS: 0,
};
}
}

View file

@ -58,7 +58,9 @@
"@kbn/cases-plugin",
"@kbn/shared-ux-prompt-not-found",
"@kbn/shared-ux-router",
"@kbn/shared-ux-link-redirect-app"
"@kbn/shared-ux-link-redirect-app",
"@kbn/actions-plugin",
"@kbn/ui-theme"
],
"exclude": ["target/**/*"]
}