[Actionable Observability] Add rule details page (#130330)

* Add rule details page

* Fix route

* Fix route

* Add useBreadcrumbs

* Add rule summary

* Complete rule data summary

* Update styling

* Add update rule

* Add edit role

* Update desgin

* Add conditions

* Add connectors icons

* Fix more button

* Remove unused FelxBox

* Add fetch alerts

* Move to items to components folder

* Format dates

* Add tabs

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* Use the shared getRuleStatusDropdown

* Add permissions

* Better handling errors

* Fix styling

* Fix tag style

* Add tags component

* Use tags component from triggers aciton ui

* Add last24hAlerts hook

* Fix last 24h alerts count hook

* Fix large font

* Fix font size

* Fix size Actions

* Fix fontsize page header

* Fix conditions size

* Fix text move vertically on small screen

* Update style

* Update alerts counts style

* Cleanup

* Add formatter for the interval

* Add edit button on the definition section

* Add delete modal

* Add loader

* Fix conditions panctuation

* Fix size

* Use the healthColor function from rule component

* Add loading while deleting a rule

* Use connectors name to show actions

* Fix type

* Fix rule page

* Fix types

* Use common RULES_PAGE_LINK var

* Fix checks

* Better error handling

* Better i18n

* Code review

* Fix checks i18n

* Use abort signal

* Revert signal for loadRule as there is no tests

* Fix style

* Fixing tests

* Reduce bundle size

* Fix i18n

* Bump limits
This commit is contained in:
Faisal Kanout 2022-05-16 19:01:59 +03:00 committed by GitHub
parent ffc515bf63
commit 0248e9357f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1110 additions and 4 deletions

View file

@ -58,7 +58,7 @@ pageLoadAssetSize:
telemetry: 51957
telemetryManagementSection: 38586
transform: 41007
triggersActionsUi: 104400
triggersActionsUi: 105800 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728
upgradeAssistant: 81241
urlForwarding: 32579
usageCollection: 39762

View file

@ -0,0 +1,159 @@
/*
* 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.
*/
/*
* 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 { useEffect, useState, useCallback, useRef } from 'react';
import { AsApiContract } from '@kbn/actions-plugin/common';
import { HttpSetup } from '@kbn/core/public';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants';
import { RULE_LOAD_ERROR } from '../pages/rule_details/translations';
interface UseFetchLast24hAlertsProps {
http: HttpSetup;
features: string;
ruleId: string;
}
interface FetchLast24hAlerts {
isLoadingLast24hAlerts: boolean;
last24hAlerts: number;
errorLast24hAlerts: string | undefined;
}
export function useFetchLast24hAlerts({ http, features, ruleId }: UseFetchLast24hAlertsProps) {
const [last24hAlerts, setLast24hAlerts] = useState<FetchLast24hAlerts>({
isLoadingLast24hAlerts: true,
last24hAlerts: 0,
errorLast24hAlerts: undefined,
});
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
const fetchLast24hAlerts = useCallback(async () => {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
try {
if (!features) return;
const { index } = await fetchIndexNameAPI({
http,
features,
});
const { error, alertsCount } = await fetchLast24hAlertsAPI({
http,
index,
ruleId,
signal: abortCtrlRef.current.signal,
});
if (error) throw error;
if (!isCancelledRef.current) {
setLast24hAlerts((oldState: FetchLast24hAlerts) => ({
...oldState,
last24hAlerts: alertsCount,
isLoading: false,
}));
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
setLast24hAlerts((oldState: FetchLast24hAlerts) => ({
...oldState,
isLoading: false,
errorLast24hAlerts: RULE_LOAD_ERROR(
error instanceof Error ? error.message : typeof error === 'string' ? error : ''
),
}));
}
}
}
}, [http, features, ruleId]);
useEffect(() => {
fetchLast24hAlerts();
}, [fetchLast24hAlerts]);
return last24hAlerts;
}
interface IndexName {
index: string;
}
export async function fetchIndexNameAPI({
http,
features,
}: {
http: HttpSetup;
features: string;
}): Promise<IndexName> {
const res = await http.get<{ index_name: string[] }>(`${BASE_RAC_ALERTS_API_PATH}/index`, {
query: { features },
});
return {
index: res.index_name[0],
};
}
export async function fetchLast24hAlertsAPI({
http,
index,
ruleId,
signal,
}: {
http: HttpSetup;
index: string;
ruleId: string;
signal: AbortSignal;
}): Promise<{
error: string | null;
alertsCount: number;
}> {
try {
const res = await http.post<AsApiContract<any>>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
signal,
body: JSON.stringify({
index,
query: {
bool: {
must: [
{
term: {
'kibana.alert.rule.uuid': ruleId,
},
},
{
range: {
'@timestamp': {
gte: 'now-24h',
lt: 'now',
},
},
},
],
},
},
aggs: {
alerts_count: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
}),
});
return {
error: null,
alertsCount: res.aggregations.alerts_count.value,
};
} catch (error) {
return {
error,
alertsCount: 0,
};
}
}

View file

@ -0,0 +1,46 @@
/*
* 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 { useEffect, useState, useCallback } from 'react';
import { loadRule } from '@kbn/triggers-actions-ui-plugin/public';
import { FetchRuleProps, FetchRule } from '../pages/rule_details/types';
import { RULE_LOAD_ERROR } from '../pages/rule_details/translations';
export function useFetchRule({ ruleId, http }: FetchRuleProps) {
const [ruleSummary, setRuleSummary] = useState<FetchRule>({
isRuleLoading: true,
rule: undefined,
errorRule: undefined,
});
const fetchRuleSummary = useCallback(async () => {
try {
const rule = await loadRule({
http,
ruleId,
});
setRuleSummary((oldState: FetchRule) => ({
...oldState,
isRuleLoading: false,
rule,
}));
} catch (error) {
setRuleSummary((oldState: FetchRule) => ({
...oldState,
isRuleLoading: false,
errorRule: RULE_LOAD_ERROR(
error instanceof Error ? error.message : typeof error === 'string' ? error : ''
),
}));
}
}, [ruleId, http]);
useEffect(() => {
fetchRuleSummary();
}, [fetchRuleSummary]);
return { ...ruleSummary, reloadRule: fetchRuleSummary };
}

View file

@ -0,0 +1,51 @@
/*
* 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 { useEffect, useState, useCallback } from 'react';
import { ActionConnector, loadAllActions } from '@kbn/triggers-actions-ui-plugin/public';
import { FetchRuleActionsProps } from '../pages/rule_details/types';
import { ACTIONS_LOAD_ERROR } from '../pages/rule_details/translations';
interface FetchActions {
isLoadingActions: boolean;
allActions: Array<ActionConnector<Record<string, unknown>>>;
errorActions: string | undefined;
}
export function useFetchRuleActions({ http }: FetchRuleActionsProps) {
const [ruleActions, setRuleActions] = useState<FetchActions>({
isLoadingActions: true,
allActions: [] as Array<ActionConnector<Record<string, unknown>>>,
errorActions: undefined,
});
const fetchRuleActions = useCallback(async () => {
try {
const response = await loadAllActions({
http,
});
setRuleActions((oldState: FetchActions) => ({
...oldState,
isLoadingActions: false,
allActions: response,
}));
} catch (error) {
setRuleActions((oldState: FetchActions) => ({
...oldState,
isLoadingActions: false,
errorActions: ACTIONS_LOAD_ERROR(
error instanceof Error ? error.message : typeof error === 'string' ? error : ''
),
}));
}
}, [http]);
useEffect(() => {
fetchRuleActions();
}, [fetchRuleActions]);
return { ...ruleActions, reloadRuleActions: fetchRuleActions };
}

View file

@ -0,0 +1,48 @@
/*
* 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 { useEffect, useState, useCallback } from 'react';
import { loadRuleSummary } from '@kbn/triggers-actions-ui-plugin/public';
import { FetchRuleSummaryProps, FetchRuleSummary } from '../pages/rule_details/types';
import { RULE_LOAD_ERROR } from '../pages/rule_details/translations';
export function useFetchRuleSummary({ ruleId, http }: FetchRuleSummaryProps) {
const [ruleSummary, setRuleSummary] = useState<FetchRuleSummary>({
isLoadingRuleSummary: true,
ruleSummary: undefined,
errorRuleSummary: undefined,
});
const fetchRuleSummary = useCallback(async () => {
setRuleSummary((oldState: FetchRuleSummary) => ({ ...oldState, isLoading: true }));
try {
const response = await loadRuleSummary({
http,
ruleId,
});
setRuleSummary((oldState: FetchRuleSummary) => ({
...oldState,
isLoading: false,
ruleSummary: response,
}));
} catch (error) {
setRuleSummary((oldState: FetchRuleSummary) => ({
...oldState,
isLoading: false,
errorRuleSummary: RULE_LOAD_ERROR(
error instanceof Error ? error.message : typeof error === 'string' ? error : ''
),
}));
}
}, [ruleId, http]);
useEffect(() => {
fetchRuleSummary();
}, [fetchRuleSummary]);
return { ...ruleSummary, reloadRuleSummary: fetchRuleSummary };
}

View file

@ -0,0 +1,61 @@
/*
* 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 {
EuiText,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
IconType,
EuiLoadingSpinner,
} from '@elastic/eui';
import { intersectionBy } from 'lodash';
import { ActionsProps } from '../types';
import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions';
import { useKibana } from '../../../utils/kibana_react';
interface MapActionTypeIcon {
[key: string]: string | IconType;
}
const mapActionTypeIcon: MapActionTypeIcon = {
/* TODO: Add the rest of the application logs (SVGs ones) */
'.server-log': 'logsApp',
'.email': 'email',
'.pagerduty': 'apps',
'.index': 'indexOpen',
'.slack': 'logoSlack',
'.webhook': 'logoWebhook',
};
export function Actions({ ruleActions }: ActionsProps) {
const {
http,
notifications: { toasts },
} = useKibana().services;
const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http });
if (ruleActions && ruleActions.length <= 0) return <EuiText size="s">0</EuiText>;
const actions = intersectionBy(allActions, ruleActions, 'actionTypeId');
if (isLoadingActions) return <EuiLoadingSpinner size="s" />;
return (
<EuiFlexGroup direction="column">
{actions.map((action) => (
<>
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiIcon size="m" type={mapActionTypeIcon[action.actionTypeId] ?? 'apps'} />
</EuiFlexItem>
<EuiFlexItem style={{ margin: '0px' }}>
<EuiText size="s">{action.name}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
))}
{errorActions && toasts.addDanger({ title: errorActions })}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { PageTitle } from './page_title';
export { ItemTitleRuleSummary } from './item_title_rule_summary';
export { ItemValueRuleSummary } from './item_value_rule_summary';
export { Actions } from './actions';

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexItem, EuiTitle } from '@elastic/eui';
import { ItemTitleRuleSummaryProps } from '../types';
export function ItemTitleRuleSummary({ children }: ItemTitleRuleSummaryProps) {
return (
<EuiTitle size="xxs">
<EuiFlexItem style={{ whiteSpace: 'nowrap' }} grow={1}>
{children}
</EuiFlexItem>
</EuiTitle>
);
}

