[Actionable Observability] Add links to navigate from alerts table to rule (#118035) (#118301)

[Actionable Observability] Add links to navigate from alerts table and flyout to rule generate it

Co-authored-by: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-11-11 05:53:45 -05:00 committed by GitHub
parent 51956998de
commit cdafd322c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 244 additions and 126 deletions

View file

@ -24,6 +24,7 @@ import { EuiSelect } from '@elastic/eui';
import { uniqBy } from 'lodash';
import { Alert } from '../../../../../../alerting/common';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { paths } from '../../../../config';
const ALL_TYPES = 'ALL_TYPES';
const allTypes = {
@ -41,8 +42,8 @@ export function AlertsSection({ alerts }: Props) {
const { config, core } = usePluginContext();
const [filter, setFilter] = useState(ALL_TYPES);
const manageLink = config.unsafe.alertingExperience.enabled
? core.http.basePath.prepend(`/app/observability/alerts`)
: core.http.basePath.prepend(`/app/management/insightsAndAlerting/triggersActions/rules`);
? core.http.basePath.prepend(paths.observability.alerts)
: core.http.basePath.prepend(paths.management.rules);
const filterOptions = uniqBy(alerts, (alert) => alert.consumer).map(({ consumer }) => ({
value: consumer,
text: consumer,
@ -89,9 +90,7 @@ export function AlertsSection({ alerts }: Props) {
<EuiFlexGroup direction="column" gutterSize="s" key={alert.id}>
<EuiFlexItem grow={false}>
<EuiLink
href={core.http.basePath.prepend(
`/app/management/insightsAndAlerting/triggersActions/alert/${alert.id}`
)}
href={core.http.basePath.prepend(paths.management.alertDetails(alert.id))}
>
<EuiText size="s">{alert.name}</EuiText>
</EuiLink>

View file

@ -0,0 +1,9 @@
/*
* 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 { paths } from './paths';
export { translations } from './translations';

View file

@ -0,0 +1,19 @@
/*
* 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 const paths = {
observability: {
alerts: '/app/observability/alerts',
},
management: {
rules: '/app/management/insightsAndAlerting/triggersActions/rules',
ruleDetails: (ruleId: string) =>
`/app/management/insightsAndAlerting/triggersActions/rule/${encodeURI(ruleId)}`,
alertDetails: (alertId: string) =>
`/app/management/insightsAndAlerting/triggersActions/alert/${encodeURI(alertId)}`,
},
};

View file

@ -0,0 +1,104 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const translations = {
alertsTable: {
viewDetailsTextLabel: i18n.translate('xpack.observability.alertsTable.viewDetailsTextLabel', {
defaultMessage: 'View details',
}),
viewInAppTextLabel: i18n.translate('xpack.observability.alertsTable.viewInAppTextLabel', {
defaultMessage: 'View in app',
}),
moreActionsTextLabel: i18n.translate('xpack.observability.alertsTable.moreActionsTextLabel', {
defaultMessage: 'More actions',
}),
notEnoughPermissions: i18n.translate('xpack.observability.alertsTable.notEnoughPermissions', {
defaultMessage: 'Additional privileges required',
}),
statusColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.statusColumnDescription',
{
defaultMessage: 'Alert Status',
}
),
lastUpdatedColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.lastUpdatedColumnDescription',
{
defaultMessage: 'Last updated',
}
),
durationColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.durationColumnDescription',
{
defaultMessage: 'Duration',
}
),
reasonColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.reasonColumnDescription',
{
defaultMessage: 'Reason',
}
),
actionsTextLabel: i18n.translate('xpack.observability.alertsTable.actionsTextLabel', {
defaultMessage: 'Actions',
}),
loadingTextLabel: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', {
defaultMessage: 'loading alerts',
}),
footerTextLabel: i18n.translate('xpack.observability.alertsTable.footerTextLabel', {
defaultMessage: 'alerts',
}),
showingAlertsTitle: (totalAlerts: number) =>
i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', {
values: { totalAlerts },
defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}',
}),
viewRuleDetailsButtonText: i18n.translate(
'xpack.observability.alertsTable.viewRuleDetailsButtonText',
{
defaultMessage: 'View rule details',
}
),
},
alertsFlyout: {
statusLabel: i18n.translate('xpack.observability.alertsFlyout.statusLabel', {
defaultMessage: 'Status',
}),
lastUpdatedLabel: i18n.translate('xpack.observability.alertsFlyout.lastUpdatedLabel', {
defaultMessage: 'Last updated',
}),
durationLabel: i18n.translate('xpack.observability.alertsFlyout.durationLabel', {
defaultMessage: 'Duration',
}),
expectedValueLabel: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', {
defaultMessage: 'Expected value',
}),
actualValueLabel: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', {
defaultMessage: 'Actual value',
}),
ruleTypeLabel: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', {
defaultMessage: 'Rule type',
}),
reasonTitle: i18n.translate('xpack.observability.alertsFlyout.reasonTitle', {
defaultMessage: 'Reason',
}),
viewRulesDetailsLinkText: i18n.translate(
'xpack.observability.alertsFlyout.viewRulesDetailsLinkText',
{
defaultMessage: 'View rule details',
}
),
documentSummaryTitle: i18n.translate('xpack.observability.alertsFlyout.documentSummaryTitle', {
defaultMessage: 'Document Summary',
}),
viewInAppButtonText: i18n.translate('xpack.observability.alertsFlyout.viewInAppButtonText', {
defaultMessage: 'View in app',
}),
},
};

View file

@ -15,11 +15,12 @@ import {
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFlyoutProps,
EuiLink,
EuiSpacer,
EuiText,
EuiTitle,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type {
ALERT_DURATION as ALERT_DURATION_TYPED,
ALERT_EVALUATION_THRESHOLD as ALERT_EVALUATION_THRESHOLD_TYPED,
@ -47,6 +48,7 @@ import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observ
import { parseAlert } from '../parse_alert';
import { AlertStatusIndicator } from '../../../components/shared/alert_status_indicator';
import { ExperimentalBadge } from '../../../components/shared/experimental_badge';
import { translations, paths } from '../../../config';
type AlertsFlyoutProps = {
alert?: TopAlert;
@ -77,6 +79,7 @@ export function AlertsFlyout({
const { services } = useKibana();
const { http } = services;
const prepend = http?.basePath.prepend;
const decoratedAlerts = useMemo(() => {
const parseObservabilityAlert = parseAlert(observabilityRuleTypeRegistry);
return (alerts ?? []).map(parseObservabilityAlert);
@ -90,11 +93,12 @@ export function AlertsFlyout({
return null;
}
const ruleId = alertData.fields['kibana.alert.rule.uuid'] ?? null;
const linkToRule = ruleId && prepend ? prepend(paths.management.ruleDetails(ruleId)) : null;
const overviewListItems = [
{
title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', {
defaultMessage: 'Status',
}),
title: translations.alertsFlyout.statusLabel,
description: (
<AlertStatusIndicator
alertStatus={alertData.active ? ALERT_STATUS_ACTIVE : ALERT_STATUS_RECOVERED}
@ -102,52 +106,55 @@ export function AlertsFlyout({
),
},
{
title: i18n.translate('xpack.observability.alertsFlyout.lastUpdatedLabel', {
defaultMessage: 'Last updated',
}),
title: translations.alertsFlyout.lastUpdatedLabel,
description: (
<span title={alertData.start.toString()}>{moment(alertData.start).format(dateFormat)}</span>
),
},
{
title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', {
defaultMessage: 'Duration',
}),
title: translations.alertsFlyout.durationLabel,
description: asDuration(alertData.fields[ALERT_DURATION], { extended: true }),
},
{
title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', {
defaultMessage: 'Expected value',
}),
title: translations.alertsFlyout.expectedValueLabel,
description: alertData.fields[ALERT_EVALUATION_THRESHOLD] ?? '-',
},
{
title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', {
defaultMessage: 'Actual value',
}),
title: translations.alertsFlyout.actualValueLabel,
description: alertData.fields[ALERT_EVALUATION_VALUE] ?? '-',
},
{
title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', {
defaultMessage: 'Rule type',
}),
title: translations.alertsFlyout.ruleTypeLabel,
description: alertData.fields[ALERT_RULE_CATEGORY] ?? '-',
},
];
return (
<EuiFlyout onClose={onClose} size="s" data-test-subj="alertsFlyout">
<EuiFlyoutHeader>
<EuiFlyoutHeader hasBorder>
<ExperimentalBadge />
<EuiSpacer size="s" />
<EuiTitle size="m" data-test-subj="alertsFlyoutTitle">
<h2>{alertData.fields[ALERT_RULE_NAME]}</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s">{alertData.reason}</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiTitle size="xs">
<h4>{translations.alertsFlyout.reasonTitle}</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s">{alertData.reason}</EuiText>
<EuiSpacer size="s" />
{!!linkToRule && (
<EuiLink href={linkToRule} data-test-subj="viewRuleDetailsFlyout">
{translations.alertsFlyout.viewRulesDetailsLinkText}
</EuiLink>
)}
<EuiHorizontalRule size="full" />
<EuiTitle size="xs">
<h4>{translations.alertsFlyout.documentSummaryTitle}</h4>
</EuiTitle>
<EuiSpacer size="m" />
<EuiDescriptionList
compressed={true}
type="responsiveColumn"
@ -173,7 +180,7 @@ export function AlertsFlyout({
data-test-subj="alertsFlyoutViewInAppButton"
fill
>
View in app
{translations.alertsFlyout.viewInAppButtonText}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -34,10 +34,12 @@ import {
EuiDataGridColumn,
EuiFlexGroup,
EuiFlexItem,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import styled from 'styled-components';
import React, { Suspense, useMemo, useState, useCallback, useEffect } from 'react';
import usePrevious from 'react-use/lib/usePrevious';
@ -65,7 +67,7 @@ import { getDefaultCellActions } from './default_cell_actions';
import { LazyAlertsFlyout } from '../..';
import { parseAlert } from './parse_alert';
import { CoreStart } from '../../../../../../src/core/public';
import { translations } from './translations';
import { translations, paths } from '../../config';
const ALERT_DURATION: typeof ALERT_DURATION_TYPED = ALERT_DURATION_NON_TYPED;
const ALERT_REASON: typeof ALERT_REASON_TYPED = ALERT_REASON_NON_TYPED;
@ -115,25 +117,25 @@ export const columns: Array<
> = [
{
columnHeaderType: 'not-filtered',
displayAsText: translations.statusColumnDescription,
displayAsText: translations.alertsTable.statusColumnDescription,
id: ALERT_STATUS,
initialWidth: 110,
},
{
columnHeaderType: 'not-filtered',
displayAsText: translations.lastUpdatedColumnDescription,
displayAsText: translations.alertsTable.lastUpdatedColumnDescription,
id: TIMESTAMP,
initialWidth: 230,
},
{
columnHeaderType: 'not-filtered',
displayAsText: translations.durationColumnDescription,
displayAsText: translations.alertsTable.durationColumnDescription,
id: ALERT_DURATION,
initialWidth: 116,
},
{
columnHeaderType: 'not-filtered',
displayAsText: translations.reasonColumnDescription,
displayAsText: translations.alertsTable.reasonColumnDescription,
id: ALERT_REASON,
linkField: '*',
},
@ -188,6 +190,7 @@ function ObservabilityActions({
const toggleActionsPopover = useCallback((id) => {
setActionsPopover((current) => (current ? null : id));
}, []);
const casePermissions = useGetUserCasesPermissions();
const event = useMemo(() => {
return {
@ -219,6 +222,9 @@ function ObservabilityActions({
onUpdateFailure: onAlertStatusUpdated,
});
const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null;
const linkToRule = ruleId ? prepend(paths.management.ruleDetails(ruleId)) : null;
const actionsMenuItems = useMemo(() => {
return [
...(casePermissions?.crud
@ -240,37 +246,56 @@ function ObservabilityActions({
]
: []),
...(alertPermissions.crud ? statusActionItems : []),
...(!!linkToRule
? [
<EuiContextMenuItem
key="viewRuleDetails"
data-test-subj="viewRuleDetails"
href={linkToRule}
>
{translations.alertsTable.viewRuleDetailsButtonText}
</EuiContextMenuItem>,
]
: []),
];
}, [afterCaseSelection, casePermissions, timelines, event, statusActionItems, alertPermissions]);
}, [
afterCaseSelection,
casePermissions,
timelines,
event,
statusActionItems,
alertPermissions,
linkToRule,
]);
const actionsToolTip =
actionsMenuItems.length <= 0
? translations.notEnoughPermissions
: translations.moreActionsTextLabel;
? translations.alertsTable.notEnoughPermissions
: translations.alertsTable.moreActionsTextLabel;
return (
<>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem>
<EuiToolTip content={translations.viewDetailsTextLabel}>
<EuiToolTip content={translations.alertsTable.viewDetailsTextLabel}>
<EuiButtonIcon
size="s"
iconType="expand"
color="text"
onClick={() => setFlyoutAlert(alert)}
data-test-subj="openFlyoutButton"
aria-label={translations.viewDetailsTextLabel}
aria-label={translations.alertsTable.viewDetailsTextLabel}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip content={translations.viewInAppTextLabel}>
<EuiToolTip content={translations.alertsTable.viewInAppTextLabel}>
<EuiButtonIcon
size="s"
href={prepend(alert.link ?? '')}
iconType="eye"
color="text"
aria-label={translations.viewInAppTextLabel}
aria-label={translations.alertsTable.viewInAppTextLabel}
/>
</EuiToolTip>
</EuiFlexItem>
@ -280,7 +305,6 @@ function ObservabilityActions({
<EuiToolTip content={actionsToolTip}>
<EuiButtonIcon
display="empty"
disabled={actionsMenuItems.length <= 0}
size="s"
color="text"
iconType="boxesHorizontal"
@ -345,7 +369,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
id: 'expand',
width: 120,
headerCellRender: () => {
return <EventsThContent>{translations.actionsTextLabel}</EventsThContent>;
return <EventsThContent>{translations.alertsTable.actionsTextLabel}</EventsThContent>;
},
rowCellRender: (actionProps: ActionProps) => {
return (
@ -377,8 +401,8 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
hasAlertsCrudPermissions,
indexNames,
itemsPerPageOptions: [10, 25, 50],
loadingText: translations.loadingTextLabel,
footerText: translations.footerTextLabel,
loadingText: translations.alertsTable.loadingTextLabel,
footerText: translations.alertsTable.footerTextLabel,
query: {
query: `${ALERT_WORKFLOW_STATUS}: ${workflowStatus}${kuery !== '' ? ` and ${kuery}` : ''}`,
language: 'kuery',
@ -399,7 +423,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
filterStatus: workflowStatus as AlertWorkflowStatus,
leadingControlColumns,
trailingControlColumns,
unit: (totalAlerts: number) => translations.showingAlertsTitle(totalAlerts),
unit: (totalAlerts: number) => translations.alertsTable.showingAlertsTitle(totalAlerts),
};
}, [
casePermissions,

View file

@ -1,61 +0,0 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const translations = {
viewDetailsTextLabel: i18n.translate('xpack.observability.alertsTable.viewDetailsTextLabel', {
defaultMessage: 'View details',
}),
viewInAppTextLabel: i18n.translate('xpack.observability.alertsTable.viewInAppTextLabel', {
defaultMessage: 'View in app',
}),
moreActionsTextLabel: i18n.translate('xpack.observability.alertsTable.moreActionsTextLabel', {
defaultMessage: 'More actions',
}),
notEnoughPermissions: i18n.translate('xpack.observability.alertsTable.notEnoughPermissions', {
defaultMessage: 'Additional privileges required',
}),
statusColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.statusColumnDescription',
{
defaultMessage: 'Alert Status',
}
),
lastUpdatedColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.lastUpdatedColumnDescription',
{
defaultMessage: 'Last updated',
}
),
durationColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.durationColumnDescription',
{
defaultMessage: 'Duration',
}
),
reasonColumnDescription: i18n.translate(
'xpack.observability.alertsTGrid.reasonColumnDescription',
{
defaultMessage: 'Reason',
}
),
actionsTextLabel: i18n.translate('xpack.observability.alertsTable.actionsTextLabel', {
defaultMessage: 'Actions',
}),
loadingTextLabel: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', {
defaultMessage: 'loading alerts',
}),
footerTextLabel: i18n.translate('xpack.observability.alertsTable.footerTextLabel', {
defaultMessage: 'alerts',
}),
showingAlertsTitle: (totalAlerts: number) =>
i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', {
values: { totalAlerts },
defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}',
}),
};

View file

@ -18,6 +18,9 @@ const DATE_WITH_DATA = {
const ALERTS_FLYOUT_SELECTOR = 'alertsFlyout';
const FILTER_FOR_VALUE_BUTTON_SELECTOR = 'filterForValue';
const ALERTS_TABLE_CONTAINER_SELECTOR = 'events-viewer-panel';
const VIEW_RULE_DETAILS_SELECTOR = 'viewRuleDetails';
const VIEW_RULE_DETAILS_FLYOUT_SELECTOR = 'viewRuleDetailsFlyout';
const ACTION_COLUMN_INDEX = 1;
type WorkflowStatus = 'open' | 'acknowledged' | 'closed';
@ -150,6 +153,10 @@ export function ObservabilityAlertsCommonProvider({
return await testSubjects.existOrFail('alertsFlyoutViewInAppButton');
};
const getAlertsFlyoutViewRuleDetailsLinkOrFail = async () => {
return await testSubjects.existOrFail('viewRuleDetailsFlyout');
};
const getAlertsFlyoutDescriptionListTitles = async (): Promise<WebElementWrapper[]> => {
const flyout = await getAlertsFlyout();
return await testSubjects.findAllDescendant('alertsFlyoutDescriptionListTitle', flyout);
@ -179,6 +186,13 @@ export function ObservabilityAlertsCommonProvider({
await actionsOverflowButton.click();
};
const viewRuleDetailsButtonClick = async () => {
return await (await testSubjects.find(VIEW_RULE_DETAILS_SELECTOR)).click();
};
const viewRuleDetailsLinkClick = async () => {
return await (await testSubjects.find(VIEW_RULE_DETAILS_FLYOUT_SELECTOR)).click();
};
// Workflow status
const setWorkflowStatusForRow = async (rowIndex: number, workflowStatus: WorkflowStatus) => {
await openActionsMenuForRow(rowIndex);
@ -259,5 +273,8 @@ export function ObservabilityAlertsCommonProvider({
navigateWithoutFilter,
getExperimentalDisclaimer,
getActionsButtonByIndex,
viewRuleDetailsButtonClick,
viewRuleDetailsLinkClick,
getAlertsFlyoutViewRuleDetailsLinkOrFail,
};
}

View file

@ -20,8 +20,9 @@ const TOTAL_ALERTS_CELL_COUNT = 198;
export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const find = getService('find');
describe('Observability alerts', function () {
describe('Observability alerts 1', function () {
this.tags('includeFirefox');
const testSubjects = getService('testSubjects');
@ -178,6 +179,10 @@ export default ({ getService }: FtrProviderContext) => {
it('Displays a View in App button', async () => {
await observability.alerts.common.getAlertsFlyoutViewInAppButtonOrFail();
});
it('Displays a View rule details link', async () => {
await observability.alerts.common.getAlertsFlyoutViewRuleDetailsLinkOrFail();
});
});
});
@ -213,28 +218,23 @@ export default ({ getService }: FtrProviderContext) => {
});
});
});
});
describe('Actions Button', () => {
before(async () => {
await observability.users.setTestUserRole(
observability.users.defineBasicObservabilityRole({
observabilityCases: ['read'],
logs: ['read'],
})
);
await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs');
await observability.alerts.common.navigateToTimeWithData();
});
describe('Actions Button', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs');
await observability.alerts.common.navigateToTimeWithData();
});
after(async () => {
await observability.users.restoreDefaultTestUserRole();
await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs');
});
it('Is disabled when a user has only read privilages', async () => {
const actionsButton = await observability.alerts.common.getActionsButtonByIndex(0);
expect(await actionsButton.getAttribute('disabled')).to.be('true');
it('Opens rule details page when click on "View Rule Details"', async () => {
const actionsButton = await observability.alerts.common.getActionsButtonByIndex(0);
await actionsButton.click();
await observability.alerts.common.viewRuleDetailsButtonClick();
expect(await find.existsByCssSelector('[title="Rules and Connectors"]')).to.eql(true);
});
});
});
});