diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/components/alert_annotation.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/components/alert_annotation.tsx new file mode 100644 index 0000000000000..0d05b82a8b93d --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/components/alert_annotation.tsx @@ -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 ( + } + markerPosition={Position.Top} + /> + ); +} diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/components/logs_history_chart.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/components/logs_history_chart.tsx new file mode 100644 index 0000000000000..5857bdaba8b48 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/components/logs_history_chart.tsx @@ -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; + alert: TopAlert>; +}) => { + // 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 ( + + + + +

+ {i18n.translate('xpack.infra.logs.alertDetails.chartHistory.chartTitle', { + defaultMessage: 'Logs threshold alerts history', + })} +

+
+
+ + + {i18n.translate('xpack.infra.logs.alertDetails.chartHistory.last30days', { + defaultMessage: 'Last 30 days', + })} + + +
+ + + + + + + +

{alertsHistory?.totalTriggeredAlerts || '-'}

+
+
+
+ + + {i18n.translate('xpack.infra.logs.alertDetails.chartHistory.alertsTriggered', { + defaultMessage: 'Alerts triggered', + })} + + +
+
+ + + + +

+ {alertsHistory?.avgTimeToRecoverUS + ? convertTo({ + unit: 'minutes', + microseconds: alertsHistory?.avgTimeToRecoverUS, + extended: true, + }).formatted + : '-'} +

+
+
+
+ + + {i18n.translate('xpack.infra.logs.alertDetails.chartHistory.avgTimeToRecover', { + defaultMessage: 'Avg time to recover', + })} + + +
+
+ + } + markerBody={(annotationData) => ( + <> + + + {annotationData.header} + + + + + )} + markerPosition={Position.Top} + />, + ]} + ruleParams={rule.params} + logViewReference={rule.params.logView} + chartCriterion={criteria as PartialCriterion} + showThreshold={true} + executionTimeRange={executionTimeRange} + /> +
+ ); +}; +// eslint-disable-next-line import/no-default-export +export default LogsHistoryChart; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx index bedd7a127394a..8158e2edb17e0 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx @@ -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 - - {rule.params.criteria.map((criteria) => { + + {rule.params.criteria.map((criteria, idx) => { const chartCriterion = criteria as PartialCriterion; return ( - + chartCriterion={chartCriterion} showThreshold={true} executionTimeRange={{ gte: rangeFrom, lte: rangeTo }} + annotations={[]} /> ); })} + {/* For now we show the history chart only if we have one criteria */} + {rule.params.criteria.length === 1 && ( + + + + )} ); }; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx index 01d96cebdad9b..849b029846fe3 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx @@ -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>; } export const CriterionPreview: React.FC = ({ @@ -67,6 +68,7 @@ export const CriterionPreview: React.FC = ({ logViewReference, showThreshold, executionTimeRange, + annotations, }) => { const chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset | null = useMemo(() => { const { field, comparator, value } = chartCriterion; @@ -111,6 +113,7 @@ export const CriterionPreview: React.FC = ({ chartAlertParams={chartAlertParams} showThreshold={showThreshold} executionTimeRange={executionTimeRange} + annotations={annotations} /> ); }; @@ -122,6 +125,7 @@ interface ChartProps { chartAlertParams: GetLogAlertsChartPreviewDataAlertParamsSubset; showThreshold: boolean; executionTimeRange?: ExecutionTimeRange; + annotations?: Array>; } const CriterionPreviewChart: React.FC = ({ @@ -131,6 +135,7 @@ const CriterionPreviewChart: React.FC = ({ chartAlertParams, showThreshold, executionTimeRange, + annotations, }) => { const { uiSettings } = useKibana().services; const isDarkMode = uiSettings?.get('theme:darkMode') || false; @@ -287,6 +292,7 @@ const CriterionPreviewChart: React.FC = ({ ]} /> ) : null} + {annotations} {showThreshold && threshold && isAbove ? ( ; + 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({ + 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 { + try { + const res = await http.post>(`${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, + }; + } +} diff --git a/x-pack/plugins/infra/tsconfig.json b/x-pack/plugins/infra/tsconfig.json index 5b826b80b981e..74d80b00d45e5 100644 --- a/x-pack/plugins/infra/tsconfig.json +++ b/x-pack/plugins/infra/tsconfig.json @@ -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/**/*"] }