View file

@ -0,0 +1,17 @@
/*
* 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 { EuiFlexItem, EuiText } from '@elastic/eui';
import { ItemValueRuleSummaryProps } from '../types';
export function ItemValueRuleSummary({ itemValue, extraSpace = true }: ItemValueRuleSummaryProps) {
return (
<EuiFlexItem grow={extraSpace ? 3 : 1}>
<EuiText size="s">{itemValue}</EuiText>
</EuiFlexItem>
);
}

View file

@ -0,0 +1,46 @@
/*
* 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, { useState } from 'react';
import moment from 'moment';
import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ExperimentalBadge } from '../../../components/shared/experimental_badge';
import { PageHeaderProps } from '../types';
import { useKibana } from '../../../utils/kibana_react';
import { LAST_UPDATED_MESSAGE, CREATED_WORD, BY_WORD, ON_WORD } from '../translations';
export function PageTitle({ rule }: PageHeaderProps) {
const { triggersActionsUi } = useKibana().services;
const [isTagsPopoverOpen, setIsTagsPopoverOpen] = useState<boolean>(false);
const tagsClicked = () =>
setIsTagsPopoverOpen(
(oldStateIsTagsPopoverOpen) => rule.tags.length > 0 && !oldStateIsTagsPopoverOpen
);
const closeTagsPopover = () => setIsTagsPopoverOpen(false);
return (
<>
{rule.name} <ExperimentalBadge />
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem component="span" grow={false}>
<EuiText color="subdued" size="xs">
<b>{LAST_UPDATED_MESSAGE}</b> {BY_WORD} {rule.updatedBy} {ON_WORD}&nbsp;
{moment(rule.updatedAt).format('ll')} &emsp;
<b>{CREATED_WORD}</b> {BY_WORD} {rule.createdBy} {ON_WORD}&nbsp;
{moment(rule.createdAt).format('ll')}
</EuiText>
</EuiFlexItem>
{rule.tags.length > 0 &&
triggersActionsUi.getRuleTagBadge({
isOpen: isTagsPopoverOpen,
tags: rule.tags,
onClick: () => tagsClicked(),
onClose: () => closeTagsPopover(),
})}
</EuiFlexGroup>
</>
);
}

View file

@ -0,0 +1,23 @@
/*
* 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 { RuleType, Rule } from '@kbn/triggers-actions-ui-plugin/public';
type Capabilities = Record<string, any>;
export type InitialRule = Partial<Rule> &
Pick<Rule, 'params' | 'consumer' | 'schedule' | 'actions' | 'tags' | 'notifyWhen'>;
export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean {
return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false;
}
export const hasExecuteActionsCapability = (capabilities: Capabilities) =>
capabilities?.actions?.execute;
export const RULES_PAGE_LINK = '/app/observability/alerts/rules';
export const ALERT_PAGE_LINK = '/app/observability/alerts';

View file

@ -0,0 +1,483 @@
/*
* 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, { useState, useEffect, useCallback } from 'react';
import moment from 'moment';
import { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import {
EuiText,
EuiSpacer,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiPanel,
EuiTitle,
EuiHealth,
EuiPopover,
EuiHorizontalRule,
EuiTabbedContent,
EuiEmptyPrompt,
} from '@elastic/eui';
import {
enableRule,
disableRule,
snoozeRule,
unsnoozeRule,
deleteRules,
useLoadRuleTypes,
RuleType,
} from '@kbn/triggers-actions-ui-plugin/public';
// TODO: use a Delete modal from triggersActionUI when it's sharable
import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation';
import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner';
import { getHealthColor, OBSERVABILITY_SOLUTIONS } from '../rules/config';
import {
RuleDetailsPathParams,
EVENT_ERROR_LOG_TAB,
EVENT_LOG_LIST_TAB,
ALERT_LIST_TAB,
} from './types';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useFetchRule } from '../../hooks/use_fetch_rule';
import { RULES_BREADCRUMB_TEXT } from '../rules/translations';
import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components';
import { useKibana } from '../../utils/kibana_react';
import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts';
import { formatInterval } from './utils';
import {
hasExecuteActionsCapability,
hasAllPrivilege,
RULES_PAGE_LINK,
ALERT_PAGE_LINK,
} from './config';
export function RuleDetailsPage() {
const {
http,
triggersActionsUi: { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout },
application: { capabilities, navigateToUrl },
notifications: { toasts },
} = useKibana().services;
const { ruleId } = useParams<RuleDetailsPathParams>();
const { ObservabilityPageTemplate } = usePluginContext();
const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http });
const { ruleTypes } = useLoadRuleTypes({
filteredSolutions: OBSERVABILITY_SOLUTIONS,
});
const [features, setFeatures] = useState<string>('');
const [ruleType, setRuleType] = useState<RuleType<string, string>>();
const [ruleToDelete, setRuleToDelete] = useState<string[]>([]);
const [isPageLoading, setIsPageLoading] = useState(false);
const { last24hAlerts } = useFetchLast24hAlerts({
http,
features,
ruleId,
});
const [editFlyoutVisible, setEditFlyoutVisible] = useState<boolean>(false);
const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false);
const handleClosePopover = useCallback(() => setIsRuleEditPopoverOpen(false), []);
const handleOpenPopover = useCallback(() => setIsRuleEditPopoverOpen(true), []);
const handleRemoveRule = useCallback(() => {
setIsRuleEditPopoverOpen(false);
if (rule) setRuleToDelete([rule.id]);
}, [rule]);
const handleEditRule = useCallback(() => {
setIsRuleEditPopoverOpen(false);
setEditFlyoutVisible(true);
}, []);
useEffect(() => {
if (ruleTypes.length && rule) {
const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId);
if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) {
setRuleType(matchedRuleType);
setFeatures(matchedRuleType.producer);
} else setFeatures(rule.consumer);
}
}, [rule, ruleTypes]);
useBreadcrumbs([
{
text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', {
defaultMessage: 'Alerts',
}),
href: http.basePath.prepend(ALERT_PAGE_LINK),
},
{
href: http.basePath.prepend(RULES_PAGE_LINK),
text: RULES_BREADCRUMB_TEXT,
},
{
text: rule && rule.name,
},
]);
const canExecuteActions = hasExecuteActionsCapability(capabilities);
const canSaveRule =
rule &&
hasAllPrivilege(rule, ruleType) &&
// if the rule has actions, can the user save the rule's action params
(canExecuteActions || (!canExecuteActions && rule.actions.length === 0));
const hasEditButton =
// can the user save the rule
canSaveRule &&
// is this rule type editable from within Rules Management
(ruleTypeRegistry.has(rule.ruleTypeId)
? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext
: false);
const getRuleConditionsWording = () => {
const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0;
return (
<>
{numberOfConditions}&nbsp;
{i18n.translate('xpack.observability.ruleDetails.conditions', {
defaultMessage: 'condition{s}',
values: { s: numberOfConditions > 1 ? 's' : '' },
})}
</>
);
};
const tabs = [
{
id: EVENT_LOG_LIST_TAB,
name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', {
defaultMessage: 'Execution history',
}),
'data-test-subj': 'eventLogListTab',
content: <EuiText>Execution history</EuiText>,
},
{
id: ALERT_LIST_TAB,
name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', {
defaultMessage: 'Alerts',
}),
'data-test-subj': 'ruleAlertListTab',
content: <EuiText>Alerts</EuiText>,
},
{
id: EVENT_ERROR_LOG_TAB,
name: i18n.translate('xpack.observability.ruleDetails.rule.errorLogTabText', {
defaultMessage: 'Error log',
}),
'data-test-subj': 'errorLogTab',
content: <EuiText>Error log</EuiText>,
},
];
if (isPageLoading || isRuleLoading) return <CenterJustifiedSpinner />;
if (!rule || errorRule)
return (
<EuiPanel>
<EuiEmptyPrompt
iconType="alert"
color="danger"
title={
<h2>
{i18n.translate('xpack.observability.ruleDetails.errorPromptTitle', {
defaultMessage: 'Unable to load rule details',
})}
</h2>
}
body={
<p>
{i18n.translate('xpack.observability.ruleDetails.errorPromptBody', {
defaultMessage: 'There was an error loading the rule details.',
})}
</p>
}
/>
</EuiPanel>
);
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: <PageTitle rule={rule} />,
bottomBorder: false,
rightSideItems: hasEditButton
? [
<EuiFlexGroup direction="rowReverse" alignItems="center">
<EuiFlexItem>
<EuiPopover
id="contextRuleEditMenu"
isOpen={isRuleEditPopoverOpen}
closePopover={handleClosePopover}
button={
<EuiButtonIcon
display="base"
size="m"
iconType="boxesHorizontal"
aria-label="More"
onClick={handleOpenPopover}
/>
}
>
<EuiFlexGroup direction="column" alignItems="flexStart">
<EuiButtonEmpty size="s" iconType="pencil" onClick={handleEditRule}>
<EuiSpacer size="s" />
<EuiText size="s">
{i18n.translate('xpack.observability.ruleDetails.editRule', {
defaultMessage: 'Edit rule',
})}
</EuiText>
</EuiButtonEmpty>
<EuiSpacer size="s" />
<EuiButtonEmpty
size="s"
iconType="trash"
color="danger"
onClick={handleRemoveRule}
>
<EuiText size="s">
{i18n.translate('xpack.observability.ruleDetails.deleteRule', {
defaultMessage: 'Delete rule',
})}
</EuiText>
</EuiButtonEmpty>
<EuiSpacer size="s" />
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexItem>
<EuiTitle size="xxs">
<EuiFlexItem>
{i18n.translate('xpack.observability.ruleDetails.triggreAction.status', {
defaultMessage: 'Status',
})}
</EuiFlexItem>
</EuiTitle>
{getRuleStatusDropdown({
rule,
enableRule: async () => await enableRule({ http, id: rule.id }),
disableRule: async () => await disableRule({ http, id: rule.id }),
onRuleChanged: () => reloadRule(),
isEditable: hasEditButton,
snoozeRule: async (snoozeEndTime: string | -1) => {
await snoozeRule({ http, id: rule.id, snoozeEndTime });
},
unsnoozeRule: async () => await unsnoozeRule({ http, id: rule.id }),
})}
</EuiFlexItem>
</EuiFlexGroup>,
]
: [],
}}
>
<EuiFlexGroup wrap={true}>
{/* Left side of Rule Summary */}
<EuiFlexItem grow={1}>
<EuiPanel
color={getHealthColor(rule.executionStatus.status)}
hasBorder={false}
paddingSize={'l'}
>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="s">
<EuiHealth textSize="inherit" color={getHealthColor(rule.executionStatus.status)}>
{rule.executionStatus.status.charAt(0).toUpperCase() +
rule.executionStatus.status.slice(1)}
</EuiHealth>
</EuiTitle>
</EuiFlexItem>
<EuiSpacer size="l" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.lastRun', {
defaultMessage: 'Last Run',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
extraSpace={false}
itemValue={moment(rule.executionStatus.lastExecutionDate).fromNow()}
/>
</EuiFlexGroup>
<EuiSpacer size="xl" />
<EuiHorizontalRule margin="none" />
<EuiSpacer size="s" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.alerts', {
defaultMessage: 'Alerts',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
extraSpace={false}
itemValue={`
${String(last24hAlerts)} ${i18n.translate(
'xpack.observability.ruleDetails.last24h',
{
defaultMessage: '(last 24 h)',
}
)}`}
/>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiSpacer size="l" />
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{/* Right side of Rule Summary */}
<EuiFlexItem grow={3}>
<EuiPanel color="subdued" hasBorder={false} paddingSize={'l'}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiTitle size="s">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.observability.ruleDetails.definition', {
defaultMessage: 'Definition',
})}
</EuiFlexItem>
</EuiTitle>
{hasEditButton && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType={'pencil'} onClick={() => setEditFlyoutVisible(true)} />
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem>
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.ruleType', {
defaultMessage: 'Rule type',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary itemValue={rule.ruleTypeId} />
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup alignItems="flexStart">
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.description', {
defaultMessage: 'Description',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
itemValue={ruleTypeRegistry.get(rule.ruleTypeId).description}
/>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.conditionsTitle', {
defaultMessage: 'Conditions',
})}
</ItemTitleRuleSummary>
<EuiFlexItem grow={3}>
<EuiFlexGroup alignItems="center">
{hasEditButton ? (
<EuiButtonEmpty onClick={() => setEditFlyoutVisible(true)}>
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
</EuiButtonEmpty>
) : (
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.runsEvery', {
defaultMessage: 'Runs every',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary itemValue={formatInterval(rule.schedule.interval)} />
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.notifyWhen', {
defaultMessage: 'Notify',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary itemValue={String(rule.notifyWhen)} />
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup alignItems="baseline">
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.actions', {
defaultMessage: 'Actions',
})}
</ItemTitleRuleSummary>
<EuiFlexItem grow={3}>
<Actions ruleActions={rule.actions} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiTabbedContent data-test-subj="ruleDetailsTabbedContent" tabs={tabs} />
{editFlyoutVisible &&
getEditAlertFlyout({
initialRule: rule,
onClose: () => {
setEditFlyoutVisible(false);
},
onSave: reloadRule,
})}
<DeleteModalConfirmation
onDeleted={async () => {
setRuleToDelete([]);
navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK));
}}
onErrors={async () => {
setRuleToDelete([]);
navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK));
}}
onCancel={() => {}}
apiDeleteCall={deleteRules}
idsToDelete={ruleToDelete}
singleTitle={rule.name}
multipleTitle={rule.name}
setIsLoadingState={(isLoading: boolean) => {
setIsPageLoading(isLoading);
}}
/>
{errorRule && toasts.addDanger({ title: errorRule })}
</ObservabilityPageTemplate>
);
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const RULE_LOAD_ERROR = (errorMessage: string) =>
i18n.translate('xpack.observability.ruleDetails.ruleLoadError', {
defaultMessage: 'Unable to load rule. Reason: {message}',
values: { message: errorMessage },
});
export const ACTIONS_LOAD_ERROR = (errorMessage: string) =>
i18n.translate('xpack.observability.ruleDetails.connectorsLoadError', {
defaultMessage: 'Unable to load rule actions connectors. Reason: {message}',
values: { message: errorMessage },
});
export const TAGS_TITLE = i18n.translate('xpack.observability.ruleDetails.tagsTitle', {
defaultMessage: 'Tags',
});
export const LAST_UPDATED_MESSAGE = i18n.translate(
'xpack.observability.ruleDetails.lastUpdatedMessage',
{
defaultMessage: 'Last updated',
}
);
export const BY_WORD = i18n.translate('xpack.observability.ruleDetails.byWord', {
defaultMessage: 'by',
});
export const ON_WORD = i18n.translate('xpack.observability.ruleDetails.onWord', {
defaultMessage: 'on',
});
export const CREATED_WORD = i18n.translate('xpack.observability.ruleDetails.createdWord', {
defaultMessage: 'Created',
});

