[Actionable Observability] [ResponseOps] - Create and add Rule Alerts Summary as a sharable component to the O11y Rule Details (#135805)

* Add useLoadRuleAlertsAggs hook

* Update hook

* Make RuleAlertsSummary sharable

* Like RuleAlertsSummary component with useLoadRuleAlertsAggs hook

* Add date_histogram to the hook

* Update layout

* Provide 0 as default value when there is no recovered or active alerts

* Update style

* Fix style

* Fix style rule details page

* Add OBSERVABILITY_SOLUTIONS filter

* Update naming filteredRuleTypes

* Add alerts aggs chart data

* Always return active and recovered

* Update the query/aggs

* pair programing to get the bar series working with date

* Add correct color to the chart

* WIP

* Style the chart correctly

* Update x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alerts_summary.tsx

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>

* Update x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alerts_summary.tsx

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>

* Update x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alerts_summary.tsx

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>

* Update x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alerts_summary.tsx

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>

* Update x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alerts_summary.tsx

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>

* Remove duplicated copyrights

* Code review update component structure

* Fix import error

* Remove OBSERVABILITY_SOLUTIONS

* Code review

* No more needed as the aggs is changed

* Fix import

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Faisal Kanout 2022-07-25 23:14:53 +02:00 committed by GitHub
parent d3ae221bed
commit 546f2b158b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 481 additions and 17 deletions

View file

@ -50,7 +50,6 @@ import { observabilityFeatureId } from '../../../common';
import { ALERT_STATUS_LICENSE_ERROR, rulesStatusesTranslationsMapping } from './translations';
import { ObservabilityAppServices } from '../../application/types';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
export function RuleDetailsPage() {
const {
cases,
@ -61,6 +60,7 @@ export function RuleDetailsPage() {
getEditAlertFlyout,
getRuleEventLogList,
getAlertsStateTable,
getRuleAlertsSummary,
getRuleStatusPanel,
getRuleDefinition,
},
@ -154,7 +154,6 @@ export function RuleDetailsPage() {
: false);
const userPermissions = useGetUserCasesPermissions();
const alertStateProps = {
cases: {
ui: cases.ui,
@ -304,21 +303,24 @@ export function RuleDetailsPage() {
}}
>
<EuiFlexGroup wrap={true} gutterSize="m">
{/* Left side of Rule Summary */}
{getRuleStatusPanel({
rule,
isEditable: hasEditButton,
requestRefresh: reloadRule,
healthColor: getHealthColor(rule.executionStatus.status),
statusMessage,
})}
{/* Right side of Rule Summary */}
{getRuleDefinition({
filteredRuleTypes,
rule,
onEditRule: () => reloadRule(),
} as RuleDefinitionProps)}
<EuiFlexItem style={{ minWidth: 350 }}>
{getRuleStatusPanel({
rule,
isEditable: hasEditButton,
requestRefresh: reloadRule,
healthColor: getHealthColor(rule.executionStatus.status),
statusMessage,
})}
</EuiFlexItem>
<EuiSpacer size="m" />
<EuiFlexItem style={{ minWidth: 350 }}>
{getRuleAlertsSummary({
rule,
filteredRuleTypes,
})}
</EuiFlexItem>
<EuiSpacer size="m" />
{getRuleDefinition({ rule, onEditRule: () => reloadRule() } as RuleDefinitionProps)}
</EuiFlexGroup>
<EuiSpacer size="l" />

View file

@ -0,0 +1,210 @@
/*
* 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 { useEffect, useState, useCallback, useRef } from 'react';
import { AsApiContract } from '@kbn/actions-plugin/common';
import { HttpSetup } from '@kbn/core/public';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants';
import { useKibana } from '../../common/lib/kibana';
import { AlertChartData } from '../sections/rule_details/components/alert_summary';
interface UseLoadRuleAlertsAggs {
features: string;
ruleId: string;
}
interface RuleAlertsAggs {
active: number;
recovered: number;
error?: string;
}
interface LoadRuleAlertsAggs {
isLoadingRuleAlertsAggs: boolean;
ruleAlertsAggs: {
active: number;
recovered: number;
};
errorRuleAlertsAggs?: string;
alertsChartData: AlertChartData[];
}
interface IndexName {
index: string;
}
export function useLoadRuleAlertsAggs({ features, ruleId }: UseLoadRuleAlertsAggs) {
const { http } = useKibana().services;
const [ruleAlertsAggs, setRuleAlertsAggs] = useState<LoadRuleAlertsAggs>({
isLoadingRuleAlertsAggs: true,
ruleAlertsAggs: { active: 0, recovered: 0 },
alertsChartData: [],
});
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 (!features) return;
const { index } = await fetchIndexNameAPI({
http,
features,
});
const { active, recovered, error, alertsChartData } = await fetchRuleAlertsAggByTimeRange({
http,
index,
ruleId,
signal: abortCtrlRef.current.signal,
});
if (error) throw error;
if (!isCancelledRef.current) {
setRuleAlertsAggs((oldState: LoadRuleAlertsAggs) => ({
...oldState,
ruleAlertsAggs: {
active,
recovered,
},
alertsChartData,
isLoadingRuleAlertsAggs: false,
}));
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
setRuleAlertsAggs((oldState: LoadRuleAlertsAggs) => ({
...oldState,
isLoadingRuleAlertsAggs: false,
errorRuleAlertsAggs: 'error',
alertsChartData: [],
}));
}
}
}
}, [http, features, ruleId]);
useEffect(() => {
loadRuleAlertsAgg();
}, [loadRuleAlertsAgg]);
return ruleAlertsAggs;
}
export async function fetchIndexNameAPI({
http,
features,
}: {
http: HttpSetup;
features: string;
}): Promise<IndexName> {
const res = await http.get<{ index_name: string[] }>(`${BASE_RAC_ALERTS_API_PATH}/index`, {
query: { features },
});
return {
index: res.index_name[0],
};
}
interface RuleAlertsAggs {
active: number;
recovered: number;
error?: string;
alertsChartData: AlertChartData[];
}
interface BucketAggsPerDay {
key_as_string: string;
doc_count: number;
}
export async function fetchRuleAlertsAggByTimeRange({
http,
index,
ruleId,
signal,
}: {
http: HttpSetup;
index: string;
ruleId: string;
signal: AbortSignal;
}): Promise<RuleAlertsAggs> {
try {
const res = await http.post<AsApiContract<any>>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
signal,
body: JSON.stringify({
index,
size: 0,
query: {
bool: {
must: [
{
term: {
'kibana.alert.rule.uuid': ruleId,
},
},
{
range: {
'@timestamp': {
// When needed, we can make this range configurable via a function argument.
gte: 'now-30d',
lt: 'now',
},
},
},
],
},
},
aggs: {
filterAggs: {
filters: {
filters: {
alert_active: { term: { 'kibana.alert.status': 'active' } },
alert_recovered: { term: { 'kibana.alert.status': 'recovered' } },
},
},
aggs: {
status_per_day: {
date_histogram: {
field: '@timestamp',
fixed_interval: '1d',
},
},
},
},
},
}),
});
const active = res?.aggregations?.filterAggs.buckets.alert_active?.doc_count ?? 0;
const recovered = res?.aggregations?.filterAggs.buckets.alert_recovered?.doc_count ?? 0;
const alertsChartData = [
...res?.aggregations?.filterAggs.buckets.alert_active.status_per_day.buckets.map(
(bucket: BucketAggsPerDay) => ({
date: bucket.key_as_string,
status: 'active',
count: bucket.doc_count,
})
),
...res?.aggregations?.filterAggs.buckets.alert_recovered.status_per_day.buckets.map(
(bucket: BucketAggsPerDay) => ({
date: bucket.key_as_string,
status: 'recovered',
count: bucket.doc_count,
})
),
];
return {
active,
recovered,
alertsChartData,
};
} catch (error) {
return {
error,
active: 0,
recovered: 0,
alertsChartData: [],
} as RuleAlertsAggs;
}
}

View file

@ -55,6 +55,9 @@ export const RuleDefinition = suspendedComponentWithProps(
export const RuleTagBadge = suspendedComponentWithProps(
lazy(() => import('./rules_list/components/rule_tag_badge'))
);
export const RuleAlertsSummary = suspendedComponentWithProps(
lazy(() => import('./rule_details/components/alert_summary/rule_alerts_summary'))
);
export const RuleStatusPanel = suspendedComponentWithProps(
lazy(() => import('./rule_details/components/rule_status_panel'))
);

View file

@ -0,0 +1,29 @@
/*
* 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 { LIGHT_THEME, XYChartSeriesIdentifier } from '@elastic/charts';
import { AlertChartData } from './types';
export const formatChartAlertData = (
data: AlertChartData[]
): Array<{ x: string; y: number; g: string }> =>
data.map((alert) => ({
x: alert.date,
y: alert.count,
g: alert.status,
}));
export const getColorSeries = ({ seriesKeys }: XYChartSeriesIdentifier) => {
switch (seriesKeys[0]) {
case 'active':
return LIGHT_THEME.colors.vizColors[1];
case 'recovered':
return LIGHT_THEME.colors.vizColors[2];
default:
return null;
}
};

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export { RuleAlertsSummary } from './rule_alerts_summary';
export type { RuleAlertsSummaryProps, AlertChartData, AlertsChartProps } from './types';
export { formatChartAlertData, getColorSeries } from './helpers';

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 { BarSeries, Chart, ScaleType, Settings, PartialTheme, TooltipType } from '@elastic/charts';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLoadingSpinner,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useLoadRuleAlertsAggs } from '../../../../hooks/use_load_rule_alerts_aggregations';
import { useLoadRuleTypes } from '../../../../hooks/use_load_rule_types';
import { formatChartAlertData, getColorSeries } from '.';
import { RuleAlertsSummaryProps } from '.';
const theme: PartialTheme = {
chartMargins: {
bottom: 0,
left: 0,
top: 20,
right: 0,
},
chartPaddings: {
bottom: 0,
left: 0,
top: 0,
right: 0,
},
};
const Y_ACCESSORS = ['y'];
const X_ACCESSORS = ['x'];
const G_ACCESSORS = ['g'];
export const RuleAlertsSummary = ({ rule, filteredRuleTypes }: RuleAlertsSummaryProps) => {
const [features, setFeatures] = useState<string>('');
const { ruleTypes } = useLoadRuleTypes({
filteredRuleTypes,
});
const {
ruleAlertsAggs: { active, recovered },
isLoadingRuleAlertsAggs,
errorRuleAlertsAggs,
alertsChartData,
} = useLoadRuleAlertsAggs({
ruleId: rule.id,
features,
});
const chartData = useMemo(() => formatChartAlertData(alertsChartData), [alertsChartData]);
useEffect(() => {
const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId);
if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) {
setFeatures(matchedRuleType.producer);
} else setFeatures(rule.consumer);
}, [rule, ruleTypes]);
if (isLoadingRuleAlertsAggs) return <EuiLoadingSpinner />;
if (errorRuleAlertsAggs) return <EuiFlexItem>Error</EuiFlexItem>;
return (
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h5>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.title"
defaultMessage="Alerts"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiPanel hasShadow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.allAlertsLabel"
defaultMessage="All alerts"
/>
</EuiText>
<EuiText>
<h4>{active + recovered}</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.activeLabel"
defaultMessage="Active"
/>
</EuiText>
<EuiText color="#4A7194">
<h4>{active}</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel"
defaultMessage="Recovered"
/>
</EuiText>
<EuiFlexItem>
<EuiText color="#C4407C">
<h4>{recovered}</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiPanel>
<EuiHorizontalRule margin="none" />
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h5>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.recentAlertHistoryTitle"
defaultMessage="Recent alert history"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<Chart size={['100%', '35%']}>
<Settings tooltip={TooltipType.None} theme={theme} />
<BarSeries
id="bars"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={Y_ACCESSORS}
stackAccessors={X_ACCESSORS}
splitSeriesAccessors={G_ACCESSORS}
color={getColorSeries}
data={chartData}
/>
</Chart>
</EuiPanel>
);
};
// eslint-disable-next-line import/no-default-export
export { RuleAlertsSummary as default };

View file

@ -0,0 +1,22 @@
/*
* 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 { Rule } from '../../../../../types';
export interface RuleAlertsSummaryProps {
rule: Rule;
filteredRuleTypes: string[];
}
export interface AlertChartData {
status: 'active' | 'recovered';
count: number;
date: string;
}
export interface AlertsChartProps {
data: AlertChartData[];
}

View file

@ -0,0 +1,14 @@
/*
* 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 { RuleAlertsSummary } from '../application/sections';
import { RuleAlertsSummaryProps } from '../application/sections/rule_details/components/alert_summary';
export const getRuleAlertsSummaryLazy = (props: RuleAlertsSummaryProps) => {
return <RuleAlertsSummary {...props} />;
};

View file

@ -40,6 +40,7 @@ import { EditConnectorFlyoutProps } from './application/sections/action_connecto
import { getActionFormLazy } from './common/get_action_form';
import { ActionAccordionFormProps } from './application/sections/action_connector_form/action_form';
import { getFieldBrowserLazy } from './common/get_field_browser';
import { getRuleAlertsSummaryLazy } from './common/get_rule_alerts_summary';
import { getRuleDefinitionLazy } from './common/get_rule_definition';
import { getRuleStatusPanelLazy } from './common/get_rule_status_panel';
@ -114,6 +115,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart {
rulesListProps: {},
});
},
getRuleAlertsSummary: (props) => {
return getRuleAlertsSummaryLazy(props);
},
getRuleDefinition: (props) => {
return getRuleDefinitionLazy({ ...props, actionTypeRegistry, ruleTypeRegistry });
},

View file

@ -76,6 +76,8 @@ import { ActionAccordionFormProps } from './application/sections/action_connecto
import type { FieldBrowserProps } from './application/sections/field_browser/types';
import { getRuleDefinitionLazy } from './common/get_rule_definition';
import { RuleStatusPanelProps } from './application/sections/rule_details/components/rule_status_panel';
import { RuleAlertsSummaryProps } from './application/sections/rule_details/components/alert_summary';
import { getRuleAlertsSummaryLazy } from './common/get_rule_alerts_summary';
export interface TriggersAndActionsUIPublicPluginSetup {
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
@ -118,6 +120,7 @@ export interface TriggersAndActionsUIPublicPluginStart {
) => ReactElement<RulesListNotifyBadgeProps>;
getRuleDefinition: (props: RuleDefinitionProps) => ReactElement<RuleDefinitionProps>;
getRuleStatusPanel: (props: RuleStatusPanelProps) => ReactElement<RuleStatusPanelProps>;
getRuleAlertsSummary: (props: RuleAlertsSummaryProps) => ReactElement<RuleAlertsSummaryProps>;
}
interface PluginsSetup {
@ -351,6 +354,9 @@ export class Plugin
getRuleStatusPanel: (props: RuleStatusPanelProps) => {
return getRuleStatusPanelLazy(props);
},
getRuleAlertsSummary: (props: RuleAlertsSummaryProps) => {
return getRuleAlertsSummaryLazy(props);
},
};
}