Skip to content

Commit

Permalink
[Alert details page] Add related alerts tab to the alert details page (
Browse files Browse the repository at this point in the history
…#193263)

Closes #188014

## Summary

This PR adds a related alerts tab to the alert details page and saves
the filters in the URL. The logic for filtering alerts:

- Alerts that have tags similar to the existing alert (one or more)
- Alerts for similar group by/source fields ("or" logic)
- Either at the root level (e.g. host.hostname: 'host-1'), or have this
information available in `kibana.alert.group.value`.

By default, we filter to only show active alerts.

|Alert|Related alerts|
|---|---|

|![image](https://github.com/user-attachments/assets/2117d41b-85d6-4d09-836f-d0a0858f0f89)|![image](https://github.com/user-attachments/assets/df3cb061-122e-451c-94e5-1bbcda74cabc)|

|![image](https://github.com/user-attachments/assets/4308afdf-5940-49b6-80a3-bfe9de23c805)|![image](https://github.com/user-attachments/assets/43011507-1148-4188-aa12-f8692c0e6ccf)|

#### Follow-up work
I will create some follow-up tickets for these topics.
- Selecting `Rule name` as the default group by and opening the first
group
- Considering adding an event timeline to this tab
- Showing the number of active alerts in the tab

(cherry picked from commit 96dd4c7)
  • Loading branch information
maryam-saeidi committed Sep 20, 2024
1 parent 297ab0b commit 618c4d0
Show file tree
Hide file tree
Showing 16 changed files with 327 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ import {
alertSearchBarStateContainer,
Provider,
useAlertSearchBarStateContainer,
DEFAULT_STATE,
} from './containers';
import { ObservabilityAlertSearchBar } from './alert_search_bar';
import { AlertSearchBarWithUrlSyncProps } from './types';
import { useKibana } from '../../utils/kibana_react';
import { useToasts } from '../../hooks/use_toast';

function AlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) {
const { urlStorageKey, ...searchBarProps } = props;
const stateProps = useAlertSearchBarStateContainer(urlStorageKey);
const { urlStorageKey, defaultState = DEFAULT_STATE, ...searchBarProps } = props;
const stateProps = useAlertSearchBarStateContainer(urlStorageKey, undefined, defaultState);
const {
data: {
query: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
* 2.0.
*/

export { Provider, alertSearchBarStateContainer } from './state_container';
export { Provider, alertSearchBarStateContainer, DEFAULT_STATE } from './state_container';
export { useAlertSearchBarStateContainer } from './use_alert_search_bar_state_container';
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,7 @@ import {
} from '@kbn/kibana-utils-plugin/public';
import { AlertStatus } from '../../../../common/typings';
import { ALL_ALERTS } from '../constants';

interface AlertSearchBarContainerState {
rangeFrom: string;
rangeTo: string;
kuery: string;
status: AlertStatus;
filters: Filter[];
savedQueryId?: string;
}
import { AlertSearchBarContainerState } from '../types';

interface AlertSearchBarStateTransitions {
setRangeFrom: (
Expand All @@ -43,7 +35,7 @@ interface AlertSearchBarStateTransitions {
) => (savedQueryId?: string) => AlertSearchBarContainerState;
}

const defaultState: AlertSearchBarContainerState = {
const DEFAULT_STATE: AlertSearchBarContainerState = {
rangeFrom: 'now-24h',
rangeTo: 'now',
kuery: '',
Expand All @@ -60,13 +52,13 @@ const transitions: AlertSearchBarStateTransitions = {
setSavedQueryId: (state) => (savedQueryId) => ({ ...state, savedQueryId }),
};

const alertSearchBarStateContainer = createStateContainer(defaultState, transitions);
const alertSearchBarStateContainer = createStateContainer(DEFAULT_STATE, transitions);

type AlertSearchBarStateContainer = typeof alertSearchBarStateContainer;

const { Provider, useContainer } = createStateContainerReactHelpers<AlertSearchBarStateContainer>();

export { Provider, alertSearchBarStateContainer, useContainer, defaultState };
export { Provider, alertSearchBarStateContainer, useContainer, DEFAULT_STATE };
export type {
AlertSearchBarStateContainer,
AlertSearchBarContainerState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useTimefilterService } from '../../../hooks/use_timefilter_service';

import {
useContainer,
defaultState,
DEFAULT_STATE,
AlertSearchBarStateContainer,
AlertSearchBarContainerState,
} from './state_container';
Expand All @@ -42,12 +42,13 @@ export const alertSearchBarState = t.partial({

export function useAlertSearchBarStateContainer(
urlStorageKey: string,
{ replace }: { replace?: boolean } = {}
{ replace }: { replace?: boolean } = {},
defaultState: AlertSearchBarContainerState = DEFAULT_STATE
) {
const [savedQuery, setSavedQuery] = useState<SavedQuery>();
const stateContainer = useContainer();

useUrlStateSyncEffect(stateContainer, urlStorageKey, replace);
useUrlStateSyncEffect(stateContainer, urlStorageKey, replace, defaultState);

const { setRangeFrom, setRangeTo, setKuery, setStatus, setFilters, setSavedQueryId } =
stateContainer.transitions;
Expand Down Expand Up @@ -105,7 +106,8 @@ export function useAlertSearchBarStateContainer(
function useUrlStateSyncEffect(
stateContainer: AlertSearchBarStateContainer,
urlStorageKey: string,
replace: boolean = true
replace: boolean = true,
defaultState: AlertSearchBarContainerState = DEFAULT_STATE
) {
const history = useHistory();
const timefilterService = useTimefilterService();
Expand All @@ -127,20 +129,23 @@ function useUrlStateSyncEffect(
timefilterService,
stateContainer,
urlStateStorage,
urlStorageKey
urlStorageKey,
defaultState
);

start();

return stop;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stateContainer, history, timefilterService, urlStorageKey, replace]);
}

function setupUrlStateSync(
stateContainer: AlertSearchBarStateContainer,
urlStateStorage: IKbnUrlStateStorage,
urlStorageKey: string,
replace: boolean = true
replace: boolean = true,
defaultState: AlertSearchBarContainerState = DEFAULT_STATE
) {
// This handles filling the state when an incomplete URL set is provided
const setWithDefaults = (changedState: Partial<AlertSearchBarContainerState> | null) => {
Expand All @@ -165,7 +170,8 @@ function initializeUrlAndStateContainer(
timefilterService: TimefilterContract,
stateContainer: AlertSearchBarStateContainer,
urlStateStorage: IKbnUrlStateStorage,
urlStorageKey: string
urlStorageKey: string,
defaultState: AlertSearchBarContainerState
) {
const urlState = alertSearchBarState.decode(
urlStateStorage.get<Partial<AlertSearchBarContainerState>>(urlStorageKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface AlertStatusFilterProps {

export interface AlertSearchBarWithUrlSyncProps extends CommonAlertSearchBarProps {
urlStorageKey: string;
defaultState?: AlertSearchBarContainerState;
}

export interface Dependencies {
Expand Down Expand Up @@ -49,7 +50,7 @@ export interface ObservabilityAlertSearchBarProps
services: Services;
}

interface AlertSearchBarContainerState {
export interface AlertSearchBarContainerState {
rangeFrom: string;
rangeTo: string;
kuery: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { GetGroupStats } from '@kbn/grouping/src';
import { ALERT_INSTANCE_ID, ALERT_RULE_NAME } from '@kbn/rule-data-utils';
import { AlertsByGroupingAgg } from '../../../components/alerts_table/types';
import { AlertsByGroupingAgg } from '../types';

export const getGroupStats: GetGroupStats<AlertsByGroupingAgg> = (selectedGroup, bucket) => {
const defaultBadges = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { isArray } from 'lodash/fp';
import { EuiFlexGroup, EuiIconTip, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui';
import { firstNonNullValue, GroupPanelRenderer } from '@kbn/grouping/src';
import { FormattedMessage } from '@kbn/i18n-react';
import { AlertsByGroupingAgg } from '../../../components/alerts_table/types';
import { Tags } from '../../../components/tags';
import { AlertsByGroupingAgg } from '../types';
import { Tags } from '../../tags';
import { ungrouped } from './constants';

export const renderGroupPanel: GroupPanelRenderer<AlertsByGroupingAgg> = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm';

export const ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID = `alerts-page-alerts-table`;
export const RULE_DETAILS_ALERTS_TABLE_CONFIG_ID = `rule-details-alerts-table`;

export const SEARCH_BAR_URL_STORAGE_KEY = 'searchBarParams';
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ruleTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/app
import { waitFor } from '@testing-library/react';
import { Chance } from 'chance';
import React, { Fragment } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { from } from 'rxjs';
import { useFetchAlertDetail } from '../../hooks/use_fetch_alert_detail';
import { ConfigSchema } from '../../plugin';
Expand All @@ -30,6 +30,7 @@ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
useLocation: jest.fn(),
useHistory: jest.fn(),
}));

jest.mock('../../utils/kibana_react');
Expand Down Expand Up @@ -85,6 +86,7 @@ jest.mock('@kbn/observability-shared-plugin/public');
const useFetchAlertDetailMock = useFetchAlertDetail as jest.Mock;
const useParamsMock = useParams as jest.Mock;
const useLocationMock = useLocation as jest.Mock;
const useHistoryMock = useHistory as jest.Mock;
const useBreadcrumbsMock = useBreadcrumbs as jest.Mock;

const chance = new Chance();
Expand All @@ -110,6 +112,7 @@ describe('Alert details', () => {
jest.clearAllMocks();
useParamsMock.mockReturnValue(params);
useLocationMock.mockReturnValue({ pathname: '/alerts/uuid', search: '', state: '', hash: '' });
useHistoryMock.mockReturnValue({ replace: jest.fn() });
useBreadcrumbsMock.mockReturnValue([]);
ruleTypeRegistry.list.mockReturnValue([ruleType]);
ruleTypeRegistry.get.mockReturnValue(ruleType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useParams } from 'react-router-dom';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import {
EuiEmptyPrompt,
EuiPanel,
Expand All @@ -23,13 +23,17 @@ import {
ALERT_RULE_UUID,
ALERT_STATUS,
ALERT_STATUS_UNTRACKED,
ALERT_GROUP,
} from '@kbn/rule-data-utils';
import { RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import dedent from 'dedent';
import { AlertFieldsTable } from '@kbn/alerts-ui-shared';
import { css } from '@emotion/react';
import { omit } from 'lodash';
import type { Group } from '../../../common/typings';
import { observabilityFeatureId } from '../../../common';
import { RelatedAlerts } from './components/related_alerts';
import { useKibana } from '../../utils/kibana_react';
import { useFetchRule } from '../../hooks/use_fetch_rule';
import { usePluginContext } from '../../hooks/use_plugin_context';
Expand All @@ -40,7 +44,6 @@ import { AlertSummary, AlertSummaryField } from './components/alert_summary';
import { CenterJustifiedSpinner } from '../../components/center_justified_spinner';
import { getTimeZone } from '../../utils/get_time_zone';
import { isAlertDetailsEnabledPerApp } from '../../utils/is_alert_details_enabled';
import { observabilityFeatureId } from '../../../common';
import { paths } from '../../../common/locators/paths';
import { HeaderMenu } from '../overview/components/header_menu/header_menu';
import { AlertOverview } from '../../components/alert_overview/alert_overview';
Expand All @@ -61,6 +64,12 @@ export const LOG_DOCUMENT_COUNT_RULE_TYPE_ID = 'logs.alert.document.count';
export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold';
export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold';

const OVERVIEW_TAB_ID = 'overview';
const METADATA_TAB_ID = 'metadata';
const RELATED_ALERTS_TAB_ID = 'related_alerts';
const ALERT_DETAILS_TAB_URL_STORAGE_KEY = 'tabId';
type TabId = typeof OVERVIEW_TAB_ID | typeof METADATA_TAB_ID | typeof RELATED_ALERTS_TAB_ID;

export function AlertDetails() {
const {
cases: {
Expand All @@ -73,6 +82,8 @@ export function AlertDetails() {
uiSettings,
} = useKibana().services;

const { search } = useLocation();
const history = useHistory();
const { ObservabilityPageTemplate, config } = usePluginContext();
const { alertId } = useParams<AlertDetailsPathParams>();
const [isLoading, alertDetail] = useFetchAlertDetail(alertId);
Expand All @@ -87,6 +98,27 @@ export function AlertDetails() {
const [alertStatus, setAlertStatus] = useState<AlertStatus>();
const { euiTheme } = useEuiTheme();

const [activeTabId, setActiveTabId] = useState<TabId>(() => {
const searchParams = new URLSearchParams(search);
const urlTabId = searchParams.get(ALERT_DETAILS_TAB_URL_STORAGE_KEY);

return urlTabId && [OVERVIEW_TAB_ID, METADATA_TAB_ID, RELATED_ALERTS_TAB_ID].includes(urlTabId)
? (urlTabId as TabId)
: OVERVIEW_TAB_ID;
});
const handleSetTabId = async (tabId: TabId) => {
setActiveTabId(tabId);

let searchParams = new URLSearchParams(search);
if (tabId === RELATED_ALERTS_TAB_ID) {
searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId);
} else {
searchParams = new URLSearchParams();
searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId);
}
history.replace({ search: searchParams.toString() });
};

useEffect(() => {
if (!alertDetail || !observabilityAIAssistant) {
return;
Expand Down Expand Up @@ -162,9 +194,6 @@ export function AlertDetails() {
const AlertDetailsAppSection = ruleTypeModel ? ruleTypeModel.alertDetailsAppSection : null;
const timeZone = getTimeZone(uiSettings);

const OVERVIEW_TAB_ID = 'overview';
const METADATA_TAB_ID = 'metadata';

const overviewTab = alertDetail ? (
AlertDetailsAppSection &&
/*
Expand Down Expand Up @@ -229,6 +258,20 @@ export function AlertDetails() {
'data-test-subj': 'metadataTab',
content: metadataTab,
},
{
id: RELATED_ALERTS_TAB_ID,
name: i18n.translate('xpack.observability.alertDetails.tab.relatedAlertsLabel', {
defaultMessage: 'Related Alerts',
}),
'data-test-subj': 'relatedAlertsTab',
content: (
<RelatedAlerts
alert={alertDetail?.formatted}
tags={alertDetail?.formatted.fields.tags}
groups={alertDetail?.formatted.fields[ALERT_GROUP] as Group[]}
/>
),
},
];

return (
Expand Down Expand Up @@ -266,7 +309,12 @@ export function AlertDetails() {
data-test-subj="alertDetails"
>
<HeaderMenu />
<EuiTabbedContent data-test-subj="alertDetailsTabbedContent" tabs={tabs} />
<EuiTabbedContent
data-test-subj="alertDetailsTabbedContent"
tabs={tabs}
selectedTab={tabs.find((tab) => tab.id === activeTabId)}
onTabClick={(tab) => handleSetTabId(tab.id as TabId)}
/>
</ObservabilityPageTemplate>
);
}
Expand Down
Loading

0 comments on commit 618c4d0

Please sign in to comment.