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/**/*"]
}