[AO] Add charts to Alert Summary Widget (#148143)

Resolves #147752

## 📝 Summary

Add charts to Alert Summary Widget

|Dark theme|Light Theme|Storybook|
|---|---|---|

|![image](209846554-49499388-1253-4f38-9b99-9b7c59809902.png)|

## 🧪 How to test
1. Create a rule that generates alerts, check the Alert Summary Widget
component on the rule details page
2. Or just check the
[storybook](https://ci-artifacts.kibana.dev/storybooks/pr-148143/4cbef9ea90dac5b6858e64979f1a4b96e8523a7a/triggers_actions_ui/index.html?path=/story/app-alertssummarywidgetui--overview)
This commit is contained in:
Maryam Saeidi 2022-12-29 12:02:12 +01:00 committed by GitHub
parent f115807ce0
commit 7875f0c348
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 290 additions and 155 deletions

View file

@ -372,7 +372,7 @@ export function RuleDetailsPage() {
: [],
}}
>
<EuiFlexGroup wrap={true} gutterSize="m">
<EuiFlexGroup wrap gutterSize="m">
<EuiFlexItem style={{ minWidth: 350 }}>
{getRuleStatusPanel({
rule,
@ -382,17 +382,15 @@ export function RuleDetailsPage() {
statusMessage,
})}
</EuiFlexItem>
<EuiSpacer size="m" />
<EuiFlexItem style={{ minWidth: 350 }}>
<AlertSummaryWidget
featureIds={featureIds}
onClick={(status) => onAlertSummaryWidgetClick(status)}
onClick={onAlertSummaryWidgetClick}
timeRange={defaultAlertTimeRange}
filter={alertSummaryWidgetFilter.current}
/>
</EuiFlexItem>
<EuiSpacer size="m" />
{getRuleDefinition({ rule, onEditRule: () => reloadRule() } as RuleDefinitionProps)}
{getRuleDefinition({ rule, onEditRule: reloadRule } as RuleDefinitionProps)}
</EuiFlexGroup>
<EuiSpacer size="l" />

View file

@ -35271,7 +35271,6 @@
"xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status": "Statut",
"xpack.triggersActionsUI.sections.ruleDetails.alertsList.ruleTypeExcessDurationMessage": "La durée dépasse le temps d'exécution attendu de la règle.",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.activeLabel": "Actuellement actives",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.allAlertsLabel": "Tous",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.errorLoadingBody": "Une erreur s'est produite lors du chargement du récapitulatif des alertes. Contactez votre administrateur pour obtenir de l'aide.",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.errorLoadingTitle": "Impossible de charger le récapitulatif des alertes",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.title": "Alertes",

View file

@ -35240,7 +35240,6 @@
"xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status": "ステータス",
"xpack.triggersActionsUI.sections.ruleDetails.alertsList.ruleTypeExcessDurationMessage": "期間がルールの想定実行時間を超えています。",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.activeLabel": "現在アクティブ",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.allAlertsLabel": "すべて",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.errorLoadingBody": "アラート概要の読み込みエラーが発生しました。ヘルプについては、管理者にお問い合わせください。",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.errorLoadingTitle": "アラート概要を読み込めません",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.title": "アラート",

View file

@ -35276,7 +35276,6 @@
"xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status": "状态",
"xpack.triggersActionsUI.sections.ruleDetails.alertsList.ruleTypeExcessDurationMessage": "持续时间超出了规则的预期运行时间。",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.activeLabel": "当前处于活动状态",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.allAlertsLabel": "全部",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.errorLoadingBody": "加载告警摘要时出现错误。请联系您的管理员寻求帮助。",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.errorLoadingTitle": "无法加载告警摘要",
"xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.title": "告警",

View file

@ -28,7 +28,7 @@ describe('useLoadAlertSummary', () => {
it('should return the mocked data from API', async () => {
mockedPostAPI.mockResolvedValue({
...mockAlertSummaryResponse(),
...mockAlertSummaryResponse,
});
const { result, waitForNextUpdate } = renderHook(() =>
@ -39,15 +39,19 @@ describe('useLoadAlertSummary', () => {
);
expect(result.current).toEqual({
isLoading: true,
alertSummary: { active: 0, recovered: 0 },
alertSummary: {
activeAlertCount: 0,
activeAlerts: [],
recoveredAlertCount: 0,
recoveredAlerts: [],
},
});
await waitForNextUpdate();
const { alertSummary, error } = result.current;
expect(alertSummary).toEqual({
active: 1,
recovered: 1,
...mockAlertSummaryResponse,
});
expect(error).toBeFalsy();
});
@ -61,7 +65,7 @@ describe('useLoadAlertSummary', () => {
},
};
mockedPostAPI.mockResolvedValue({
...mockAlertSummaryResponse(),
...mockAlertSummaryResponse,
});
const { waitForNextUpdate } = renderHook(() =>

View file

@ -21,6 +21,11 @@ export interface AlertSummaryTimeRange {
title: JSX.Element | string;
}
export interface Alert {
key: number;
doc_count: number;
}
interface UseLoadAlertSummaryProps {
featureIds?: ValidFeatureId[];
timeRange: AlertSummaryTimeRange;
@ -29,18 +34,14 @@ interface UseLoadAlertSummaryProps {
interface AlertSummary {
activeAlertCount: number;
activeAlerts?: object[];
activeAlerts: Alert[];
recoveredAlertCount: number;
recoveredAlerts?: object[];
error?: string;
recoveredAlerts: Alert[];
}
interface LoadAlertSummaryResponse {
isLoading: boolean;
alertSummary: {
active: number;
recovered: number;
};
alertSummary: AlertSummary;
error?: string;
}
@ -48,7 +49,12 @@ export function useLoadAlertSummary({ featureIds, timeRange, filter }: UseLoadAl
const { http } = useKibana().services;
const [alertSummary, setAlertSummary] = useState<LoadAlertSummaryResponse>({
isLoading: true,
alertSummary: { active: 0, recovered: 0 },
alertSummary: {
activeAlertCount: 0,
activeAlerts: [],
recoveredAlertCount: 0,
recoveredAlerts: [],
},
});
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
@ -59,20 +65,23 @@ export function useLoadAlertSummary({ featureIds, timeRange, filter }: UseLoadAl
abortCtrlRef.current = new AbortController();
try {
const { activeAlertCount, recoveredAlertCount, error } = await fetchAlertSummary({
featureIds,
filter,
http,
signal: abortCtrlRef.current.signal,
timeRange,
});
if (error) throw error;
const { activeAlertCount, activeAlerts, recoveredAlertCount, recoveredAlerts } =
await fetchAlertSummary({
featureIds,
filter,
http,
signal: abortCtrlRef.current.signal,
timeRange,
});
if (!isCancelledRef.current) {
setAlertSummary((oldState) => ({
...oldState,
alertSummary: {
active: activeAlertCount,
recovered: recoveredAlertCount,
activeAlertCount,
activeAlerts,
recoveredAlertCount,
recoveredAlerts,
},
isLoading: false,
}));
@ -110,30 +119,26 @@ async function fetchAlertSummary({
timeRange: AlertSummaryTimeRange;
filter?: estypes.QueryDslQueryContainer;
}): Promise<AlertSummary> {
try {
const res = await http.post<AsApiContract<any>>(`${BASE_RAC_ALERTS_API_PATH}/_alert_summary`, {
signal,
body: JSON.stringify({
fixed_interval: fixedInterval,
gte: utcFrom,
lte: utcTo,
featureIds,
filter: [filter],
}),
});
const res = await http.post<AsApiContract<any>>(`${BASE_RAC_ALERTS_API_PATH}/_alert_summary`, {
signal,
body: JSON.stringify({
fixed_interval: fixedInterval,
gte: utcFrom,
lte: utcTo,
featureIds,
filter: [filter],
}),
});
const activeAlertCount = res?.activeAlertCount ?? 0;
const recoveredAlertCount = res?.recoveredAlertCount ?? 0;
const activeAlertCount = res?.activeAlertCount ?? 0;
const activeAlerts = res?.activeAlerts ?? [];
const recoveredAlertCount = res?.recoveredAlertCount ?? 0;
const recoveredAlerts = res?.recoveredAlerts ?? [];
return {
activeAlertCount,
recoveredAlertCount,
};
} catch (error) {
return {
error,
activeAlertCount: 0,
recoveredAlertCount: 0,
};
}
return {
activeAlertCount,
activeAlerts,
recoveredAlertCount,
recoveredAlerts,
};
}

View file

@ -69,19 +69,17 @@ export const mockRule = (): Rule => {
};
};
export const mockAlertSummaryResponse = () => {
return {
activeAlertCount: 1,
recoveredAlertCount: 1,
activeAlerts: [
{ key_as_string: '1671321600000', key: 1671321600000, doc_count: 0 },
{ key_as_string: '1671408000000', key: 1671408000000, doc_count: 2 },
],
recoveredAlerts: [
{ key_as_string: '2022-12-18T00:00:00.000Z', key: 1671321600000, doc_count: 0 },
{ key_as_string: '2022-12-19T00:00:00.000Z', key: 1671408000000, doc_count: 1 },
],
};
export const mockAlertSummaryResponse = {
activeAlertCount: 1,
recoveredAlertCount: 1,
activeAlerts: [
{ key: 1671321600000, doc_count: 0 },
{ key: 1671408000000, doc_count: 2 },
],
recoveredAlerts: [
{ key: 1671321600000, doc_count: 0 },
{ key: 1671408000000, doc_count: 1 },
],
};
export const mockAlertSummaryTimeRange: AlertSummaryTimeRange = {

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { nextTick } from '@kbn/test-jest-helpers';
@ -18,11 +19,22 @@ jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
jest.mock('../../../../hooks/use_load_alert_summary', () => ({
useLoadAlertSummary: jest.fn().mockReturnValue({
alertSummary: { active: 1, recovered: 7 },
alertSummary: {
activeAlertCount: 1,
recoveredAlertCount: 7,
activeAlerts: [
{ key: 1671321600000, doc_count: 0 },
{ key: 1671408000000, doc_count: 1 },
],
recoveredAlerts: [
{ key: 1671321600000, doc_count: 2 },
{ key: 1671408000000, doc_count: 5 },
],
},
}),
}));
describe('Rule Alert Summary', () => {
describe('Alert Summary Widget', () => {
let wrapper: ReactWrapper;
const mockedTimeRange = {
...mockAlertSummaryTimeRange,
@ -52,7 +64,7 @@ describe('Rule Alert Summary', () => {
it('should show zeros for all alerts counters', async () => {
expect(wrapper.find('[data-test-subj="activeAlertsCount"]').text()).toEqual('1');
expect(wrapper.find('[data-test-subj="recoveredAlertsCount"]').text()).toBe('7');
expect(wrapper.find('[data-test-subj="totalAlertsCount"]').text()).toBe('8');
expect(wrapper.find('[data-test-subj="totalAlertsCount"]').at(1).text()).toBe('Alerts (8)');
expect(wrapper.find('[data-test-subj="mockedTimeRangeTitle"]').text()).toBe(
'mockedTimeRangeTitle'
);

View file

@ -18,7 +18,7 @@ export const AlertSummaryWidget = ({
timeRange,
}: AlertSummaryWidgetProps) => {
const {
alertSummary: { active, recovered },
alertSummary: { activeAlertCount, activeAlerts, recoveredAlertCount, recoveredAlerts },
isLoading,
error,
} = useLoadAlertSummary({
@ -32,9 +32,11 @@ export const AlertSummaryWidget = ({
return (
<AlertsSummaryWidgetUI
active={active}
activeAlertCount={activeAlertCount}
activeAlerts={activeAlerts}
onClick={onClick}
recovered={recovered}
recoveredAlertCount={recoveredAlertCount}
recoveredAlerts={recoveredAlerts}
timeRangeTitle={timeRange.title}
/>
);

View file

@ -0,0 +1,86 @@
/*
* 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 { Chart, CurveType, LineSeries, ScaleType, Settings } from '@elastic/charts';
import { Color } from '@elastic/charts/dist/common/colors';
import { ColorVariant } from '@elastic/charts/dist/utils/common';
import { EuiFlexGroup, EuiFlexItem, EuiListGroupItemProps, EuiText } from '@elastic/eui';
import {
EUI_CHARTS_THEME_DARK,
EUI_CHARTS_THEME_LIGHT,
EUI_SPARKLINE_THEME_PARTIAL,
} from '@elastic/eui/dist/eui_charts_theme';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
import { euiLightVars, euiDarkVars } from '@kbn/ui-theme';
import React from 'react';
import { Alert } from '../../../../../hooks/use_load_alert_summary';
interface AlertChartProps {
count: number;
data: Alert[];
dataTestSubj: string;
id: string;
stroke: Color | ColorVariant;
title: EuiListGroupItemProps['label'];
}
export const AlertStateInfo = ({
count,
data,
dataTestSubj,
id,
stroke,
title,
}: AlertChartProps) => {
const isDarkMode = useUiSetting<boolean>('theme:darkMode');
const textColor = isDarkMode ? euiDarkVars.euiTextColor : euiLightVars.euiTextColor;
const theme = [
EUI_SPARKLINE_THEME_PARTIAL,
{
...(isDarkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme),
chartMargins: {
left: 10,
right: 10,
top: 10,
bottom: 10,
},
},
];
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={1} style={{ minWidth: '70px' }}>
<EuiText color={textColor}>
<h3 data-test-subj={dataTestSubj}>{count}</h3>
</EuiText>
<EuiText size="s" color="subdued">
{title}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<Chart size={{ height: 50 }}>
<Settings theme={theme} tooltip={{ type: 'none' }} />
<LineSeries
id={id}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="key"
yAccessors={['doc_count']}
data={data}
lineSeriesStyle={{
line: {
strokeWidth: 2,
stroke,
},
}}
curve={CurveType.CURVE_MONOTONE_X}
/>
</Chart>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -15,8 +15,44 @@ export default {
export const Overview = {
args: {
active: 15,
recovered: 53,
activeAlertCount: 94,
activeAlerts: [
{ key: 1671108000000, doc_count: 0 },
{ key: 1671208000000, doc_count: 0 },
{ key: 1671308000000, doc_count: 0 },
{ key: 1671408000000, doc_count: 2 },
{ key: 1671508000000, doc_count: 4 },
{ key: 1671608000000, doc_count: 5 },
{ key: 1671708000000, doc_count: 3 },
{ key: 1671808000000, doc_count: 6 },
{ key: 1671908000000, doc_count: 14 },
{ key: 1672008000000, doc_count: 15 },
{ key: 1672108000000, doc_count: 15 },
{ key: 1672208000000, doc_count: 10 },
{ key: 1672308000000, doc_count: 9 },
{ key: 1672408000000, doc_count: 7 },
{ key: 1672508000000, doc_count: 2 },
{ key: 1672608000000, doc_count: 2 },
],
recoveredAlertCount: 15,
recoveredAlerts: [
{ key: 1671108000000, doc_count: 0 },
{ key: 1671208000000, doc_count: 0 },
{ key: 1671308000000, doc_count: 0 },
{ key: 1671408000000, doc_count: 0 },
{ key: 1671508000000, doc_count: 0 },
{ key: 1671608000000, doc_count: 0 },
{ key: 1671708000000, doc_count: 2 },
{ key: 1671808000000, doc_count: 0 },
{ key: 1671908000000, doc_count: 0 },
{ key: 1672008000000, doc_count: 0 },
{ key: 1672108000000, doc_count: 0 },
{ key: 1672208000000, doc_count: 5 },
{ key: 1672308000000, doc_count: 1 },
{ key: 1672408000000, doc_count: 2 },
{ key: 1672508000000, doc_count: 5 },
{ key: 1672608000000, doc_count: 0 },
],
timeRangeTitle: 'Last 30 days',
onClick: action('clicked'),
},

View file

@ -5,8 +5,7 @@
* 2.0.
*/
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, AlertStatus } from '@kbn/rule-data-utils';
import { euiLightVars } from '@kbn/ui-theme';
import React, { MouseEvent } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -16,13 +15,16 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import React, { MouseEvent } from 'react';
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, AlertStatus } from '@kbn/rule-data-utils';
import { FormattedMessage } from '@kbn/i18n-react';
import { AlertStateInfo } from './alert_state_chart';
import { AlertsSummaryWidgetUIProps } from './types';
export const AlertsSummaryWidgetUI = ({
active,
recovered,
activeAlertCount,
activeAlerts,
recoveredAlertCount,
recoveredAlerts,
timeRangeTitle,
onClick,
}: AlertsSummaryWidgetUIProps) => {
@ -45,79 +47,71 @@ export const AlertsSummaryWidgetUI = ({
onClick={handleClick}
>
<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>
{!!timeRangeTitle && (
<>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued" data-test-subj="timeRangeTitle">
{timeRangeTitle}
</EuiText>
</>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xxs">
<h5 data-test-subj="totalAlertsCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.title"
defaultMessage="Alerts"
/>
&nbsp;({activeAlertCount + recoveredAlertCount})
</h5>
</EuiTitle>
{!!timeRangeTitle && (
<>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued" data-test-subj="timeRangeTitle">
{timeRangeTitle}
</EuiText>
</>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem>
<EuiLink onClick={handleClick}>
<EuiText color={euiLightVars.euiTextColor}>
<h3 data-test-subj="totalAlertsCount">{active + recovered}</h3>
</EuiText>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.allAlertsLabel"
defaultMessage="All"
/>
</EuiText>
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink
onClick={(event: React.MouseEvent<HTMLAnchorElement>) =>
handleClick(event, ALERT_STATUS_ACTIVE)
}
>
<EuiText color={euiLightVars.euiColorDangerText}>
<h3 data-test-subj="activeAlertsCount">{active}</h3>
</EuiText>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.activeLabel"
defaultMessage="Currently active"
/>
</EuiText>
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink
onClick={(event: React.MouseEvent<HTMLAnchorElement>) =>
handleClick(event, ALERT_STATUS_RECOVERED)
}
>
<EuiFlexItem>
<EuiText color={euiLightVars.euiColorSuccessText}>
<h3 data-test-subj="recoveredAlertsCount">{recovered}</h3>
</EuiText>
</EuiFlexItem>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel"
defaultMessage="Recovered"
/>
</EuiText>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGroup wrap>
{/* Active */}
<EuiFlexItem style={{ minWidth: '200px' }}>
<EuiLink
onClick={(event: React.MouseEvent<HTMLAnchorElement>) =>
handleClick(event, ALERT_STATUS_ACTIVE)
}
>
<AlertStateInfo
count={activeAlertCount}
data={activeAlerts}
dataTestSubj="activeAlertsCount"
id="active"
stroke="#E7664C"
title={
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.activeLabel"
defaultMessage="Active"
/>
}
/>
</EuiLink>
</EuiFlexItem>
{/* Recovered */}
<EuiFlexItem style={{ minWidth: '200px' }}>
<EuiLink
onClick={(event: React.MouseEvent<HTMLAnchorElement>) =>
handleClick(event, ALERT_STATUS_RECOVERED)
}
>
<AlertStateInfo
count={recoveredAlertCount}
data={recoveredAlerts}
dataTestSubj="recoveredAlertsCount"
id="recovered"
stroke="#54B399"
title={
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel"
defaultMessage="Recovered"
/>
}
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -6,10 +6,13 @@
*/
import { AlertStatus } from '@kbn/rule-data-utils';
import { Alert } from '../../../../../hooks/use_load_alert_summary';
export interface AlertsSummaryWidgetUIProps {
active: number;
recovered: number;
activeAlertCount: number;
activeAlerts: Alert[];
recoveredAlertCount: number;
recoveredAlerts: Alert[];
timeRangeTitle: JSX.Element | string;
onClick: (status?: AlertStatus) => void;
}