View file

@ -0,0 +1,70 @@
/*
* 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 { HttpSetup } from '@kbn/core/public';
import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public';
export interface RuleDetailsPathParams {
ruleId: string;
}
export interface PageHeaderProps {
rule: Rule;
}
export interface FetchRuleProps {
ruleId: string;
http: HttpSetup;
}
export interface FetchRule {
isRuleLoading: boolean;
rule?: Rule;
ruleType?: RuleType;
errorRule?: string;
}
export interface FetchRuleSummaryProps {
ruleId: string;
http: HttpSetup;
}
export interface FetchRuleActionsProps {
http: HttpSetup;
}
export interface FetchRuleSummary {
isLoadingRuleSummary: boolean;
ruleSummary?: RuleSummary;
errorRuleSummary?: string;
}
export interface AlertListItemStatus {
label: string;
healthColor: string;
actionGroup?: string;
}
export interface AlertListItem {
alert: string;
status: AlertListItemStatus;
start?: Date;
duration: number;
isMuted: boolean;
sortPriority: number;
}
export interface ItemTitleRuleSummaryProps {
children: string;
}
export interface ItemValueRuleSummaryProps {
itemValue: string;
extraSpace?: boolean;
}
export interface ActionsProps {
ruleActions: any[];
}
export const EVENT_LOG_LIST_TAB = 'rule_event_log_list';
export const ALERT_LIST_TAB = 'rule_alert_list';
export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list';

View file

@ -0,0 +1,15 @@
/*
* 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 { formatDurationFromTimeUnitChar, TimeUnitChar } from '../../../common';
export const formatInterval = (ruleInterval: string) => {
const interval: string[] | null = ruleInterval.match(/(^\d*)([s|m|h|d])/);
if (!interval || interval.length < 3) return ruleInterval;
const value: number = +interval[1];
const unit = interval[2] as TimeUnitChar;
return formatDurationFromTimeUnitChar(value, unit);
};

View file

@ -12,9 +12,7 @@ import { useKibana } from '../../../utils/kibana_react';
export function Name({ name, rule }: RuleNameProps) {
const { http } = useKibana().services;
const detailsLink = http.basePath.prepend(
`/app/management/insightsAndAlerting/triggersActions/rule/${rule.id}`
);
const detailsLink = http.basePath.prepend(`/app/observability/alerts/rules/${rule.id}`);
const link = (
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>

View file

@ -17,6 +17,7 @@ import { OverviewPage } from '../pages/overview';
import { jsonRt } from './json_rt';
import { ObservabilityExploratoryView } from '../components/shared/exploratory_view/obsv_exploratory_view';
import { RulesPage } from '../pages/rules';
import { RuleDetailsPage } from '../pages/rule_details';
import { AlertingPages } from '../config';
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
@ -109,4 +110,11 @@ export const routes = {
params: {},
exact: true,
},
'/alerts/rules/:ruleId': {
handler: () => {
return <RuleDetailsPage />;
},
params: {},
exact: true,
},
};

View file

@ -31,6 +31,11 @@ export type {
RuleTypeParams,
AsApiContract,
RuleTableItem,
AlertsTableProps,
AlertsData,
BulkActionsObjectProp,
RuleSummary,
AlertStatus,
AlertsTableConfigurationRegistryContract,
} from './types';
@ -54,6 +59,8 @@ export { Plugin };
export * from './plugin';
// TODO remove this import when we expose the Rules tables as a component
export { loadRules } from './application/lib/rule_api/rules';
export { loadRuleTypes } from './application/lib/rule_api';
export { loadRuleSummary } from './application/lib/rule_api/rule_summary';
export { deleteRules } from './application/lib/rule_api/delete';
export { enableRule } from './application/lib/rule_api/enable';
export { disableRule } from './application/lib/rule_api/disable';
@ -63,6 +70,8 @@ export { snoozeRule } from './application/lib/rule_api/snooze';
export { unsnoozeRule } from './application/lib/rule_api/unsnooze';
export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/aggregate';
export { useLoadRuleTypes } from './application/hooks/use_load_rule_types';
export { loadRule } from './application/lib/rule_api/get_rule';
export { loadAllActions } from './application/lib/action_connector_api';
export { loadActionTypes } from './application/lib/action_connector_api/connector_types';