[Alerting] License Errors on Alert List View (#89920)

* Adding tooltips to alert list and modal for license upgrade

* Fixing typings

* Custom License Error status. Moving modal to alerts list page

* Adding unit test

* Cleanup

* Unit tests

* Removing tooltip from alert name

* License

* PR fixes

* Updating modal wording

* Updating license state error message

* i18n fix

* Fixing functional test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
ymao1 2021-02-10 08:06:09 -05:00 committed by GitHub
parent ce441bdc32
commit 3e91bc728d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 355 additions and 149 deletions

View file

@ -248,7 +248,7 @@ describe('ensureLicenseForAlertType()', () => {
expect(() =>
licenseState.ensureLicenseForAlertType(alertType)
).toThrowErrorMatchingInlineSnapshot(
`"Alert test is disabled because it requires a Gold license. Contact your administrator to upgrade your license."`
`"Alert test is disabled because it requires a Gold license. Go to License Management to view upgrade options."`
);
});

View file

@ -9,6 +9,7 @@ import Boom from '@hapi/boom';
import { i18n } from '@kbn/i18n';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { assertNever } from '@kbn/std';
import { capitalize } from 'lodash';
import { Observable, Subscription } from 'rxjs';
import { LicensingPluginStart } from '../../../licensing/server';
import { ILicense, LicenseType } from '../../../licensing/common/types';
@ -190,8 +191,11 @@ export class LicenseState {
throw new AlertTypeDisabledError(
i18n.translate('xpack.alerts.serverSideErrors.invalidLicenseErrorMessage', {
defaultMessage:
'Alert {alertTypeId} is disabled because it requires a Gold license. Contact your administrator to upgrade your license.',
values: { alertTypeId: alertType.id },
'Alert {alertTypeId} is disabled because it requires a {licenseType} license. Go to License Management to view upgrade options.',
values: {
alertTypeId: alertType.id,
licenseType: capitalize(alertType.minimumLicenseRequired),
},
}),
'license_invalid'
);

View file

@ -4830,7 +4830,6 @@
"xpack.alerts.server.healthStatus.degraded": "アラートフレームワークは劣化しました",
"xpack.alerts.server.healthStatus.unavailable": "アラートフレームワークを使用できません",
"xpack.alerts.serverSideErrors.expirerdLicenseErrorMessage": "{licenseType} ライセンスの期限が切れたのでアラートタイプ {alertTypeId} は無効です。",
"xpack.alerts.serverSideErrors.invalidLicenseErrorMessage": "アラート {alertTypeId} は無効です。Gold ライセンスが必要です。ライセンスをアップグレードするには、管理者に問い合わせてください。",
"xpack.alerts.serverSideErrors.unavailableLicenseErrorMessage": "現時点でライセンス情報を入手できないため、アラートタイプ {alertTypeId} は無効です。",
"xpack.alerts.serverSideErrors.unavailableLicenseInformationErrorMessage": "アラートを利用できません。現在ライセンス情報が利用できません。",
"xpack.apm.a.thresholdMet": "しきい値一致",

View file

@ -4836,7 +4836,6 @@
"xpack.alerts.server.healthStatus.degraded": "告警框架已降级",
"xpack.alerts.server.healthStatus.unavailable": "告警框架不可用",
"xpack.alerts.serverSideErrors.expirerdLicenseErrorMessage": "告警类型 {alertTypeId} 已禁用,因为您的{licenseType}许可证已过期。",
"xpack.alerts.serverSideErrors.invalidLicenseErrorMessage": "告警 {alertTypeId} 已禁用,因为它需要黄金级许可证。请联系管理员升级您的许可证。",
"xpack.alerts.serverSideErrors.unavailableLicenseErrorMessage": "告警类型 {alertTypeId} 已禁用,因为许可证信息当前不可用。",
"xpack.alerts.serverSideErrors.unavailableLicenseInformationErrorMessage": "告警不可用 - 许可信息当前不可用。",
"xpack.apm.a.thresholdMet": "已达到阈值",

View file

@ -127,11 +127,16 @@ describe('alerts_list component empty', () => {
wrapper.find('button[data-test-subj="createFirstAlertButton"]').simulate('click');
// When the AlertAdd component is rendered, it waits for the healthcheck to resolve
await new Promise((resolve) => {
setTimeout(resolve, 1000);
await act(async () => {
// When the AlertAdd component is rendered, it waits for the healthcheck to resolve
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
await nextTick();
wrapper.update();
});
wrapper.update();
expect(wrapper.find('AlertAdd').exists()).toEqual(true);
});
});
@ -139,104 +144,131 @@ describe('alerts_list component empty', () => {
describe('alerts_list component with items', () => {
let wrapper: ReactWrapper<any>;
const mockedAlertsData = [
{
id: '1',
name: 'test alert',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'active',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
},
{
id: '2',
name: 'test alert ok',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'ok',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
},
{
id: '3',
name: 'test alert pending',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'pending',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
},
{
id: '4',
name: 'test alert error',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: AlertExecutionStatusErrorReasons.Unknown,
message: 'test',
},
},
},
{
id: '5',
name: 'test alert license error',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: AlertExecutionStatusErrorReasons.License,
message: 'test',
},
},
},
];
async function setup() {
loadAlerts.mockResolvedValue({
page: 1,
perPage: 10000,
total: 4,
data: [
{
id: '1',
name: 'test alert',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'active',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
},
{
id: '2',
name: 'test alert ok',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'ok',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
},
{
id: '3',
name: 'test alert pending',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'pending',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: null,
},
},
{
id: '4',
name: 'test alert error',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'error',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: AlertExecutionStatusErrorReasons.Unknown,
message: 'test',
},
},
},
],
data: mockedAlertsData,
});
loadActionTypes.mockResolvedValue([
{
@ -271,21 +303,66 @@ describe('alerts_list component with items', () => {
it('renders table of alerts', async () => {
await setup();
expect(wrapper.find('EuiBasicTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(4);
expect(wrapper.find('[data-test-subj="alertsTableCell-status"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="alertStatus-active"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="alertStatus-error"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="alertStatus-ok"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="alertStatus-pending"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="alertStatus-unknown"]').length).toBe(0);
expect(wrapper.find('EuiTableRow')).toHaveLength(mockedAlertsData.length);
expect(wrapper.find('EuiTableRowCell[data-test-subj="alertsTableCell-status"]').length).toEqual(
mockedAlertsData.length
);
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-active"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-ok"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-pending"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-unknown"]').length).toEqual(0);
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-error"]').length).toEqual(2);
expect(wrapper.find('[data-test-subj="alertStatus-error-tooltip"]').length).toEqual(2);
expect(
wrapper.find('EuiButtonEmpty[data-test-subj="alertStatus-error-license-fix"]').length
).toEqual(1);
expect(wrapper.find('[data-test-subj="refreshAlertsButton"]').exists()).toBeTruthy();
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-error"]').first().text()).toEqual(
'Error'
);
expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-error"]').last().text()).toEqual(
'License Error'
);
});
it('loads alerts when refresh button is clicked', async () => {
await setup();
wrapper.find('[data-test-subj="refreshAlertsButton"]').first().simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadAlerts).toHaveBeenCalled();
});
it('renders license errors and manage license modal on click', async () => {
global.open = jest.fn();
await setup();
expect(wrapper.find('ManageLicenseModal').exists()).toBeFalsy();
expect(
wrapper.find('EuiButtonEmpty[data-test-subj="alertStatus-error-license-fix"]').length
).toEqual(1);
wrapper
.find('EuiButtonEmpty[data-test-subj="alertStatus-error-license-fix"]')
.simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('ManageLicenseModal').exists()).toBeTruthy();
expect(wrapper.find('EuiButton[data-test-subj="confirmModalConfirmButton"]').text()).toEqual(
'Manage license'
);
wrapper.find('EuiButton[data-test-subj="confirmModalConfirmButton"]').simulate('click');
expect(global.open).toHaveBeenCalled();
});
});
describe('alerts_list component empty with show only capability', () => {
@ -308,7 +385,9 @@ describe('alerts_list component empty with show only capability', () => {
name: 'Test2',
},
]);
loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]);
loadAlertTypes.mockResolvedValue([
{ id: 'test_alert_type', name: 'some alert type', authorizedConsumers: {} },
]);
loadAllActions.mockResolvedValue([]);
// eslint-disable-next-line react-hooks/rules-of-hooks
useKibanaMock().services.alertTypeRegistry = alertTypeRegistry;

