mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
662507b91e
commit
64d53bde6e
6 changed files with 416 additions and 6 deletions
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
186
x-pack/plugins/infra/public/hooks/use_alerts_history.ts
Normal file
186
x-pack/plugins/infra/public/hooks/use_alerts_history.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue