diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index d5711a3e8c919a..6dfba82bdf5c97 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -11,13 +11,7 @@ import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerts/common'; import { BASE_ALERT_API_PATH } from '../constants'; -import { - Alert, - AlertType, - AlertWithoutId, - AlertTaskState, - AlertInstanceSummary, -} from '../../types'; +import { Alert, AlertType, AlertUpdates, AlertTaskState, AlertInstanceSummary } from '../../types'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`); @@ -70,12 +64,14 @@ export async function loadAlerts({ searchText, typesFilter, actionTypesFilter, + alertStatusesFilter, }: { http: HttpSetup; page: { index: number; size: number }; searchText?: string; typesFilter?: string[]; actionTypesFilter?: string[]; + alertStatusesFilter?: string[]; }): Promise<{ page: number; perPage: number; @@ -97,6 +93,9 @@ export async function loadAlerts({ ].join('') ); } + if (alertStatusesFilter && alertStatusesFilter.length) { + filters.push(`alert.attributes.executionStatus.status:(${alertStatusesFilter.join(' or ')})`); + } return await http.get(`${BASE_ALERT_API_PATH}/_find`, { query: { page: page.index + 1, @@ -137,7 +136,7 @@ export async function createAlert({ }: { http: HttpSetup; alert: Omit< - AlertWithoutId, + AlertUpdates, 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus' >; }): Promise { @@ -152,7 +151,7 @@ export async function updateAlert({ id, }: { http: HttpSetup; - alert: Pick; + alert: Pick; id: string; }): Promise { return await http.put(`${BASE_ALERT_API_PATH}/alert/${id}`, { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 5c9969221cfc35..51c3e030f44eb1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -15,6 +15,7 @@ import { EuiSwitch, EuiBetaBadge, EuiButtonEmpty, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; @@ -142,6 +143,38 @@ describe('alert_details', () => { ).toBeTruthy(); }); + it('renders the alert error banner with error message, when alert status is an error', () => { + const alert = mockAlert({ + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: 'unknown', + message: 'test', + }, + }, + }); + const alertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [], params: [] }, + defaultActionGroupId: 'default', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, + }; + + expect( + shallow( + + ).containsMatchingElement( + + {'test'} + + ) + ).toBeTruthy(); + }); + describe('actions', () => { it('renders an alert action', () => { const alert = mockAlert({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 6ee7915e2be71f..42a25b399ddd35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -24,6 +24,7 @@ import { EuiSpacer, EuiBetaBadge, EuiButtonEmpty, + EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -42,6 +43,7 @@ import { PLUGIN } from '../../../constants/plugin'; import { AlertEdit } from '../../alert_form'; import { AlertsContextProvider } from '../../../context/alerts_context'; import { routeToAlertDetails } from '../../../constants'; +import { alertsErrorReasonTranslationsMapping } from '../../alerts_list/translations'; type AlertDetailsProps = { alert: Alert; @@ -105,11 +107,20 @@ export const AlertDetails: React.FunctionComponent = ({ const [isEnabled, setIsEnabled] = useState(alert.enabled); const [isMuted, setIsMuted] = useState(alert.muteAll); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + const [dissmissAlertErrors, setDissmissAlertErrors] = useState(false); const setAlert = async () => { history.push(routeToAlertDetails.replace(`:alertId`, alert.id)); }; + const getAlertStatusErrorReasonText = () => { + if (alert.executionStatus.error && alert.executionStatus.error.reason) { + return alertsErrorReasonTranslationsMapping[alert.executionStatus.error.reason]; + } else { + return alertsErrorReasonTranslationsMapping.unknown; + } + }; + return ( @@ -275,6 +286,30 @@ export const AlertDetails: React.FunctionComponent = ({ + {!dissmissAlertErrors && alert.executionStatus.status === 'error' ? ( + + + + + {alert.executionStatus.error?.message} + + + setDissmissAlertErrors(true)}> + + + + + + ) : null} {alert.enabled ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alert_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alert_status_filter.tsx new file mode 100644 index 00000000000000..87e7a82cd8f233 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alert_status_filter.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiFilterSelectItem, + EuiHealth, +} from '@elastic/eui'; +import { + AlertExecutionStatuses, + AlertExecutionStatusValues, +} from '../../../../../../alerts/common'; +import { alertsStatusesTranslationsMapping } from '../translations'; + +interface AlertStatusFilterProps { + selectedStatuses: string[]; + onChange?: (selectedAlertStatusesIds: string[]) => void; +} + +export const AlertStatusFilter: React.FunctionComponent = ({ + selectedStatuses, + onChange, +}: AlertStatusFilterProps) => { + const [selectedValues, setSelectedValues] = useState(selectedStatuses); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + useEffect(() => { + setSelectedValues(selectedStatuses); + }, [selectedStatuses]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > +
+ {[...AlertExecutionStatusValues].sort().map((item: AlertExecutionStatuses) => { + const healthColor = getHealthColor(item); + return ( + { + const isPreviouslyChecked = selectedValues.includes(item); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item)); + } else { + setSelectedValues(selectedValues.concat(item)); + } + }} + checked={selectedValues.includes(item) ? 'on' : undefined} + > + {alertsStatusesTranslationsMapping[item]} + + ); + })} +
+
+
+ ); +}; + +export function getHealthColor(status: AlertExecutionStatuses) { + switch (status) { + case 'active': + return 'primary'; + case 'error': + return 'danger'; + case 'ok': + return 'subdued'; + case 'pending': + return 'success'; + default: + return 'warning'; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index ada881cb93d1ef..86b9afd9565f87 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -150,7 +150,7 @@ describe('alerts_list component with items', () => { loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, - total: 2, + total: 4, data: [ { id: '1', @@ -168,10 +168,59 @@ describe('alerts_list component with items', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, }, { id: '2', - name: 'test alert 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', @@ -185,6 +234,14 @@ describe('alerts_list component with items', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: 'unknown', + message: 'test', + }, + }, }, ], }); @@ -245,7 +302,13 @@ 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(2); + 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); }); }); @@ -351,6 +414,11 @@ describe('alerts_list with show only capability', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, }, { id: '2', @@ -368,6 +436,11 @@ describe('alerts_list with show only capability', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, }, ], }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 07e0fd7ae19e55..95082bc6ca99f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react-hooks/exhaustive-deps */ + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useEffect, useState, Fragment } from 'react'; @@ -19,6 +21,10 @@ import { EuiLink, EuiLoadingSpinner, EuiEmptyPrompt, + EuiCallOut, + EuiButtonEmpty, + EuiHealth, + EuiText, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; @@ -32,14 +38,20 @@ import { AlertQuickEditButtonsWithApi as AlertQuickEditButtons } from '../../com import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; +import { AlertStatusFilter, getHealthColor } from './alert_status_filter'; import { loadAlerts, loadAlertTypes, deleteAlerts } from '../../../lib/alert_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; import { hasExecuteActionsCapability } from '../../../lib/capabilities'; import { routeToAlertDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; -import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; +import { + AlertExecutionStatus, + AlertExecutionStatusValues, + ALERTS_FEATURE_ID, +} from '../../../../../../alerts/common'; import { hasAllPrivilege } from '../../../lib/capabilities'; +import { alertsStatusesTranslationsMapping } from '../translations'; const ENTER_KEY = 13; @@ -77,7 +89,19 @@ export const AlertsList: React.FunctionComponent = () => { const [inputText, setInputText] = useState(); const [typesFilter, setTypesFilter] = useState([]); const [actionTypesFilter, setActionTypesFilter] = useState([]); + const [alertStatusesFilter, setAlertStatusesFilter] = useState([]); const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + const [dissmissAlertErrors, setDissmissAlertErrors] = useState(false); + const [alertsStatusesTotal, setAlertsStatusesTotal] = useState>( + AlertExecutionStatusValues.reduce( + (prev: Record, status: string) => + ({ + ...prev, + [status]: 0, + } as Record), + {} + ) + ); const [alertTypesState, setAlertTypesState] = useState({ isLoading: false, isInitialized: false, @@ -92,13 +116,14 @@ export const AlertsList: React.FunctionComponent = () => { useEffect(() => { loadAlertsData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertTypesState, page, searchText]); - - useEffect(() => { - loadAlertsData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(typesFilter), JSON.stringify(actionTypesFilter)]); + }, [ + alertTypesState, + page, + searchText, + JSON.stringify(typesFilter), + JSON.stringify(actionTypesFilter), + JSON.stringify(alertStatusesFilter), + ]); useEffect(() => { (async () => { @@ -120,7 +145,6 @@ export const AlertsList: React.FunctionComponent = () => { setAlertTypesState({ ...alertTypesState, isLoading: false }); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -137,7 +161,6 @@ export const AlertsList: React.FunctionComponent = () => { }); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function loadAlertsData() { @@ -151,7 +174,9 @@ export const AlertsList: React.FunctionComponent = () => { searchText, typesFilter, actionTypesFilter, + alertStatusesFilter, }); + await loadAlertsTotalStatuses(); setAlertsState({ isLoading: false, data: alertsResponse.data, @@ -175,7 +200,52 @@ export const AlertsList: React.FunctionComponent = () => { } } + async function loadAlertsTotalStatuses() { + let alertsStatuses = {}; + try { + AlertExecutionStatusValues.forEach(async (status: string) => { + const alertsTotalResponse = await loadAlerts({ + http, + page: { index: 0, size: 0 }, + searchText, + typesFilter, + actionTypesFilter, + alertStatusesFilter: [status], + }); + setAlertsStatusesTotal({ ...alertsStatuses, [status]: alertsTotalResponse.total }); + alertsStatuses = { ...alertsStatuses, [status]: alertsTotalResponse.total }; + }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsStatusesInfoMessage', + { + defaultMessage: 'Unable to load alert statuses info', + } + ), + }); + } + } + 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 ( + + {alertsStatusesTranslationsMapping[executionStatus.status]} + + ); + }, + }, { field: 'name', name: i18n.translate( @@ -280,24 +350,13 @@ export const AlertsList: React.FunctionComponent = () => { actionTypes={actionTypes} onChange={(ids: string[]) => setActionTypesFilter(ids)} />, + setAlertStatusesFilter(ids)} + />, ]; - if (authorizedToCreateAnyAlerts) { - toolsRight.push( - setAlertFlyoutVisibility(true)} - > - - - ); - } - const authorizedToModifySelectedAlerts = selectedIds.length ? filterAlertsById(alertsState.data, selectedIds).every((selectedAlert) => hasAllPrivilege(selectedAlert, alertTypesState.data.get(selectedAlert.alertTypeId)) @@ -326,6 +385,21 @@ export const AlertsList: React.FunctionComponent = () => {
)} + {authorizedToCreateAnyAlerts ? ( + + setAlertFlyoutVisibility(true)} + > + + + + ) : null} {
- + + {!dissmissAlertErrors && alertsStatusesTotal.error > 0 ? ( + + + + } + iconType="alert" + > + setAlertStatusesFilter(['error'])} + > + + + setDissmissAlertErrors(true)}> + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Large to remain consistent with ActionsList table spacing */} @@ -402,7 +587,8 @@ export const AlertsList: React.FunctionComponent = () => { const isFilterApplied = !( isEmpty(searchText) && isEmpty(typesFilter) && - isEmpty(actionTypesFilter) + isEmpty(actionTypesFilter) && + isEmpty(alertStatusesFilter) ); return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts new file mode 100644 index 00000000000000..dbcf2d6854af5e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERT_STATUS_OK = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusOk', + { + defaultMessage: 'Ok', + } +); + +export const ALERT_STATUS_ACTIVE = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusActive', + { + defaultMessage: 'Active', + } +); + +export const ALERT_STATUS_ERROR = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusError', + { + defaultMessage: 'Error', + } +); + +export const ALERT_STATUS_PENDING = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusPending', + { + defaultMessage: 'Pending', + } +); + +export const ALERT_STATUS_UNKNOWN = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusUnknown', + { + defaultMessage: 'Unknown', + } +); + +export const alertsStatusesTranslationsMapping = { + ok: ALERT_STATUS_OK, + active: ALERT_STATUS_ACTIVE, + error: ALERT_STATUS_ERROR, + pending: ALERT_STATUS_PENDING, + unknown: ALERT_STATUS_UNKNOWN, +}; + +export const ALERT_ERROR_UNKNOWN_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonUnknown', + { + defaultMessage: 'An error occurred for unknown reasons.', + } +); + +export const ALERT_ERROR_READING_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonReading', + { + defaultMessage: 'An error occurred when reading the alert.', + } +); + +export const ALERT_ERROR_DECRYPTING_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonDecrypting', + { + defaultMessage: 'An error occurred when decrypting the alert.', + } +); + +export const ALERT_ERROR_EXECUTION_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonRunning', + { + defaultMessage: 'An error occurred when running the alert.', + } +); + +export const alertsErrorReasonTranslationsMapping = { + read: ALERT_ERROR_READING_REASON, + decrypt: ALERT_ERROR_DECRYPTING_REASON, + execute: ALERT_ERROR_EXECUTION_REASON, + unknown: ALERT_ERROR_UNKNOWN_REASON, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index c551746fdec0c0..148facdee248d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -147,7 +147,7 @@ export interface AlertType { export type SanitizedAlertType = Omit; -export type AlertWithoutId = Omit; +export type AlertUpdates = Omit; export interface AlertTableItem extends Alert { alertType: AlertType['name'];