View file

@ -53,14 +53,15 @@ import {
AlertExecutionStatus,
AlertExecutionStatusValues,
ALERTS_FEATURE_ID,
AlertExecutionStatusErrorReasons,
} from '../../../../../../alerts/common';
import { hasAllPrivilege } from '../../../lib/capabilities';
import { alertsStatusesTranslationsMapping } from '../translations';
import { alertsStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations';
import { useKibana } from '../../../../common/lib/kibana';
import { checkAlertTypeEnabled } from '../../../lib/check_alert_type_enabled';
import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants';
import './alerts_list.scss';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import { ManageLicenseModal } from './manage_license_modal';
const ENTER_KEY = 13;
@ -97,7 +98,11 @@ export const AlertsList: React.FunctionComponent = () => {
const [actionTypesFilter, setActionTypesFilter] = useState<string[]>([]);
const [alertStatusesFilter, setAlertStatusesFilter] = useState<string[]>([]);
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false);
const [dissmissAlertErrors, setDissmissAlertErrors] = useState<boolean>(false);
const [dismissAlertErrors, setDismissAlertErrors] = useState<boolean>(false);
const [manageLicenseModalOpts, setManageLicenseModalOpts] = useState<{
licenseType: string;
alertTypeId: string;
} | null>(null);
const [alertsStatusesTotal, setAlertsStatusesTotal] = useState<Record<string, number>>(
AlertExecutionStatusValues.reduce(
(prev: Record<string, number>, status: string) =>
@ -238,25 +243,64 @@ export const AlertsList: React.FunctionComponent = () => {
}
}
const renderAlertExecutionStatus = (
executionStatus: AlertExecutionStatus,
item: AlertTableItem
) => {
const healthColor = getHealthColor(executionStatus.status);
const tooltipMessage =
executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null;
const isLicenseError =
executionStatus.error?.reason === AlertExecutionStatusErrorReasons.License;
const statusMessage = isLicenseError
? ALERT_STATUS_LICENSE_ERROR
: alertsStatusesTranslationsMapping[executionStatus.status];
const health = (
<EuiHealth data-test-subj={`alertStatus-${executionStatus.status}`} color={healthColor}>
{statusMessage}
</EuiHealth>
);
const healthWithTooltip = tooltipMessage ? (
<EuiToolTip
data-test-subj="alertStatus-error-tooltip"
position="top"
content={tooltipMessage}
>
{health}
</EuiToolTip>
) : (
health
);
return (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>{healthWithTooltip}</EuiFlexItem>
{isLicenseError && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
data-test-subj="alertStatus-error-license-fix"
onClick={() =>
setManageLicenseModalOpts({
licenseType: alertTypesState.data.get(item.alertTypeId)?.minimumLicenseRequired!,
alertTypeId: item.alertTypeId,
})
}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.fixLicenseLink"
defaultMessage="Fix"
/>
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
const alertsTableColumns = [
{
field: 'executionStatus',
name: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.statusTitle',
{ defaultMessage: 'Status' }
),
sortable: false,
truncateText: false,
'data-test-subj': 'alertsTableCell-status',
render: (executionStatus: AlertExecutionStatus) => {
const healthColor = getHealthColor(executionStatus.status);
return (
<EuiHealth data-test-subj={`alertStatus-${executionStatus.status}`} color={healthColor}>
{alertsStatusesTranslationsMapping[executionStatus.status]}
</EuiHealth>
);
},
},
{
field: 'name',
name: i18n.translate(
@ -265,12 +309,10 @@ export const AlertsList: React.FunctionComponent = () => {
),
sortable: false,
truncateText: true,
width: '35%',
'data-test-subj': 'alertsTableCell-name',
render: (name: string, alert: AlertTableItem) => {
const checkEnabledResult = checkAlertTypeEnabled(
alertTypesState.data.get(alert.alertTypeId)
);
const link = (
return (
<EuiLink
title={name}
onClick={() => {
@ -280,17 +322,20 @@ export const AlertsList: React.FunctionComponent = () => {
{name}
</EuiLink>
);
return checkEnabledResult.isEnabled ? (
link
) : (
<EuiToolTip
position="top"
data-test-subj={`${alert.id}-disabledTooltip`}
content={checkEnabledResult.message}
>
{link}
</EuiToolTip>
);
},
},
{
field: 'executionStatus',
name: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.statusTitle',
{ defaultMessage: 'Status' }
),
sortable: false,
truncateText: false,
width: '150px',
'data-test-subj': 'alertsTableCell-status',
render: (executionStatus: AlertExecutionStatus, item: AlertTableItem) => {
return renderAlertExecutionStatus(executionStatus, item);
},
},
{
@ -492,7 +537,7 @@ export const AlertsList: React.FunctionComponent = () => {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{!dissmissAlertErrors && alertsStatusesTotal.error > 0 ? (
{!dismissAlertErrors && alertsStatusesTotal.error > 0 ? (
<EuiFlexGroup>
<EuiFlexItem>
<EuiCallOut
@ -521,7 +566,7 @@ export const AlertsList: React.FunctionComponent = () => {
defaultMessage="View"
/>
</EuiButton>
<EuiButtonEmpty color="danger" onClick={() => setDissmissAlertErrors(true)}>
<EuiButtonEmpty color="danger" onClick={() => setDismissAlertErrors(true)}>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.dismissBunnerButtonLabel"
defaultMessage="Dismiss"
@ -639,6 +684,17 @@ export const AlertsList: React.FunctionComponent = () => {
setPage(changedPage);
}}
/>
{manageLicenseModalOpts && (
<ManageLicenseModal
licenseType={manageLicenseModalOpts.licenseType}
alertTypeId={manageLicenseModalOpts.alertTypeId}
onConfirm={() => {
window.open(`${http.basePath.get()}/app/management/stack/license_management`, '_blank');
setManageLicenseModalOpts(null);
}}
onCancel={() => setManageLicenseModalOpts(null)}
/>
)}
</Fragment>
);

View file

@ -0,0 +1,62 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui';
import { capitalize } from 'lodash';
interface Props {
licenseType: string;
alertTypeId: string;
onConfirm: () => void;
onCancel: () => void;
}
export const ManageLicenseModal: React.FC<Props> = ({
licenseType,
alertTypeId,
onConfirm,
onCancel,
}) => {
const licenseRequired = capitalize(licenseType);
return (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.triggersActionsUI.sections.manageLicense.manageLicenseTitle', {
defaultMessage: '{licenseRequired} license required',
values: { licenseRequired },
})}
onCancel={onCancel}
onConfirm={onConfirm}
confirmButtonText={i18n.translate(
'xpack.triggersActionsUI.sections.manageLicense.manageLicenseConfirmButtonText',
{
defaultMessage: 'Manage license',
}
)}
cancelButtonText={i18n.translate(
'xpack.triggersActionsUI.sections.manageLicense.manageLicenseCancelButtonText',
{
defaultMessage: 'Cancel',
}
)}
defaultFocusedButton="confirm"
data-test-subj="manageLicenseModal"
>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.manageLicense.manageLicenseMessage"
defaultMessage="Alert {alertTypeId} is disabled because it requires a {licenseRequired} license. Continue to License Management to view upgrade options."
values={{ alertTypeId, licenseRequired }}
/>
</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -28,6 +28,13 @@ export const ALERT_STATUS_ERROR = i18n.translate(
}
);
export const ALERT_STATUS_LICENSE_ERROR = i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertStatusLicenseError',
{
defaultMessage: 'License Error',
}
);
export const ALERT_STATUS_PENDING = i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertStatusPending',
{

View file

@ -22,7 +22,7 @@ export default function emailTest({ getService }: FtrProviderContext) {
statusCode: 403,
error: 'Forbidden',
message:
'Alert test.gold.noop is disabled because it requires a Gold license. Contact your administrator to upgrade your license.',
'Alert test.gold.noop is disabled because it requires a Gold license. Go to License Management to view upgrade options.',
});
});
});