[RAM] Update Rule Status - UI Changes (#144466)

## Summary
Parent issue for updating rule status:
https://github.com/elastic/kibana/issues/136039
Frontend issue: https://github.com/elastic/kibana/issues/145191

Backend PR: https://github.com/elastic/kibana/pull/140882

Updates the rules list and rules details page to support the new
consolidated statuses. With E2E and unit testing.

Rules list:
- Table cell values
- Last response filter
- Table cell filtering
- Status aggregations

Rule details:
- Rule status summary
- KPI headers renaming
- Event log cells renaming


![dashdash](https://user-images.githubusercontent.com/74562234/201778676-775f58e9-6707-4972-a1ca-2dcf71befc5b.png)


![rule_details_consolidate](https://user-images.githubusercontent.com/74562234/201778792-f03c368a-3b0d-43cf-805e-f8151b4b96ae.png)

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2022-11-15 15:29:03 -08:00 committed by GitHub
parent 9cd5c0d2da
commit 256e1f8bd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1255 additions and 289 deletions

View file

@ -40,7 +40,7 @@ const getMockRule = () => {
error: null,
},
monitoring: {
execution: {
run: {
history: [
{
success: true,

View file

@ -88,6 +88,7 @@ export const StorybookContextDecorator: React.FC<StorybookContextDecoratorProps>
ruleTagFilter: true,
ruleStatusFilter: true,
rulesDetailLogs: true,
ruleLastRunOutcome: true,
},
});
return (

View file

@ -17,6 +17,7 @@ export const allowedExperimentalValues = Object.freeze({
ruleTagFilter: true,
ruleStatusFilter: true,
rulesDetailLogs: true,
ruleLastRunOutcome: true,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -77,6 +77,7 @@ interface UseBulkEditSelectProps {
actionTypesFilter?: string[];
tagsFilter?: string[];
ruleExecutionStatusesFilter?: string[];
ruleLastRunOutcomesFilter?: string[];
ruleStatusesFilter?: RuleStatus[];
searchText?: string;
}
@ -89,6 +90,7 @@ export function useBulkEditSelect(props: UseBulkEditSelectProps) {
actionTypesFilter,
tagsFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
searchText,
} = props;
@ -187,6 +189,7 @@ export function useBulkEditSelect(props: UseBulkEditSelectProps) {
actionTypesFilter,
tagsFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
searchText,
});
@ -208,6 +211,7 @@ export function useBulkEditSelect(props: UseBulkEditSelectProps) {
actionTypesFilter,
tagsFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
searchText,
]

View file

@ -7,11 +7,21 @@
import { i18n } from '@kbn/i18n';
import { useState, useCallback, useMemo } from 'react';
import { RuleExecutionStatusValues } from '@kbn/alerting-plugin/common';
import { RuleExecutionStatusValues, RuleLastRunOutcomeValues } from '@kbn/alerting-plugin/common';
import type { LoadRuleAggregationsProps } from '../lib/rule_api';
import { loadRuleAggregationsWithKueryFilter } from '../lib/rule_api/aggregate_kuery_filter';
import { useKibana } from '../../common/lib/kibana';
const initializeAggregationResult = (values: readonly string[]) => {
return values.reduce<Record<string, number>>(
(prev: Record<string, number>, status: string) => ({
...prev,
[status]: 0,
}),
{}
);
};
type UseLoadRuleAggregationsProps = Omit<LoadRuleAggregationsProps, 'http'> & {
onError: (message: string) => void;
};
@ -21,6 +31,7 @@ export function useLoadRuleAggregations({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
onError,
@ -28,15 +39,13 @@ export function useLoadRuleAggregations({
const { http } = useKibana().services;
const [rulesStatusesTotal, setRulesStatusesTotal] = useState<Record<string, number>>(
RuleExecutionStatusValues.reduce<Record<string, number>>(
(prev: Record<string, number>, status: string) => ({
...prev,
[status]: 0,
}),
{}
)
initializeAggregationResult(RuleExecutionStatusValues)
);
const [rulesLastRunOutcomesTotal, setRulesLastRunOutcomesTotal] = useState<
Record<string, number>
>(initializeAggregationResult(RuleLastRunOutcomeValues));
const internalLoadRuleAggregations = useCallback(async () => {
try {
const rulesAggs = await loadRuleAggregationsWithKueryFilter({
@ -45,12 +54,16 @@ export function useLoadRuleAggregations({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
});
if (rulesAggs?.ruleExecutionStatus) {
setRulesStatusesTotal(rulesAggs.ruleExecutionStatus);
}
if (rulesAggs?.ruleLastRunOutcome) {
setRulesLastRunOutcomesTotal(rulesAggs.ruleLastRunOutcome);
}
} catch (e) {
onError(
i18n.translate(
@ -67,18 +80,28 @@ export function useLoadRuleAggregations({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
onError,
setRulesStatusesTotal,
setRulesLastRunOutcomesTotal,
]);
return useMemo(
() => ({
loadRuleAggregations: internalLoadRuleAggregations,
rulesStatusesTotal,
rulesLastRunOutcomesTotal,
setRulesStatusesTotal,
setRulesLastRunOutcomesTotal,
}),
[internalLoadRuleAggregations, rulesStatusesTotal, setRulesStatusesTotal]
[
internalLoadRuleAggregations,
rulesStatusesTotal,
rulesLastRunOutcomesTotal,
setRulesStatusesTotal,
setRulesLastRunOutcomesTotal,
]
);
}

View file

@ -89,6 +89,7 @@ export function useLoadRules({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
sort,
@ -120,6 +121,7 @@ export function useLoadRules({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
sort,
@ -144,6 +146,7 @@ export function useLoadRules({
hasEmptyTypesFilter &&
isEmpty(actionTypesFilter) &&
isEmpty(ruleExecutionStatusesFilter) &&
isEmpty(ruleLastRunOutcomesFilter) &&
isEmpty(ruleStatusesFilter) &&
isEmpty(tagsFilter)
);
@ -168,6 +171,7 @@ export function useLoadRules({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
sort,

View file

@ -15,6 +15,7 @@ export interface RuleTagsAggregations {
export const rewriteBodyRes: RewriteRequestCase<RuleAggregations> = ({
rule_execution_status: ruleExecutionStatus,
rule_last_run_outcome: ruleLastRunOutcome,
rule_enabled_status: ruleEnabledStatus,
rule_muted_status: ruleMutedStatus,
rule_snoozed_status: ruleSnoozedStatus,
@ -26,6 +27,7 @@ export const rewriteBodyRes: RewriteRequestCase<RuleAggregations> = ({
ruleEnabledStatus,
ruleMutedStatus,
ruleSnoozedStatus,
ruleLastRunOutcome,
ruleTags,
});
@ -41,6 +43,7 @@ export interface LoadRuleAggregationsProps {
typesFilter?: string[];
actionTypesFilter?: string[];
ruleExecutionStatusesFilter?: string[];
ruleLastRunOutcomesFilter?: string[];
ruleStatusesFilter?: RuleStatus[];
tagsFilter?: string[];
}

View file

@ -6,7 +6,7 @@
*/
import { RuleExecutionStatus } from '@kbn/alerting-plugin/common';
import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import { Rule, RuleAction, ResolvedRule } from '../../../types';
import { Rule, RuleAction, ResolvedRule, RuleLastRun } from '../../../types';
const transformAction: RewriteRequestCase<RuleAction> = ({
group,
@ -30,6 +30,16 @@ const transformExecutionStatus: RewriteRequestCase<RuleExecutionStatus> = ({
...rest,
});
const transformLastRun: RewriteRequestCase<RuleLastRun> = ({
outcome_msg: outcomeMsg,
alerts_count: alertsCount,
...rest
}) => ({
outcomeMsg,
alertsCount,
...rest,
});
export const transformRule: RewriteRequestCase<Rule> = ({
rule_type_id: ruleTypeId,
created_by: createdBy,
@ -46,6 +56,8 @@ export const transformRule: RewriteRequestCase<Rule> = ({
snooze_schedule: snoozeSchedule,
is_snoozed_until: isSnoozedUntil,
active_snoozes: activeSnoozes,
last_run: lastRun,
next_run: nextRun,
...rest
}: any) => ({
ruleTypeId,
@ -65,6 +77,8 @@ export const transformRule: RewriteRequestCase<Rule> = ({
scheduledTaskId,
isSnoozedUntil,
activeSnoozes,
...(lastRun ? { lastRun: transformLastRun(lastRun) } : {}),
...(nextRun ? { nextRun } : {}),
...rest,
});

View file

@ -12,7 +12,13 @@ import { transformRule } from './common_transformations';
type RuleCreateBody = Omit<
RuleUpdates,
'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus'
| 'createdBy'
| 'updatedBy'
| 'muteAll'
| 'mutedInstanceIds'
| 'executionStatus'
| 'lastRun'
| 'nextRun'
>;
const rewriteBodyRequest: RewriteResponseCase<RuleCreateBody> = ({
ruleTypeId,

View file

@ -57,6 +57,30 @@ describe('mapFiltersToKueryNode', () => {
);
});
test('should handle ruleLastRunOutcomesFilter', () => {
expect(
toElasticsearchQuery(
mapFiltersToKueryNode({
ruleLastRunOutcomesFilter: ['succeeded'],
}) as KueryNode
)
).toEqual(
toElasticsearchQuery(fromKueryExpression('alert.attributes.lastRun.outcome: succeeded'))
);
expect(
toElasticsearchQuery(
mapFiltersToKueryNode({
ruleLastRunOutcomesFilter: ['succeeded', 'failed', 'warning'],
}) as KueryNode
)
).toEqual(
toElasticsearchQuery(
fromKueryExpression('alert.attributes.lastRun.outcome: (succeeded or failed or warning)')
)
);
});
test('should handle ruleStatusesFilter', () => {
expect(
toElasticsearchQuery(
@ -260,6 +284,7 @@ describe('mapFiltersToKueryNode', () => {
typesFilter: ['type', 'filter'],
actionTypesFilter: ['action', 'types', 'filter'],
ruleExecutionStatusesFilter: ['alert', 'statuses', 'filter'],
ruleLastRunOutcomesFilter: ['warning', 'failed'],
tagsFilter: ['a', 'b', 'c'],
}) as KueryNode
)
@ -271,6 +296,7 @@ describe('mapFiltersToKueryNode', () => {
alert.attributes.actions:{ actionTypeId:types } OR
alert.attributes.actions:{ actionTypeId:filter }) and
alert.attributes.executionStatus.status:(alert or statuses or filter) and
alert.attributes.lastRun.outcome:(warning or failed) and
alert.attributes.tags:(a or b or c)`
)
)

View file

@ -12,6 +12,7 @@ export const mapFiltersToKueryNode = ({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
searchText,
@ -20,6 +21,7 @@ export const mapFiltersToKueryNode = ({
actionTypesFilter?: string[];
tagsFilter?: string[];
ruleExecutionStatusesFilter?: string[];
ruleLastRunOutcomesFilter?: string[];
ruleStatusesFilter?: RuleStatus[];
searchText?: string;
}): KueryNode | null => {
@ -51,6 +53,16 @@ export const mapFiltersToKueryNode = ({
);
}
if (ruleLastRunOutcomesFilter && ruleLastRunOutcomesFilter.length) {
filterKueryNode.push(
nodeBuilder.or(
ruleLastRunOutcomesFilter.map((resf) =>
nodeBuilder.is('alert.attributes.lastRun.outcome', resf)
)
)
);
}
if (ruleStatusesFilter && ruleStatusesFilter.length) {
const snoozedFilter = nodeBuilder.or([
fromKueryExpression('alert.attributes.muteAll: true'),

View file

@ -18,6 +18,7 @@ export interface LoadRulesProps {
actionTypesFilter?: string[];
tagsFilter?: string[];
ruleExecutionStatusesFilter?: string[];
ruleLastRunOutcomesFilter?: string[];
ruleStatusesFilter?: RuleStatus[];
sort?: Sorting;
}

View file

@ -18,6 +18,7 @@ export async function loadRulesWithKueryFilter({
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
sort = { field: 'name', direction: 'asc' },
@ -32,6 +33,7 @@ export async function loadRulesWithKueryFilter({
actionTypesFilter,
tagsFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
searchText,
});

View file

@ -8,11 +8,7 @@
import React, { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiTabbedContent } from '@elastic/eui';
import {
ActionGroup,
RuleExecutionStatusErrorReasons,
AlertStatusValues,
} from '@kbn/alerting-plugin/common';
import { ActionGroup, AlertStatusValues } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import { Rule, RuleSummary, AlertStatus, RuleType } from '../../../../types';
import {
@ -20,15 +16,14 @@ import {
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
import './rule.scss';
import { getHealthColor } from '../../rules_list/components/rule_execution_status_filter';
import {
rulesStatusesTranslationsMapping,
ALERT_STATUS_LICENSE_ERROR,
} from '../../rules_list/translations';
import type { RuleEventLogListProps } from './rule_event_log_list';
import { AlertListItem } from './types';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
import {
getRuleHealthColor,
getRuleStatusMessage,
} from '../../../../common/lib/rule_status_helpers';
import RuleStatusPanelWithApi from './rule_status_panel';
const RuleEventLogList = lazy(() => import('./rule_event_log_list'));
@ -78,12 +73,8 @@ export function RuleComponent({
requestRefresh();
};
const healthColor = getHealthColor(rule.executionStatus.status);
const isLicenseError =
rule.executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License;
const statusMessage = isLicenseError
? ALERT_STATUS_LICENSE_ERROR
: rulesStatusesTranslationsMapping[rule.executionStatus.status];
const healthColor = getRuleHealthColor(rule);
const statusMessage = getRuleStatusMessage(rule);
const renderRuleAlertList = () => {
return suspendedComponentWithProps(

View file

@ -30,6 +30,7 @@ import {
executionLogSortableColumns,
ExecutionLogSortFields,
} from '@kbn/alerting-plugin/common';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { RuleEventLogListCellRenderer, ColumnId } from './rule_event_log_list_cell_renderer';
import { RuleEventLogPaginationStatus } from './rule_event_log_pagination_status';
import { RuleActionErrorBadge } from './rule_action_error_badge';
@ -174,6 +175,8 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
const { euiTheme } = useEuiTheme();
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
const getPaginatedRowIndex = useCallback(
(rowIndex: number) => {
const { pageIndex, pageSize } = pagination;
@ -621,6 +624,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
dateFormat={dateFormat}
ruleId={ruleId}
spaceIds={spaceIds}
lastRunOutcomeEnabled={isRuleLastRunOutcomeEnabled}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -32,10 +32,19 @@ interface RuleEventLogListCellRendererProps {
dateFormat?: string;
ruleId?: string;
spaceIds?: string[];
lastRunOutcomeEnabled?: boolean;
}
export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => {
const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT, ruleId, spaceIds } = props;
const {
columnId,
value,
version,
dateFormat = DEFAULT_DATE_FORMAT,
ruleId,
spaceIds,
lastRunOutcomeEnabled = false,
} = props;
const spacesData = useSpacesData();
const { http } = useKibana().services;
@ -87,7 +96,12 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer
}
if (columnId === 'status') {
return <RuleEventLogListStatus status={value as RuleAlertingOutcome} />;
return (
<RuleEventLogListStatus
status={value as RuleAlertingOutcome}
lastRunOutcomeEnabled={lastRunOutcomeEnabled}
/>
);
}
if (columnId === 'timestamp') {

View file

@ -11,6 +11,7 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { loadExecutionKPIAggregations } from '../../../lib/rule_api/load_execution_kpi_aggregations';
import { loadGlobalExecutionKPIAggregations } from '../../../lib/rule_api/load_global_execution_kpi_aggregations';
import { RuleEventLogListKPI } from './rule_event_log_list_kpi';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: jest.fn().mockReturnValue({
@ -28,6 +29,10 @@ jest.mock('../../../lib/rule_api/load_global_execution_kpi_aggregations', () =>
loadGlobalExecutionKPIAggregations: jest.fn(),
}));
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
const mockKpiResponse = {
success: 4,
unknown: 0,
@ -48,6 +53,7 @@ const loadGlobalExecutionKPIAggregationsMock =
describe('rule_event_log_list_kpi', () => {
beforeEach(() => {
jest.clearAllMocks();
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
loadExecutionKPIAggregationsMock.mockResolvedValue(mockKpiResponse);
loadGlobalExecutionKPIAggregationsMock.mockResolvedValue(mockKpiResponse);
});

View file

@ -14,6 +14,7 @@ import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { useKibana } from '../../../../common/lib/kibana';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
@ -104,6 +105,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
} = useKibana().services;
const isInitialized = useRef(false);
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [kpi, setKpi] = useState<IExecutionKPIResult>();
@ -168,7 +170,12 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
<EuiFlexItem>
<EuiStat
data-test-subj="ruleEventLogKpi-successOutcome"
description={getStatDescription(<RuleEventLogListStatus status="success" />)}
description={getStatDescription(
<RuleEventLogListStatus
status="success"
lastRunOutcomeEnabled={isRuleLastRunOutcomeEnabled}
/>
)}
titleSize="s"
title={kpi?.success ?? 0}
isLoading={isLoadingData}
@ -177,7 +184,12 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
<EuiFlexItem>
<EuiStat
data-test-subj="ruleEventLogKpi-warningOutcome"
description={getStatDescription(<RuleEventLogListStatus status="warning" />)}
description={getStatDescription(
<RuleEventLogListStatus
status="warning"
lastRunOutcomeEnabled={isRuleLastRunOutcomeEnabled}
/>
)}
titleSize="s"
title={kpi?.warning ?? 0}
isLoading={isLoadingData}
@ -186,7 +198,12 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
<EuiFlexItem>
<EuiStat
data-test-subj="ruleEventLogKpi-failureOutcome"
description={getStatDescription(<RuleEventLogListStatus status="failure" />)}
description={getStatDescription(
<RuleEventLogListStatus
status="failure"
lastRunOutcomeEnabled={isRuleLastRunOutcomeEnabled}
/>
)}
titleSize="s"
title={kpi?.failure ?? 0}
isLoading={isLoadingData}

View file

@ -5,12 +5,19 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { EuiIcon } from '@elastic/eui';
import { RuleAlertingOutcome } from '@kbn/alerting-plugin/common';
import {
RULE_LAST_RUN_OUTCOME_SUCCEEDED,
RULE_LAST_RUN_OUTCOME_FAILED,
RULE_LAST_RUN_OUTCOME_WARNING,
ALERT_STATUS_UNKNOWN,
} from '../../rules_list/translations';
interface RuleEventLogListStatusProps {
status: RuleAlertingOutcome;
lastRunOutcomeEnabled?: boolean;
}
const statusContainerStyles = {
@ -30,14 +37,28 @@ const STATUS_TO_COLOR: Record<RuleAlertingOutcome, string> = {
warning: 'warning',
};
const STATUS_TO_OUTCOME: Record<RuleAlertingOutcome, string> = {
success: RULE_LAST_RUN_OUTCOME_SUCCEEDED,
failure: RULE_LAST_RUN_OUTCOME_FAILED,
warning: RULE_LAST_RUN_OUTCOME_WARNING,
unknown: ALERT_STATUS_UNKNOWN,
};
export const RuleEventLogListStatus = (props: RuleEventLogListStatusProps) => {
const { status } = props;
const { status, lastRunOutcomeEnabled = false } = props;
const color = STATUS_TO_COLOR[status] || 'gray';
const statusString = useMemo(() => {
if (lastRunOutcomeEnabled) {
return STATUS_TO_OUTCOME[status].toLocaleLowerCase();
}
return status;
}, [lastRunOutcomeEnabled, status]);
return (
<div style={statusContainerStyles}>
<EuiIcon type="dot" color={color} style={iconStyles} />
{status}
{statusString}
</div>
);
};

View file

@ -9,6 +9,15 @@ import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
});
const onChangeMock = jest.fn();

View file

@ -9,6 +9,7 @@ import React, { useState, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { RuleAlertingOutcome } from '@kbn/alerting-plugin/common';
import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
const statusFilters: RuleAlertingOutcome[] = ['success', 'failure', 'warning', 'unknown'];
@ -21,6 +22,8 @@ interface RuleEventLogListStatusFilterProps {
export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilterProps) => {
const { selectedOptions = [], onChange = () => {} } = props;
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onFilterItemClick = useCallback(
@ -68,7 +71,10 @@ export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilter
onClick={onFilterItemClick(status)}
checked={selectedOptions.includes(status) ? 'on' : undefined}
>
<RuleEventLogListStatus status={status} />
<RuleEventLogListStatus
status={status}
lastRunOutcomeEnabled={isRuleLastRunOutcomeEnabled}
/>
</EuiFilterSelectItem>
);
})}

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import datemath from '@kbn/datemath';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import moment from 'moment';
import { FormattedMessage } from '@kbn/i18n-react';
import {
@ -32,7 +32,7 @@ export interface RuleStatusPanelProps {
isEditable: boolean;
requestRefresh: () => void;
healthColor: string;
statusMessage: string;
statusMessage?: string | null;
}
type ComponentOpts = Pick<
@ -68,6 +68,20 @@ export const RuleStatusPanel: React.FC<ComponentOpts> = ({
[rule, unsnoozeRule]
);
const statusMessageDisplay = useMemo(() => {
if (!statusMessage) {
return (
<EuiStat
titleSize="xs"
title="--"
description=""
isLoading={!rule.lastRun?.outcome && !rule.nextRun}
/>
);
}
return statusMessage;
}, [rule, statusMessage]);
const getLastNumberOfExecutions = useCallback(async () => {
try {
const result = await loadExecutionLogAggregations({
@ -142,7 +156,7 @@ export const RuleStatusPanel: React.FC<ComponentOpts> = ({
color={healthColor}
style={{ fontWeight: 400 }}
>
{statusMessage}
{statusMessageDisplay}
</EuiHealth>
}
description={i18n.translate(

View file

@ -10,6 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui';
import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common';
import { rulesStatusesTranslationsMapping } from '../translations';
import { getExecutionStatusHealthColor } from '../../../../common/lib';
interface RuleExecutionStatusFilterProps {
selectedStatuses: string[];
@ -66,7 +67,7 @@ export const RuleExecutionStatusFilter: React.FunctionComponent<RuleExecutionSta
>
<div className="euiFilterSelect__items">
{sortedRuleExecutionStatusValues.map((item: RuleExecutionStatuses) => {
const healthColor = getHealthColor(item);
const healthColor = getExecutionStatusHealthColor(item);
return (
<EuiFilterSelectItem
key={item}
@ -91,19 +92,4 @@ export const RuleExecutionStatusFilter: React.FunctionComponent<RuleExecutionSta
);
};
export function getHealthColor(status: RuleExecutionStatuses) {
switch (status) {
case 'active':
return 'success';
case 'error':
return 'danger';
case 'ok':
return 'primary';
case 'pending':
return 'accent';
case 'warning':
return 'warning';
default:
return 'subdued';
}
}
export { getExecutionStatusHealthColor as getHealthColor };

View file

@ -0,0 +1,90 @@
/*
* 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, { useCallback, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui';
import { RuleLastRunOutcomes, RuleLastRunOutcomeValues } from '@kbn/alerting-plugin/common';
import { rulesLastRunOutcomeTranslationMapping } from '../translations';
import { getOutcomeHealthColor } from '../../../../common/lib';
const sortedRuleLastRunOutcomeValues = [...RuleLastRunOutcomeValues].sort();
interface RuleLastRunOutcomeFilterProps {
selectedOutcomes: string[];
onChange?: (selectedRuleOutcomeIds: string[]) => void;
}
export const RuleLastRunOutcomeFilter: React.FunctionComponent<RuleLastRunOutcomeFilterProps> = ({
selectedOutcomes,
onChange,
}: RuleLastRunOutcomeFilterProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onTogglePopover = useCallback(() => {
setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen);
}, [setIsPopoverOpen]);
const onClosePopover = useCallback(() => {
setIsPopoverOpen(false);
}, [setIsPopoverOpen]);
const onFilterSelectItem = useCallback(
(filterItem: string) => () => {
const isPreviouslyChecked = selectedOutcomes.includes(filterItem);
if (isPreviouslyChecked) {
onChange?.(selectedOutcomes.filter((val) => val !== filterItem));
} else {
onChange?.(selectedOutcomes.concat(filterItem));
}
},
[onChange, selectedOutcomes]
);
return (
<EuiPopover
isOpen={isPopoverOpen}
closePopover={onClosePopover}
button={
<EuiFilterButton
iconType="arrowDown"
hasActiveFilters={selectedOutcomes.length > 0}
numActiveFilters={selectedOutcomes.length}
numFilters={selectedOutcomes.length}
onClick={onTogglePopover}
data-test-subj="ruleLastRunOutcomeFilterButton"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.ruleLastRunOutcomeFilterLabel"
defaultMessage="Last response"
/>
</EuiFilterButton>
}
>
<div className="euiFilterSelect__items">
{sortedRuleLastRunOutcomeValues.map((item: RuleLastRunOutcomes) => {
const healthColor = getOutcomeHealthColor(item);
return (
<EuiFilterSelectItem
key={item}
style={{ textTransform: 'capitalize' }}
onClick={onFilterSelectItem(item)}
checked={selectedOutcomes.includes(item) ? 'on' : undefined}
data-test-subj={`ruleLastRunOutcome${item}FilterOption`}
>
<EuiHealth color={healthColor}>
{rulesLastRunOutcomeTranslationMapping[item]}
</EuiHealth>
</EuiFilterSelectItem>
);
})}
</div>
</EuiPopover>
);
};
export { getOutcomeHealthColor as getHealthColor };

View file

@ -358,6 +358,14 @@ describe('rules_list component with props', () => {
});
describe('Last response filter', () => {
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
});
afterEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
});
let wrapper: ReactWrapper<any>;
async function setup(editable: boolean = true) {
loadRulesWithKueryFilter.mockResolvedValue({
@ -408,7 +416,7 @@ describe('rules_list component with props', () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useKibanaMock().services.actionTypeRegistry = actionTypeRegistry;
wrapper = mountWithIntl(
<RulesList lastResponseFilter={['error']} onLastResponseFilterChange={jest.fn()} />
<RulesList lastRunOutcomeFilter={['failed']} onLastRunOutcomeFilterChange={jest.fn()} />
);
await act(async () => {
await nextTick();
@ -420,49 +428,48 @@ describe('rules_list component with props', () => {
expect(loadRuleAggregationsWithKueryFilter).toHaveBeenCalled();
}
it('can filter by last response', async () => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
loadRulesWithKueryFilter.mockReset();
await setup();
expect(loadRulesWithKueryFilter).toHaveBeenLastCalledWith(
expect.objectContaining({
ruleExecutionStatusesFilter: ['error'],
ruleLastRunOutcomesFilter: ['failed'],
})
);
wrapper.find('[data-test-subj="ruleExecutionStatusFilterButton"] button').simulate('click');
wrapper.find('[data-test-subj="ruleLastRunOutcomeFilterButton"] button').simulate('click');
wrapper
.find('[data-test-subj="ruleExecutionStatusactiveFilterOption"]')
.find('[data-test-subj="ruleLastRunOutcomesucceededFilterOption"]')
.first()
.simulate('click');
expect(loadRulesWithKueryFilter).toHaveBeenLastCalledWith(
expect.objectContaining({
ruleExecutionStatusesFilter: ['error', 'active'],
ruleLastRunOutcomesFilter: ['failed', 'succeeded'],
})
);
expect(wrapper.prop('onLastResponseFilterChange')).toHaveBeenCalled();
expect(wrapper.prop('onLastResponseFilterChange')).toHaveBeenLastCalledWith([
'error',
'active',
expect(wrapper.prop('onLastRunOutcomeFilterChange')).toHaveBeenCalled();
expect(wrapper.prop('onLastRunOutcomeFilterChange')).toHaveBeenLastCalledWith([
'failed',
'succeeded',
]);
wrapper.find('[data-test-subj="ruleExecutionStatusFilterButton"] button').simulate('click');
wrapper.find('[data-test-subj="ruleLastRunOutcomeFilterButton"] button').simulate('click');
wrapper
.find('[data-test-subj="ruleExecutionStatuserrorFilterOption"]')
.find('[data-test-subj="ruleLastRunOutcomefailedFilterOption"]')
.first()
.simulate('click');
expect(loadRulesWithKueryFilter).toHaveBeenLastCalledWith(
expect.objectContaining({
ruleExecutionStatusesFilter: ['active'],
ruleLastRunOutcomesFilter: ['succeeded'],
})
);
expect(wrapper.prop('onLastResponseFilterChange')).toHaveBeenCalled();
expect(wrapper.prop('onLastResponseFilterChange')).toHaveBeenLastCalledWith(['active']);
expect(wrapper.prop('onLastRunOutcomeFilterChange')).toHaveBeenCalled();
expect(wrapper.prop('onLastRunOutcomeFilterChange')).toHaveBeenLastCalledWith(['succeeded']);
});
});
@ -844,6 +851,11 @@ describe('rules_list component with items', () => {
loadRuleTypes.mockResolvedValue([ruleTypeFromApi]);
loadAllActions.mockResolvedValue([]);
loadRuleAggregationsWithKueryFilter.mockResolvedValue({
ruleLastRunOutcome: {
succeeded: 3,
failed: 3,
warning: 6,
},
ruleEnabledStatus: { enabled: 2, disabled: 0 },
ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 },
ruleMutedStatus: { muted: 0, unmuted: 2 },
@ -1005,11 +1017,9 @@ describe('rules_list component with items', () => {
expect(
wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-lastResponse"]').length
).toEqual(mockedRulesData.length);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-active"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-ok"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-pending"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-unknown"]').length).toEqual(0);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').length).toEqual(2);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-succeeded"]').length).toEqual(2);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-failed"]').length).toEqual(2);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-warning"]').length).toEqual(1);
expect(wrapper.find('[data-test-subj="ruleStatus-error-tooltip"]').length).toEqual(2);
expect(
@ -1018,10 +1028,10 @@ describe('rules_list component with items', () => {
expect(wrapper.find('[data-test-subj="rulesListAutoRefresh"]').exists()).toBeTruthy();
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').first().text()).toEqual(
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-failed"]').first().text()).toEqual(
'Error'
);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').last().text()).toEqual(
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-failed"]').last().text()).toEqual(
'License Error'
);
});
@ -1042,7 +1052,7 @@ describe('rules_list component with items', () => {
mockedRulesData.forEach((rule, index) => {
if (rule.monitoring) {
expect(ratios.at(index).text()).toEqual(
`${rule.monitoring.execution.calculated_metrics.success_ratio * 100}%`
`${rule.monitoring.run.calculated_metrics.success_ratio * 100}%`
);
} else {
expect(ratios.at(index).text()).toEqual(`N/A`);
@ -1060,10 +1070,10 @@ describe('rules_list component with items', () => {
);
mockedRulesData.forEach((rule, index) => {
if (typeof rule.monitoring?.execution.calculated_metrics.p50 === 'number') {
if (typeof rule.monitoring?.run.calculated_metrics.p50 === 'number') {
// Ensure the table cells are getting the correct values
expect(percentiles.at(index).text()).toEqual(
getFormattedDuration(rule.monitoring.execution.calculated_metrics.p50)
getFormattedDuration(rule.monitoring.run.calculated_metrics.p50)
);
// Ensure the tooltip is showing the correct content
expect(
@ -1073,7 +1083,7 @@ describe('rules_list component with items', () => {
)
.at(index)
.props().content
).toEqual(getFormattedMilliseconds(rule.monitoring.execution.calculated_metrics.p50));
).toEqual(getFormattedMilliseconds(rule.monitoring.run.calculated_metrics.p50));
} else {
expect(percentiles.at(index).text()).toEqual('N/A');
}
@ -1149,9 +1159,9 @@ describe('rules_list component with items', () => {
);
mockedRulesData.forEach((rule, index) => {
if (typeof rule.monitoring?.execution.calculated_metrics.p95 === 'number') {
if (typeof rule.monitoring?.run.calculated_metrics.p95 === 'number') {
expect(percentiles.at(index).text()).toEqual(
getFormattedDuration(rule.monitoring.execution.calculated_metrics.p95)
getFormattedDuration(rule.monitoring.run.calculated_metrics.p95)
);
} else {
expect(percentiles.at(index).text()).toEqual('N/A');
@ -1270,21 +1280,19 @@ describe('rules_list component with items', () => {
});
it('renders brief', async () => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
await setup();
// { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }
expect(wrapper.find('EuiHealth[data-test-subj="totalOkRulesCount"]').text()).toEqual('Ok: 1');
expect(wrapper.find('EuiHealth[data-test-subj="totalActiveRulesCount"]').text()).toEqual(
'Active: 2'
// ruleLastRunOutcome: {
// succeeded: 3,
// failed: 3,
// warning: 6,
// }
expect(wrapper.find('EuiHealth[data-test-subj="totalSucceededRulesCount"]').text()).toEqual(
'Succeeded: 3'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalErrorRulesCount"]').text()).toEqual(
'Error: 3'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalPendingRulesCount"]').text()).toEqual(
'Pending: 4'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalUnknownRulesCount"]').text()).toEqual(
'Unknown: 5'
expect(wrapper.find('EuiHealth[data-test-subj="totalFailedRulesCount"]').text()).toEqual(
'Failed: 3'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalWarningRulesCount"]').text()).toEqual(
'Warning: 6'

View file

@ -22,13 +22,10 @@ import {
EuiSpacer,
EuiLink,
EuiEmptyPrompt,
EuiHealth,
EuiTableSortingType,
EuiButtonIcon,
EuiSelectableOption,
EuiIcon,
EuiDescriptionList,
EuiCallOut,
} from '@elastic/eui';
import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option';
import { useHistory } from 'react-router-dom';
@ -37,6 +34,7 @@ import {
RuleExecutionStatus,
ALERTS_FEATURE_ID,
RuleExecutionStatusErrorReasons,
RuleLastRunOutcomeValues,
} from '@kbn/alerting-plugin/common';
import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common';
import { ruleDetailsRoute as commonRuleDetailsRoute } from '@kbn/rule-data-utils';
@ -56,9 +54,12 @@ import { RuleAdd, RuleEdit } from '../../rule_form';
import { BulkOperationPopover } from '../../common/components/bulk_operation_popover';
import { RuleQuickEditButtonsWithApi as RuleQuickEditButtons } from '../../common/components/rule_quick_edit_buttons';
import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions';
import { RulesListStatuses } from './rules_list_statuses';
import { TypeFilter } from './type_filter';
import { ActionTypeFilter } from './action_type_filter';
import { RuleExecutionStatusFilter } from './rule_execution_status_filter';
import { RuleLastRunOutcomeFilter } from './rule_last_run_outcome_filter';
import { RulesListErrorBanner } from './rules_list_error_banner';
import {
loadRuleTypes,
disableRule,
@ -118,6 +119,8 @@ export interface RulesListProps {
onStatusFilterChange?: (status: RuleStatus[]) => RulesPageContainerState;
lastResponseFilter?: string[];
onLastResponseFilterChange?: (lastResponse: string[]) => RulesPageContainerState;
lastRunOutcomeFilter?: string[];
onLastRunOutcomeFilterChange?: (lastRunOutcome: string[]) => RulesPageContainerState;
refresh?: Date;
rulesListKey?: string;
visibleColumns?: RulesListVisibleColumns[];
@ -130,9 +133,9 @@ interface RuleTypeState {
}
export const percentileFields = {
[Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50',
[Percentiles.P95]: 'monitoring.execution.calculated_metrics.p95',
[Percentiles.P99]: 'monitoring.execution.calculated_metrics.p99',
[Percentiles.P50]: 'monitoring.run.calculated_metrics.p50',
[Percentiles.P95]: 'monitoring.run.calculated_metrics.p95',
[Percentiles.P99]: 'monitoring.run.calculated_metrics.p99',
};
const initialPercentileOptions = Object.values(Percentiles).map((percentile) => ({
@ -150,6 +153,8 @@ export const RulesList = ({
onStatusFilterChange,
lastResponseFilter,
onLastResponseFilterChange,
lastRunOutcomeFilter,
onLastRunOutcomeFilterChange,
refresh,
rulesListKey,
visibleColumns,
@ -176,6 +181,9 @@ export const RulesList = ({
const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState<string[]>(
lastResponseFilter || []
);
const [ruleLastRunOutcomesFilter, setRuleLastRunOutcomesFilter] = useState<string[]>(
lastRunOutcomeFilter || []
);
const [ruleStatusesFilter, setRuleStatusesFilter] = useState<RuleStatus[]>(statusFilter || []);
const [tagsFilter, setTagsFilter] = useState<string[]>([]);
@ -190,6 +198,7 @@ export const RulesList = ({
const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter');
const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter');
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
const cloneRuleId = useRef<null | string>(null);
@ -282,6 +291,7 @@ export const RulesList = ({
typesFilter: rulesTypesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
sort,
@ -294,15 +304,17 @@ export const RulesList = ({
onError,
});
const { loadRuleAggregations, rulesStatusesTotal } = useLoadRuleAggregations({
searchText,
typesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleStatusesFilter,
tagsFilter,
onError,
});
const { loadRuleAggregations, rulesStatusesTotal, rulesLastRunOutcomesTotal } =
useLoadRuleAggregations({
searchText,
typesFilter: rulesTypesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
onError,
});
const onRuleEdit = (ruleItem: RuleTableItem) => {
setEditFlyoutVisibility(true);
@ -419,12 +431,24 @@ export const RulesList = ({
}
}, [lastResponseFilter]);
useEffect(() => {
if (lastRunOutcomeFilter) {
setRuleLastRunOutcomesFilter(lastRunOutcomeFilter);
}
}, [lastResponseFilter]);
useEffect(() => {
if (onLastResponseFilterChange) {
onLastResponseFilterChange(ruleExecutionStatusesFilter);
}
}, [ruleExecutionStatusesFilter]);
useEffect(() => {
if (onLastRunOutcomeFilterChange) {
onLastRunOutcomeFilterChange(ruleLastRunOutcomesFilter);
}
}, [ruleLastRunOutcomesFilter]);
// Clear bulk selection anytime the filters change
useEffect(() => {
onClearSelection();
@ -433,6 +457,7 @@ export const RulesList = ({
rulesTypesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
hasDefaultRuleTypesFiltersOn,
@ -493,7 +518,11 @@ export const RulesList = ({
setShowErrors((prevValue) => {
if (!prevValue) {
const rulesToExpand = rulesState.data.reduce((acc, ruleItem) => {
if (ruleItem.executionStatus.status === 'error') {
// Check both outcome and executionStatus for now until we deprecate executionStatus
if (
ruleItem.lastRun?.outcome === RuleLastRunOutcomeValues[2] ||
ruleItem.executionStatus.status === 'error'
) {
return {
...acc,
[ruleItem.id]: (
@ -556,6 +585,25 @@ export const RulesList = ({
return null;
};
const getRuleOutcomeOrStatusFilter = () => {
if (isRuleLastRunOutcomeEnabled) {
return [
<RuleLastRunOutcomeFilter
key="rule-last-run-outcome-filter"
selectedOutcomes={ruleLastRunOutcomesFilter}
onChange={setRuleLastRunOutcomesFilter}
/>,
];
}
return [
<RuleExecutionStatusFilter
key="rule-status-filter"
selectedStatuses={ruleExecutionStatusesFilter}
onChange={setRuleExecutionStatusesFilter}
/>,
];
};
const onDisableRule = (rule: RuleTableItem) => {
return disableRule({ http, id: rule.id });
};
@ -599,11 +647,7 @@ export const RulesList = ({
filters={typesFilter}
/>
),
<RuleExecutionStatusFilter
key="rule-status-filter"
selectedStatuses={ruleExecutionStatusesFilter}
onChange={setRuleExecutionStatusesFilter}
/>,
...getRuleOutcomeOrStatusFilter(),
...getRuleTagFilter(),
];
@ -625,6 +669,7 @@ export const RulesList = ({
typesFilter: rulesTypesFilter,
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleStatusesFilter,
tagsFilter,
});
@ -716,34 +761,11 @@ export const RulesList = ({
const table = (
<>
{rulesStatusesTotal.error > 0 ? (
<>
<EuiCallOut color="danger" size="s" data-test-subj="rulesErrorBanner">
<p>
<EuiIcon color="danger" type="alert" />
&nbsp;
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.attentionBannerTitle"
defaultMessage="Error found in {totalStatusesError, plural, one {# rule} other {# rules}}."
values={{
totalStatusesError: rulesStatusesTotal.error,
}}
/>
&nbsp;
<EuiLink color="primary" onClick={() => setRuleExecutionStatusesFilter(['error'])}>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.viewBannerButtonLabel"
defaultMessage="Show {totalStatusesError, plural, one {rule} other {rules}} with error"
values={{
totalStatusesError: rulesStatusesTotal.error,
}}
/>
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer size="s" />
</>
) : null}
<RulesListErrorBanner
rulesLastRunOutcomes={rulesLastRunOutcomesTotal}
setRuleExecutionStatusesFilter={setRuleExecutionStatusesFilter}
setRuleLastRunOutcomesFilter={setRuleLastRunOutcomesFilter}
/>
<EuiFlexGroup gutterSize="s">
{authorizedToCreateAnyRules && showCreateRuleButton ? (
<EuiFlexItem grow={false}>
@ -813,68 +835,10 @@ export const RulesList = ({
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiHealth color="success" data-test-subj="totalActiveRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesActiveDescription"
defaultMessage="Active: {totalStatusesActive}"
values={{
totalStatusesActive: rulesStatusesTotal.active,
}}
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="danger" data-test-subj="totalErrorRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesErrorDescription"
defaultMessage="Error: {totalStatusesError}"
values={{ totalStatusesError: rulesStatusesTotal.error }}
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="warning" data-test-subj="totalWarningRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesWarningDescription"
defaultMessage="Warning: {totalStatusesWarning}"
values={{
totalStatusesWarning: rulesStatusesTotal.warning,
}}
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="primary" data-test-subj="totalOkRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesOkDescription"
defaultMessage="Ok: {totalStatusesOk}"
values={{ totalStatusesOk: rulesStatusesTotal.ok }}
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="accent" data-test-subj="totalPendingRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesPendingDescription"
defaultMessage="Pending: {totalStatusesPending}"
values={{
totalStatusesPending: rulesStatusesTotal.pending,
}}
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="subdued" data-test-subj="totalUnknownRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesUnknownDescription"
defaultMessage="Unknown: {totalStatusesUnknown}"
values={{
totalStatusesUnknown: rulesStatusesTotal.unknown,
}}
/>
</EuiHealth>
</EuiFlexItem>
<RulesListStatuses
rulesStatuses={rulesStatusesTotal}
rulesLastRunOutcomes={rulesLastRunOutcomesTotal}
/>
<RulesListAutoRefresh lastUpdate={lastUpdate} onRefresh={refreshRules} />
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -36,7 +36,8 @@ export type RulesListVisibleColumns =
| 'ruleExecutionPercentile'
| 'ruleExecutionSuccessRatio'
| 'ruleExecutionStatus'
| 'ruleExecutionState';
| 'ruleExecutionState'
| 'ruleLastRunOutcome';
const OriginalRulesListVisibleColumns: RulesListVisibleColumns[] = [
'ruleName',

View file

@ -0,0 +1,64 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiIcon, EuiLink, EuiSpacer } from '@elastic/eui';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
interface RulesListErrorBannerProps {
rulesLastRunOutcomes: Record<string, number>;
setRuleExecutionStatusesFilter: (statuses: string[]) => void;
setRuleLastRunOutcomesFilter: (outcomes: string[]) => void;
}
export const RulesListErrorBanner = (props: RulesListErrorBannerProps) => {
const { rulesLastRunOutcomes, setRuleExecutionStatusesFilter, setRuleLastRunOutcomesFilter } =
props;
const onClick = () => {
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
if (isRuleLastRunOutcomeEnabled) {
setRuleLastRunOutcomesFilter(['failed']);
} else {
setRuleExecutionStatusesFilter(['error']);
}
};
if (rulesLastRunOutcomes.failed === 0) {
return null;
}
return (
<>
<EuiCallOut color="danger" size="s" data-test-subj="rulesErrorBanner">
<p>
<EuiIcon color="danger" type="alert" />
&nbsp;
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.attentionBannerTitle"
defaultMessage="Error found in {totalStatusesError, plural, one {# rule} other {# rules}}."
values={{
totalStatusesError: rulesLastRunOutcomes.failed,
}}
/>
&nbsp;
<EuiLink color="primary" onClick={onClick}>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.viewBannerButtonLabel"
defaultMessage="Show {totalStatusesError, plural, one {rule} other {rules}} with error"
values={{
totalStatusesError: rulesLastRunOutcomes.failed,
}}
/>
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
};

View file

@ -0,0 +1,90 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import {
RULE_STATUS_ACTIVE,
RULE_STATUS_ERROR,
RULE_STATUS_WARNING,
RULE_STATUS_OK,
RULE_STATUS_PENDING,
RULE_STATUS_UNKNOWN,
RULE_LAST_RUN_OUTCOME_SUCCEEDED_DESCRIPTION,
RULE_LAST_RUN_OUTCOME_WARNING_DESCRIPTION,
RULE_LAST_RUN_OUTCOME_FAILED_DESCRIPTION,
} from '../translations';
interface RulesListStatusesProps {
rulesStatuses: Record<string, number>;
rulesLastRunOutcomes: Record<string, number>;
}
export const RulesListStatuses = (props: RulesListStatusesProps) => {
const { rulesStatuses, rulesLastRunOutcomes } = props;
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
if (isRuleLastRunOutcomeEnabled) {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiHealth color="success" data-test-subj="totalSucceededRulesCount">
{RULE_LAST_RUN_OUTCOME_SUCCEEDED_DESCRIPTION(rulesLastRunOutcomes.succeeded)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="danger" data-test-subj="totalFailedRulesCount">
{RULE_LAST_RUN_OUTCOME_FAILED_DESCRIPTION(rulesLastRunOutcomes.failed)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="warning" data-test-subj="totalWarningRulesCount">
{RULE_LAST_RUN_OUTCOME_WARNING_DESCRIPTION(rulesLastRunOutcomes.warning)}
</EuiHealth>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiHealth color="success" data-test-subj="totalActiveRulesCount">
{RULE_STATUS_ACTIVE(rulesStatuses.active)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="danger" data-test-subj="totalErrorRulesCount">
{RULE_STATUS_ERROR(rulesStatuses.error)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="warning" data-test-subj="totalWarningRulesCount">
{RULE_STATUS_WARNING(rulesStatuses.warning)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="primary" data-test-subj="totalOkRulesCount">
{RULE_STATUS_OK(rulesStatuses.ok)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="accent" data-test-subj="totalPendingRulesCount">
{RULE_STATUS_PENDING(rulesStatuses.pending)}
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="subdued" data-test-subj="totalUnknownRulesCount">
{RULE_STATUS_UNKNOWN(rulesStatuses.unknown)}
</EuiHealth>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -9,7 +9,6 @@ import moment from 'moment';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { FormattedMessage } from '@kbn/i18n-react';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import {
EuiBasicTable,
@ -18,7 +17,6 @@ import {
EuiIconTip,
EuiLink,
EuiButtonEmpty,
EuiHealth,
EuiText,
EuiToolTip,
EuiTableSortingType,
@ -32,21 +30,17 @@ import {
} from '@elastic/eui';
import {
RuleExecutionStatus,
RuleExecutionStatusErrorReasons,
formatDuration,
parseDuration,
MONITORING_HISTORY_LIMIT,
} from '@kbn/alerting-plugin/common';
import {
rulesStatusesTranslationsMapping,
ALERT_STATUS_LICENSE_ERROR,
SELECT_ALL_RULES,
CLEAR_SELECTION,
TOTAL_RULES,
SELECT_ALL_ARIA_LABEL,
} from '../translations';
import { getHealthColor } from './rule_execution_status_filter';
import {
Rule,
RuleTableItem,
@ -67,6 +61,8 @@ import { hasAllPrivilege } from '../../../lib/capabilities';
import { RuleTagBadge } from './rule_tag_badge';
import { RuleStatusDropdown } from './rule_status_dropdown';
import { RulesListNotifyBadge } from './rules_list_notify_badge';
import { RulesListTableStatusCell } from './rules_list_table_status_cell';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import {
RulesListColumns,
RulesListVisibleColumns,
@ -92,9 +88,9 @@ const percentileOrdinals = {
};
export const percentileFields = {
[Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50',
[Percentiles.P95]: 'monitoring.execution.calculated_metrics.p95',
[Percentiles.P99]: 'monitoring.execution.calculated_metrics.p99',
[Percentiles.P50]: 'monitoring.run.calculated_metrics.p50',
[Percentiles.P95]: 'monitoring.run.calculated_metrics.p95',
[Percentiles.P99]: 'monitoring.run.calculated_metrics.p99',
};
const EMPTY_OBJECT = {};
@ -219,6 +215,8 @@ export const RulesListTable = (props: RulesListTableProps) => {
const [currentlyOpenNotify, setCurrentlyOpenNotify] = useState<string>();
const [isLoadingMap, setIsLoadingMap] = useState<Record<string, boolean>>({});
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const { euiTheme } = useEuiTheme();
@ -301,58 +299,6 @@ export const RulesListTable = (props: RulesListTableProps) => {
[isRuleTypeEditableInContext, onDisableRule, onEnableRule, onRuleChanged]
);
const renderRuleExecutionStatus = useCallback(
(executionStatus: RuleExecutionStatus, rule: RuleTableItem) => {
const healthColor = getHealthColor(executionStatus.status);
const tooltipMessage =
executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null;
const isLicenseError =
executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License;
const statusMessage = isLicenseError
? ALERT_STATUS_LICENSE_ERROR
: rulesStatusesTranslationsMapping[executionStatus.status];
const health = (
<EuiHealth data-test-subj={`ruleStatus-${executionStatus.status}`} color={healthColor}>
{statusMessage}
</EuiHealth>
);
const healthWithTooltip = tooltipMessage ? (
<EuiToolTip
data-test-subj="ruleStatus-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="ruleStatus-error-license-fix"
onClick={() => onManageLicenseClick(rule)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.fixLicenseLink"
defaultMessage="Fix"
/>
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
},
[onManageLicenseClick]
);
const selectionColumn = useMemo(() => {
return {
id: 'ruleSelection',
@ -382,6 +328,13 @@ export const RulesListTable = (props: RulesListTableProps) => {
};
}, [isPageSelected, onSelectPage, onSelectRow, isRowSelected]);
const ruleOutcomeColumnField = useMemo(() => {
if (isRuleLastRunOutcomeEnabled) {
return 'lastRun.outcome';
}
return 'executionStatus.status';
}, [isRuleLastRunOutcomeEnabled]);
const getRulesTableColumns = useCallback((): RulesListColumns[] => {
return [
{
@ -684,7 +637,7 @@ export const RulesListTable = (props: RulesListTableProps) => {
},
{
id: 'ruleExecutionSuccessRatio',
field: 'monitoring.execution.calculated_metrics.success_ratio',
field: 'monitoring.run.calculated_metrics.success_ratio',
width: '12%',
selectorName: i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.selector.successRatioTitle',
@ -719,7 +672,7 @@ export const RulesListTable = (props: RulesListTableProps) => {
},
{
id: 'ruleExecutionStatus',
field: 'executionStatus.status',
field: ruleOutcomeColumnField,
name: i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle',
{ defaultMessage: 'Last response' }
@ -729,7 +682,9 @@ export const RulesListTable = (props: RulesListTableProps) => {
width: '120px',
'data-test-subj': 'rulesTableCell-lastResponse',
render: (_executionStatus: RuleExecutionStatus, rule: RuleTableItem) => {
return renderRuleExecutionStatus(rule.executionStatus, rule);
return (
<RulesListTableStatusCell rule={rule} onManageLicenseClick={onManageLicenseClick} />
);
},
},
{
@ -827,15 +782,16 @@ export const RulesListTable = (props: RulesListTableProps) => {
onRuleEditClick,
onSnoozeRule,
onUnsnoozeRule,
onManageLicenseClick,
renderCollapsedItemActions,
renderPercentileCellValue,
renderPercentileColumnName,
renderRuleError,
renderRuleExecutionStatus,
renderRuleStatusDropdown,
ruleTypesState.data,
selectedPercentile,
tagPopoverOpenIndex,
ruleOutcomeColumnField,
]);
const allRuleColumns = useMemo(() => getRulesTableColumns(), [getRulesTableColumns]);

View file

@ -0,0 +1,162 @@
/*
* 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 { render } from '@testing-library/react';
import {
RulesListTableStatusCell,
RulesListTableStatusCellProps,
} from './rules_list_table_status_cell';
import { RuleTableItem } from '../../../../types';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
const mockRule: RuleTableItem = {
id: '1',
enabled: true,
executionStatus: {
status: 'ok',
},
lastRun: {
outcome: 'succeeded',
},
nextRun: new Date('2020-08-20T19:23:38Z'),
} as RuleTableItem;
const onManageLicenseClickMock = jest.fn();
const ComponentWithLocale = (props: RulesListTableStatusCellProps) => {
return (
<IntlProvider locale="en">
<RulesListTableStatusCell {...props} />
</IntlProvider>
);
};
describe('RulesListTableStatusCell', () => {
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
});
afterEach(() => {
onManageLicenseClickMock.mockClear();
});
it('should render successful rule outcome', async () => {
const { getByTestId } = render(
<ComponentWithLocale rule={mockRule} onManageLicenseClick={onManageLicenseClickMock} />
);
expect(getByTestId('ruleStatus-succeeded')).not.toBe(null);
});
it('should render failed rule outcome', async () => {
const { getByTestId } = render(
<ComponentWithLocale
rule={
{
...mockRule,
executionStatus: {
status: 'error',
},
lastRun: {
outcome: 'failed',
},
} as RuleTableItem
}
onManageLicenseClick={onManageLicenseClickMock}
/>
);
expect(getByTestId('ruleStatus-failed')).not.toBe(null);
});
it('should render warning rule outcome', async () => {
const { getByTestId } = render(
<ComponentWithLocale
rule={
{
...mockRule,
executionStatus: {
status: 'warning',
},
lastRun: {
outcome: 'warning',
},
} as RuleTableItem
}
onManageLicenseClick={onManageLicenseClickMock}
/>
);
expect(getByTestId('ruleStatus-warning')).not.toBe(null);
});
it('should render license errors', async () => {
const { getByTestId, getByText } = render(
<ComponentWithLocale
rule={
{
...mockRule,
executionStatus: {
status: 'warning',
},
lastRun: {
outcome: 'warning',
warning: 'license',
},
} as RuleTableItem
}
onManageLicenseClick={onManageLicenseClickMock}
/>
);
expect(getByTestId('ruleStatus-warning')).not.toBe(null);
expect(getByText('License Error')).not.toBe(null);
});
it('should render loading indicator for new rules', async () => {
const { getByText } = render(
<ComponentWithLocale
rule={
{
...mockRule,
executionStatus: {
status: 'pending',
},
lastRun: null,
nextRun: null,
} as RuleTableItem
}
onManageLicenseClick={onManageLicenseClickMock}
/>
);
expect(getByText('Statistic is loading')).not.toBe(null);
});
it('should render rule with no last run', async () => {
const { queryByText, getAllByText } = render(
<ComponentWithLocale
rule={
{
...mockRule,
executionStatus: {
status: 'unknown',
},
lastRun: null,
} as RuleTableItem
}
onManageLicenseClick={onManageLicenseClickMock}
/>
);
expect(queryByText('Statistic is loading')).toBe(null);
expect(getAllByText('--')).not.toBe(null);
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiHealth,
EuiToolTip,
EuiStat,
} from '@elastic/eui';
import { RuleTableItem } from '../../../../types';
import {
getRuleHealthColor,
getIsLicenseError,
getRuleStatusMessage,
} from '../../../../common/lib/rule_status_helpers';
export interface RulesListTableStatusCellProps {
rule: RuleTableItem;
onManageLicenseClick: (rule: RuleTableItem) => void;
}
export const RulesListTableStatusCell = (props: RulesListTableStatusCellProps) => {
const { rule, onManageLicenseClick } = props;
const { lastRun } = rule;
const isLicenseError = getIsLicenseError(rule);
const healthColor = getRuleHealthColor(rule);
const statusMessage = getRuleStatusMessage(rule);
const tooltipMessage = lastRun?.outcome === 'failed' ? `Error: ${lastRun?.outcomeMsg}` : null;
if (!statusMessage) {
return (
<EuiStat
titleSize="xs"
title="--"
description=""
isLoading={!lastRun?.outcome && !rule.nextRun}
/>
);
}
const health = (
<EuiHealth
data-test-subj={`ruleStatus-${lastRun?.outcome || 'pending'}`}
color={healthColor || 'default'}
>
{statusMessage}
</EuiHealth>
);
const healthWithTooltip = tooltipMessage ? (
<EuiToolTip data-test-subj="ruleStatus-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="ruleStatus-error-license-fix"
onClick={() => onManageLicenseClick(rule)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.fixLicenseLink"
defaultMessage="Fix"
/>
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -36,7 +36,7 @@ export const mockedRulesData = [
error: null,
},
monitoring: {
execution: {
run: {
history: [
{
success: true,
@ -57,8 +57,18 @@ export const mockedRulesData = [
p95: 300000,
p99: 300000,
},
last_run: {
timestamp: '2020-08-20T19:23:38Z',
metrics: {
duration: 500,
},
},
},
},
lastRun: {
outcome: 'succeeded',
alertsCount: {},
},
},
{
id: '2',
@ -83,7 +93,7 @@ export const mockedRulesData = [
error: null,
},
monitoring: {
execution: {
run: {
history: [
{
success: true,
@ -100,8 +110,18 @@ export const mockedRulesData = [
p95: 100000,
p99: 500000,
},
last_run: {
timestamp: '2020-08-20T19:23:38Z',
metrics: {
duration: 61000,
},
},
},
},
lastRun: {
outcome: 'succeeded',
alertsCount: {},
},
},
{
id: '3',
@ -126,11 +146,17 @@ export const mockedRulesData = [
error: null,
},
monitoring: {
execution: {
run: {
history: [{ success: false, duration: 100 }],
calculated_metrics: {
success_ratio: 0,
},
last_run: {
timestamp: '2020-08-20T19:23:38Z',
metrics: {
duration: 30234,
},
},
},
},
},
@ -159,6 +185,11 @@ export const mockedRulesData = [
message: 'test',
},
},
lastRun: {
outcome: 'failed',
outcomeMsg: 'test',
warning: RuleExecutionStatusErrorReasons.Unknown,
},
},
{
id: '5',
@ -185,6 +216,11 @@ export const mockedRulesData = [
message: 'test',
},
},
lastRun: {
outcome: 'failed',
outcomeMsg: 'test',
warning: RuleExecutionStatusErrorReasons.License,
},
},
{
id: '6',
@ -211,6 +247,11 @@ export const mockedRulesData = [
message: 'test',
},
},
lastRun: {
outcome: 'warning',
outcomeMsg: 'test',
warning: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
},
},
];

View file

@ -55,6 +55,26 @@ export const ALERT_STATUS_WARNING = i18n.translate(
defaultMessage: 'Warning',
}
);
export const RULE_LAST_RUN_OUTCOME_SUCCEEDED = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleLastRunOutcomeSucceeded',
{
defaultMessage: 'Succeeded',
}
);
export const RULE_LAST_RUN_OUTCOME_WARNING = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleLastRunOutcomeWarning',
{
defaultMessage: 'Warning',
}
);
export const RULE_LAST_RUN_OUTCOME_FAILED = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleLastRunOutcomeFailed',
{
defaultMessage: 'Failed',
}
);
export const rulesStatusesTranslationsMapping = {
ok: ALERT_STATUS_OK,
@ -65,6 +85,12 @@ export const rulesStatusesTranslationsMapping = {
warning: ALERT_STATUS_WARNING,
};
export const rulesLastRunOutcomeTranslationMapping = {
succeeded: RULE_LAST_RUN_OUTCOME_SUCCEEDED,
warning: RULE_LAST_RUN_OUTCOME_WARNING,
failed: RULE_LAST_RUN_OUTCOME_FAILED,
};
export const ALERT_ERROR_UNKNOWN_REASON = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleErrorReasonUnknown',
{
@ -199,6 +225,93 @@ export const CLEAR_SELECTION = i18n.translate(
}
);
export const RULE_STATUS_ACTIVE = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.totalStatusesActiveDescription',
{
defaultMessage: 'Active: {totalStatusesActive}',
values: { totalStatusesActive: total },
}
);
};
export const RULE_STATUS_ERROR = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.totalStatusesErrorDescription',
{
defaultMessage: 'Error: {totalStatusesError}',
values: { totalStatusesError: total },
}
);
};
export const RULE_STATUS_WARNING = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.totalStatusesWarningDescription',
{
defaultMessage: 'Warning: {totalStatusesWarning}',
values: { totalStatusesWarning: total },
}
);
};
export const RULE_STATUS_OK = (total: number) => {
return i18n.translate('xpack.triggersActionsUI.sections.rulesList.totalStatusesOkDescription', {
defaultMessage: 'Ok: {totalStatusesOk}',
values: { totalStatusesOk: total },
});
};
export const RULE_STATUS_PENDING = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.totalStatusesPendingDescription',
{
defaultMessage: 'Pending: {totalStatusesPending}',
values: { totalStatusesPending: total },
}
);
};
export const RULE_STATUS_UNKNOWN = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.totalStatusesUnknownDescription',
{
defaultMessage: 'Unknown: {totalStatusesUnknown}',
values: { totalStatusesUnknown: total },
}
);
};
export const RULE_LAST_RUN_OUTCOME_SUCCEEDED_DESCRIPTION = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.lastRunOutcomeSucceededDescription',
{
defaultMessage: 'Succeeded: {total}',
values: { total },
}
);
};
export const RULE_LAST_RUN_OUTCOME_WARNING_DESCRIPTION = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.lastRunOutcomeWarningDescription',
{
defaultMessage: 'Warning: {total}',
values: { total },
}
);
};
export const RULE_LAST_RUN_OUTCOME_FAILED_DESCRIPTION = (total: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.lastRunOutcomeFailedDescription',
{
defaultMessage: 'Failed: {total}',
values: { total },
}
);
};
export const SINGLE_RULE_TITLE = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.singleTitle',
{

View file

@ -20,6 +20,7 @@ describe('getIsExperimentalFeatureEnabled', () => {
rulesDetailLogs: true,
ruleTagFilter: true,
ruleStatusFilter: true,
ruleLastRunOutcome: true,
},
});
@ -43,6 +44,10 @@ describe('getIsExperimentalFeatureEnabled', () => {
expect(result).toEqual(true);
result = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
expect(result).toEqual(true);
expect(() => getIsExperimentalFeatureEnabled('doesNotExist' as any)).toThrowError(
`Invalid enable value doesNotExist. Allowed values are: ${allowedExperimentalValueKeys.join(
', '

View file

@ -6,4 +6,11 @@
*/
export { getTimeFieldOptions, getTimeOptions } from './get_time_options';
export {
getOutcomeHealthColor,
getExecutionStatusHealthColor,
getRuleHealthColor,
getIsLicenseError,
getRuleStatusMessage,
} from './rule_status_helpers';
export { useKibana } from './kibana';

View file

@ -0,0 +1,134 @@
/*
* 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 { getRuleHealthColor, getRuleStatusMessage } from './rule_status_helpers';
import { RuleTableItem } from '../../types';
import { getIsExperimentalFeatureEnabled } from '../get_experimental_features';
jest.mock('../get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
const mockRule = {
id: '1',
enabled: true,
executionStatus: {
status: 'active',
},
lastRun: {
outcome: 'succeeded',
},
} as RuleTableItem;
const warningRule = {
...mockRule,
executionStatus: {
status: 'warning',
},
lastRun: {
outcome: 'warning',
},
} as RuleTableItem;
const failedRule = {
...mockRule,
executionStatus: {
status: 'error',
},
lastRun: {
outcome: 'failed',
},
} as RuleTableItem;
const licenseErrorRule = {
...mockRule,
executionStatus: {
status: 'error',
error: {
reason: 'license',
},
},
lastRun: {
outcome: 'failed',
warning: 'license',
},
} as RuleTableItem;
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
});
describe('getRuleHealthColor', () => {
it('should return the correct color for successful rule', () => {
let color = getRuleHealthColor(mockRule);
expect(color).toEqual('success');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
color = getRuleHealthColor(mockRule);
expect(color).toEqual('success');
});
it('should return the correct color for warning rule', () => {
let color = getRuleHealthColor(warningRule);
expect(color).toEqual('warning');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
color = getRuleHealthColor(warningRule);
expect(color).toEqual('warning');
});
it('should return the correct color for failed rule', () => {
let color = getRuleHealthColor(failedRule);
expect(color).toEqual('danger');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
color = getRuleHealthColor(failedRule);
expect(color).toEqual('danger');
});
});
describe('getRuleStatusMessage', () => {
it('should get the status message for a successful rule', () => {
let statusMessage = getRuleStatusMessage(mockRule);
expect(statusMessage).toEqual('Succeeded');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
statusMessage = getRuleStatusMessage(mockRule);
expect(statusMessage).toEqual('Active');
});
it('should get the status message for a warning rule', () => {
let statusMessage = getRuleStatusMessage(warningRule);
expect(statusMessage).toEqual('Warning');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
statusMessage = getRuleStatusMessage(warningRule);
expect(statusMessage).toEqual('Warning');
});
it('should get the status message for a failed rule', () => {
let statusMessage = getRuleStatusMessage(failedRule);
expect(statusMessage).toEqual('Failed');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
statusMessage = getRuleStatusMessage(failedRule);
expect(statusMessage).toEqual('Error');
});
it('should get the status message for a license error rule', () => {
let statusMessage = getRuleStatusMessage(licenseErrorRule);
expect(statusMessage).toEqual('License Error');
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
statusMessage = getRuleStatusMessage(licenseErrorRule);
expect(statusMessage).toEqual('License Error');
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 {
RuleLastRunOutcomes,
RuleExecutionStatuses,
RuleExecutionStatusErrorReasons,
} from '@kbn/alerting-plugin/common';
import { getIsExperimentalFeatureEnabled } from '../get_experimental_features';
import { Rule } from '../../types';
import {
rulesLastRunOutcomeTranslationMapping,
rulesStatusesTranslationsMapping,
ALERT_STATUS_LICENSE_ERROR,
} from '../../application/sections/rules_list/translations';
export const getOutcomeHealthColor = (status: RuleLastRunOutcomes) => {
switch (status) {
case 'succeeded':
return 'success';
case 'failed':
return 'danger';
case 'warning':
return 'warning';
default:
return 'subdued';
}
};
export const getExecutionStatusHealthColor = (status: RuleExecutionStatuses) => {
switch (status) {
case 'active':
return 'success';
case 'error':
return 'danger';
case 'ok':
return 'primary';
case 'pending':
return 'accent';
case 'warning':
return 'warning';
default:
return 'subdued';
}
};
export const getRuleHealthColor = (rule: Rule) => {
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
if (isRuleLastRunOutcomeEnabled) {
return (rule.lastRun && getOutcomeHealthColor(rule.lastRun.outcome)) || 'subdued';
}
return getExecutionStatusHealthColor(rule.executionStatus.status);
};
export const getIsLicenseError = (rule: Rule) => {
return (
rule.lastRun?.warning === RuleExecutionStatusErrorReasons.License ||
rule.executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License
);
};
export const getRuleStatusMessage = (rule: Rule) => {
const isLicenseError = getIsLicenseError(rule);
const isRuleLastRunOutcomeEnabled = getIsExperimentalFeatureEnabled('ruleLastRunOutcome');
if (isLicenseError) {
return ALERT_STATUS_LICENSE_ERROR;
}
if (isRuleLastRunOutcomeEnabled) {
return rule.lastRun && rulesLastRunOutcomeTranslationMapping[rule.lastRun.outcome];
}
return rulesStatusesTranslationsMapping[rule.executionStatus.status];
};

View file

@ -40,6 +40,7 @@ import {
RuleTypeParams,
ActionVariable,
RuleType as CommonRuleType,
RuleLastRun,
} from '@kbn/alerting-plugin/common';
import type { BulkOperationError } from '@kbn/alerting-plugin/server';
import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common';
@ -106,6 +107,7 @@ export type {
RuleStatusDropdownProps,
RuleTagFilterProps,
RuleStatusFilterProps,
RuleLastRun,
RuleTagBadgeProps,
RuleTagBadgeOptions,
RuleEventLogListProps,
@ -301,7 +303,7 @@ export interface RuleType<
export type SanitizedRuleType = Omit<RuleType, 'apiKey'>;
export type RuleUpdates = Omit<Rule, 'id' | 'executionStatus'>;
export type RuleUpdates = Omit<Rule, 'id' | 'executionStatus' | 'lastRun' | 'nextRun'>;
export interface RuleTableItem extends Rule {
ruleType: RuleType['name'];

View file

@ -368,19 +368,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await refreshAlertsList();
await find.waitForDeletedByCssSelector('.euiBasicTable-loading');
const refreshResults = await pageObjects.triggersActionsUI.getAlertsListWithStatus();
expect(refreshResults.map((item: any) => item.status).sort()).to.eql(['Error', 'Ok']);
expect(refreshResults.map((item: any) => item.status).sort()).to.eql([
'Failed',
'Succeeded',
]);
});
await refreshAlertsList();
await find.waitForDeletedByCssSelector('.euiBasicTable-loading');
await testSubjects.click('ruleExecutionStatusFilterButton');
await testSubjects.click('ruleExecutionStatuserrorFilterOption'); // select Error status filter
await testSubjects.click('ruleLastRunOutcomeFilterButton');
await testSubjects.click('ruleLastRunOutcomefailedFilterOption'); // select Error status filter
await retry.try(async () => {
const filterErrorOnlyResults =
await pageObjects.triggersActionsUI.getAlertsListWithStatus();
expect(filterErrorOnlyResults.length).to.equal(1);
expect(filterErrorOnlyResults[0].name).to.equal(`${failingAlert.name}Test: Failing`);
expect(filterErrorOnlyResults[0].interval).to.equal('30 sec');
expect(filterErrorOnlyResults[0].status).to.equal('Error');
expect(filterErrorOnlyResults[0].status).to.equal('Failed');
expect(filterErrorOnlyResults[0].duration).to.match(/\d{2,}:\d{2}/);
});
});
@ -393,7 +396,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
expect(refreshResults.length).to.equal(1);
expect(refreshResults[0].name).to.equal(`${createdAlert.name}Test: Noop`);
expect(refreshResults[0].interval).to.equal('1 min');
expect(refreshResults[0].status).to.equal('Ok');
expect(refreshResults[0].status).to.equal('Succeeded');
expect(refreshResults[0].duration).to.match(/\d{2,}:\d{2}/);
});
@ -417,11 +420,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await retry.try(async () => {
await refreshAlertsList();
expect(await testSubjects.getVisibleText('totalRulesCount')).to.be('2 rules');
expect(await testSubjects.getVisibleText('totalActiveRulesCount')).to.be('Active: 0');
expect(await testSubjects.getVisibleText('totalOkRulesCount')).to.be('Ok: 1');
expect(await testSubjects.getVisibleText('totalErrorRulesCount')).to.be('Error: 1');
expect(await testSubjects.getVisibleText('totalPendingRulesCount')).to.be('Pending: 0');
expect(await testSubjects.getVisibleText('totalUnknownRulesCount')).to.be('Unknown: 0');
expect(await testSubjects.getVisibleText('totalSucceededRulesCount')).to.be('Succeeded: 1');
expect(await testSubjects.getVisibleText('totalFailedRulesCount')).to.be('Failed: 1');
expect(await testSubjects.getVisibleText('totalWarningRulesCount')).to.be('Warning: 0');
});
});
@ -433,7 +434,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
expect(refreshResults.length).to.equal(1);
expect(refreshResults[0].name).to.equal(`${createdAlert.name}Test: Noop`);
expect(refreshResults[0].interval).to.equal('1 min');
expect(refreshResults[0].status).to.equal('Ok');
expect(refreshResults[0].status).to.equal('Succeeded');
expect(refreshResults[0].duration).to.match(/\d{2,}:\d{2}/);
});

View file

@ -93,6 +93,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'internalAlertsTable',
'ruleTagFilter',
'ruleStatusFilter',
'ruleLastRunOutcome',
])}`,
`--xpack.alerting.rules.minimumScheduleInterval.value="2s"`,
`--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`,