[Actionable Observability] Add rule details locator and make AlertSummaryWidget clickable (#147103)

Resolves #141467

## 📝 Summary

- Added a
[locator](https://docs.elastic.dev/kibana-dev-docs/routing-and-navigation#specifying-state)
for the rule details page
- Made AlertSummaryWidget clickable and implemented the related
navigation for rule details page

## 🧪 How to test
- Create a rule and go to the rule details page
- You should be able to click on all/active/recovered sections in Alert
Summary Widget and upon click going to alert tables with the correct
filter


https://user-images.githubusercontent.com/12370520/205959565-6c383910-763f-4214-9baa-cf191f012de9.mp4
This commit is contained in:
Maryam Saeidi 2022-12-07 17:18:02 +01:00 committed by GitHub
parent 313537c178
commit a30d225421
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 246 additions and 44 deletions

View file

@ -54,6 +54,7 @@ export const casesPath = '/cases';
export const uptimeOverviewLocatorID = 'UPTIME_OVERVIEW_LOCATOR';
export const syntheticsMonitorDetailLocatorID = 'SYNTHETICS_MONITOR_DETAIL_LOCATOR';
export const syntheticsEditMonitorLocatorID = 'SYNTHETICS_EDIT_MONITOR_LOCATOR';
export const ruleDetailsLocatorID = 'RULE_DETAILS_LOCATOR';
export {
NETWORK_TIMINGS_FIELDS,

View file

@ -31,7 +31,8 @@
"inspector",
"unifiedSearch",
"security",
"guidedOnboarding"
"guidedOnboarding",
"share"
],
"ui": true,
"server": true,

View file

@ -22,6 +22,7 @@ import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { LensPublicStart } from '@kbn/lens-plugin/public';
import { SharePluginStart } from '@kbn/share-plugin/public';
import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
import { CasesUiStart } from '@kbn/cases-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
@ -39,6 +40,7 @@ export interface ObservabilityAppServices {
notifications: NotificationsStart;
overlays: OverlayStart;
savedObjectsClient: SavedObjectsStart['client'];
share: SharePluginStart;
stateTransfer: EmbeddableStateTransfer;
storage: IStorageWrapper;
theme: ThemeServiceStart;

View file

@ -0,0 +1,49 @@
/*
* 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 { ACTIVE_ALERTS } from '../components/shared/alert_search_bar/constants';
import { EXECUTION_TAB, ALERTS_TAB } from '../pages/rule_details/constants';
import { getRuleDetailsPath, RuleDetailsLocatorDefinition } from './rule_details';
describe('RuleDetailsLocator', () => {
const locator = new RuleDetailsLocatorDefinition();
const mockedRuleId = '389d3318-7e10-4996-bb45-128e1607fb7e';
it('should return correct url when only ruleId is provided', async () => {
const location = await locator.getLocation({ ruleId: mockedRuleId });
expect(location.app).toEqual('observability');
expect(location.path).toEqual(getRuleDetailsPath(mockedRuleId));
});
it('should return correct url when tabId is execution', async () => {
const location = await locator.getLocation({ ruleId: mockedRuleId, tabId: EXECUTION_TAB });
expect(location.path).toMatchInlineSnapshot(
`"/alerts/rules/389d3318-7e10-4996-bb45-128e1607fb7e?tabId=execution"`
);
});
it('should return correct url when tabId is alerts without extra search params', async () => {
const location = await locator.getLocation({ ruleId: mockedRuleId, tabId: ALERTS_TAB });
expect(location.path).toMatchInlineSnapshot(
`"/alerts/rules/389d3318-7e10-4996-bb45-128e1607fb7e?tabId=alerts&searchBarParams=(kuery:'',rangeFrom:now-15m,rangeTo:now,status:all)"`
);
});
it('should return correct url when tabId is alerts with search params', async () => {
const location = await locator.getLocation({
ruleId: mockedRuleId,
tabId: ALERTS_TAB,
rangeFrom: 'mockedRangeTo',
rangeTo: 'mockedRangeFrom',
kuery: 'mockedKuery',
status: ACTIVE_ALERTS.status,
});
expect(location.path).toMatchInlineSnapshot(
`"/alerts/rules/389d3318-7e10-4996-bb45-128e1607fb7e?tabId=alerts&searchBarParams=(kuery:mockedKuery,rangeFrom:mockedRangeTo,rangeTo:mockedRangeFrom,status:active)"`
);
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
import type { SerializableRecord } from '@kbn/utility-types';
import type { LocatorDefinition } from '@kbn/share-plugin/public';
import { ruleDetailsLocatorID } from '../../common';
import { ALL_ALERTS } from '../components/shared/alert_search_bar/constants';
import {
ALERTS_TAB,
EXECUTION_TAB,
SEARCH_BAR_URL_STORAGE_KEY,
} from '../pages/rule_details/constants';
import type { TabId } from '../pages/rule_details/types';
import type { AlertStatus } from '../../common/typings';
export interface RuleDetailsLocatorParams extends SerializableRecord {
ruleId: string;
tabId?: TabId;
rangeFrom?: string;
rangeTo?: string;
kuery?: string;
status?: AlertStatus;
}
export const getRuleDetailsPath = (ruleId: string) => {
return `/alerts/rules/${encodeURI(ruleId)}`;
};
export class RuleDetailsLocatorDefinition implements LocatorDefinition<RuleDetailsLocatorParams> {
public readonly id = ruleDetailsLocatorID;
public readonly getLocation = async (params: RuleDetailsLocatorParams) => {
const { ruleId, kuery, rangeTo, tabId, rangeFrom, status } = params;
const appState: {
tabId?: TabId;
rangeFrom?: string;
rangeTo?: string;
kuery?: string;
status?: AlertStatus;
} = {};
appState.rangeFrom = rangeFrom || 'now-15m';
appState.rangeTo = rangeTo || 'now';
appState.kuery = kuery || '';
appState.status = status || ALL_ALERTS.status;
let path = getRuleDetailsPath(ruleId);
if (tabId === ALERTS_TAB) {
path = `${path}?tabId=${tabId}`;
path = setStateToKbnUrl(
SEARCH_BAR_URL_STORAGE_KEY,
appState,
{ useHash: false, storeInHashQuery: false },
path
);
} else if (tabId === EXECUTION_TAB) {
path = `${path}?tabId=${tabId}`;
}
return {
app: 'observability',
path,
state: {},
};
};
}

View file

@ -7,7 +7,7 @@
export const EXECUTION_TAB = 'execution';
export const ALERTS_TAB = 'alerts';
export const URL_STORAGE_KEY = 'searchBarParams';
export const SEARCH_BAR_URL_STORAGE_KEY = 'searchBarParams';
export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list';
export const RULE_DETAILS_PAGE_ID = 'rule-details-alerts-o11y';
export const RULE_DETAILS_ALERTS_SEARCH_BAR_ID = 'rule-details-alerts-search-bar-o11y';

View file

@ -46,7 +46,7 @@ import {
ALERTS_TAB,
RULE_DETAILS_PAGE_ID,
RULE_DETAILS_ALERTS_SEARCH_BAR_ID,
URL_STORAGE_KEY,
SEARCH_BAR_URL_STORAGE_KEY,
} from './constants';
import { RuleDetailsPathParams, TabId } from './types';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
@ -57,7 +57,9 @@ import { PageTitle } from './components';
import { getHealthColor } from './config';
import { hasExecuteActionsCapability, hasAllPrivilege } from './config';
import { paths } from '../../config/paths';
import { observabilityFeatureId } from '../../../common';
import { ALERT_STATUS_ALL } from '../../../common/constants';
import { AlertStatus } from '../../../common/typings';
import { observabilityFeatureId, ruleDetailsLocatorID } from '../../../common';
import { ALERT_STATUS_LICENSE_ERROR, rulesStatusesTranslationsMapping } from './translations';
import { ObservabilityAppServices } from '../../application/types';
@ -70,12 +72,15 @@ export function RuleDetailsPage() {
getEditAlertFlyout,
getRuleEventLogList,
getAlertsStateTable: AlertsStateTable,
getRuleAlertsSummary,
getRuleAlertsSummary: AlertSummaryWidget,
getRuleStatusPanel,
getRuleDefinition,
},
application: { capabilities, navigateToUrl },
notifications: { toasts },
share: {
url: { locators },
},
} = useKibana<ObservabilityAppServices>().services;
const { ruleId } = useParams<RuleDetailsPathParams>();
@ -106,6 +111,22 @@ export function RuleDetailsPage() {
const ruleQuery = useRef([
{ query: `kibana.alert.rule.uuid: ${ruleId}`, language: 'kuery' },
] as Query[]);
const tabsRef = useRef<HTMLDivElement>(null);
const onAlertSummaryWidgetClick = async (status: AlertStatus = ALERT_STATUS_ALL) => {
await locators.get(ruleDetailsLocatorID)?.navigate(
{
ruleId,
tabId: ALERTS_TAB,
status,
},
{
replace: true,
}
);
setTabId(ALERTS_TAB);
tabsRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const updateUrl = (nextQuery: { tabId: TabId }) => {
const newTabId = nextQuery.tabId;
@ -224,7 +245,7 @@ export function RuleDetailsPage() {
<ObservabilityAlertSearchbarWithUrlSync
appName={RULE_DETAILS_ALERTS_SEARCH_BAR_ID}
onEsQueryChange={setEsQuery}
urlStorageKey={URL_STORAGE_KEY}
urlStorageKey={SEARCH_BAR_URL_STORAGE_KEY}
defaultSearchQueries={ruleQuery.current}
/>
<EuiSpacer size="s" />
@ -354,16 +375,18 @@ export function RuleDetailsPage() {
</EuiFlexItem>
<EuiSpacer size="m" />
<EuiFlexItem style={{ minWidth: 350 }}>
{getRuleAlertsSummary({
rule,
filteredRuleTypes,
})}
<AlertSummaryWidget
rule={rule}
filteredRuleTypes={filteredRuleTypes}
onClick={(status) => onAlertSummaryWidgetClick(status)}
/>
</EuiFlexItem>
<EuiSpacer size="m" />
{getRuleDefinition({ rule, onEditRule: () => reloadRule() } as RuleDefinitionProps)}
</EuiFlexGroup>
<EuiSpacer size="l" />
<div ref={tabsRef} />
<EuiTabbedContent
data-test-subj="ruleDetailsTabbedContent"
tabs={tabs}

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import { BehaviorSubject, from } from 'rxjs';
import { map } from 'rxjs/operators';
import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import {
AppDeepLink,
AppMountParameters,
@ -38,6 +39,7 @@ import {
} from '@kbn/triggers-actions-ui-plugin/public';
import { SecurityPluginStart } from '@kbn/security-plugin/public';
import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public';
import { RuleDetailsLocatorDefinition } from './locators/rule_details';
import { observabilityAppId, observabilityFeatureId, casesPath } from '../common';
import { createLazyObservabilityPageTemplate } from './components/shared';
import { registerDataHandler } from './data_handler';
@ -78,6 +80,7 @@ export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>;
export interface ObservabilityPublicPluginsSetup {
data: DataPublicPluginSetup;
share: SharePluginSetup;
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
home?: HomePublicPluginSetup;
usageCollection: UsageCollectionSetup;
@ -88,6 +91,7 @@ export interface ObservabilityPublicPluginsStart {
cases: CasesUiStart;
embeddable: EmbeddableStart;
home?: HomePublicPluginStart;
share: SharePluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
@ -171,6 +175,7 @@ export class Plugin
this.observabilityRuleTypeRegistry = createObservabilityRuleTypeRegistry(
pluginsSetup.triggersActionsUi.ruleTypeRegistry
);
pluginsSetup.share.url.locators.create(new RuleDetailsLocatorDefinition());
const mount = async (params: AppMountParameters<unknown>) => {
// Load application bundle

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { action } from '@storybook/addon-actions';
import { AlertsSummaryWidgetUI as Component } from './alert_summary_widget_ui';
export default {
@ -17,5 +18,6 @@ export const Overview = {
active: 15,
recovered: 53,
timeRange: 'Last 30 days',
onClick: action('clicked'),
},
};

View file

@ -5,9 +5,18 @@
* 2.0.
*/
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, AlertStatus } from '@kbn/rule-data-utils';
import { euiLightVars } from '@kbn/ui-theme';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import React, { MouseEvent } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { AlertsSummaryWidgetUIProps } from './types';
@ -15,9 +24,26 @@ export const AlertsSummaryWidgetUI = ({
active,
recovered,
timeRange,
onClick,
}: AlertsSummaryWidgetUIProps) => {
const handleClick = (
event: MouseEvent<HTMLAnchorElement | HTMLDivElement>,
status?: AlertStatus
) => {
event.preventDefault();
event.stopPropagation();
onClick(status);
};
return (
<EuiPanel data-test-subj="ruleAlertsSummary" hasShadow={false} hasBorder>
<EuiPanel
element="div"
data-test-subj="ruleAlertsSummary"
hasShadow={false}
hasBorder
onClick={handleClick}
>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column">
@ -43,39 +69,53 @@ export const AlertsSummaryWidgetUI = ({
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem>
<EuiText>
<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>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem>
<EuiText color={euiLightVars.euiColorSuccessText}>
<h3 data-test-subj="recoveredAlertsCount">{recovered}</h3>
<EuiLink onClick={handleClick}>
<EuiText color={euiLightVars.euiTextColor}>
<h3 data-test-subj="totalAlertsCount">{active + recovered}</h3>
</EuiText>
</EuiFlexItem>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel"
defaultMessage="Recovered"
/>
</EuiText>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.alertsSummary.allAlertsLabel"
defaultMessage="All"
/>
</EuiText>
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<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
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>

View file

@ -5,8 +5,11 @@
* 2.0.
*/
import { AlertStatus } from '@kbn/rule-data-utils';
export interface AlertsSummaryWidgetUIProps {
active: number;
recovered: number;
timeRange: JSX.Element | string;
onClick: (status?: AlertStatus) => void;
}

View file

@ -59,6 +59,7 @@ describe('Rule Alert Summary', () => {
<RuleAlertsSummary
rule={mockedRule}
filteredRuleTypes={['apm', 'uptime', 'metric', 'logs']}
onClick={jest.fn}
/>
</IntlProvider>
);

View file

@ -14,7 +14,7 @@ import { useLoadRuleTypes } from '../../../../hooks/use_load_rule_types';
import { RuleAlertsSummaryProps } from '.';
import { AlertSummaryWidgetError, AlertsSummaryWidgetUI } from './components';
export const RuleAlertsSummary = ({ rule, filteredRuleTypes }: RuleAlertsSummaryProps) => {
export const RuleAlertsSummary = ({ rule, filteredRuleTypes, onClick }: RuleAlertsSummaryProps) => {
const [features, setFeatures] = useState<string>('');
const { ruleTypes } = useLoadRuleTypes({
filteredRuleTypes,
@ -40,6 +40,7 @@ export const RuleAlertsSummary = ({ rule, filteredRuleTypes }: RuleAlertsSummary
return (
<AlertsSummaryWidgetUI
active={active}
onClick={onClick}
recovered={recovered}
timeRange={
<FormattedMessage

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import { AlertStatus } from '@kbn/rule-data-utils';
import { Rule } from '../../../../../types';
export interface RuleAlertsSummaryProps {
rule: Rule;
filteredRuleTypes: string[];
onClick: (status?: AlertStatus) => void;
}