From 0248e9357f02464c47cbd296fc9400097a7920a2 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Mon, 16 May 2022 19:01:59 +0300 Subject: [PATCH] [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 --- packages/kbn-optimizer/limits.yml | 2 +- .../public/hooks/use_fetch_last24h_alerts.ts | 159 ++++++ .../public/hooks/use_fetch_rule.ts | 46 ++ .../public/hooks/use_fetch_rule_actions.ts | 51 ++ .../public/hooks/use_fetch_rule_summary.ts | 48 ++ .../pages/rule_details/components/actions.tsx | 61 +++ .../pages/rule_details/components/index.ts | 11 + .../components/item_title_rule_summary.tsx | 19 + .../components/item_value_rule_summary.tsx | 17 + .../rule_details/components/page_title.tsx | 46 ++ .../public/pages/rule_details/config.ts | 23 + .../public/pages/rule_details/index.tsx | 483 ++++++++++++++++++ .../public/pages/rule_details/translations.ts | 42 ++ .../public/pages/rule_details/types.ts | 70 +++ .../public/pages/rule_details/utils.ts | 15 + .../public/pages/rules/components/name.tsx | 4 +- .../observability/public/routes/index.tsx | 8 + .../triggers_actions_ui/public/index.ts | 9 + 18 files changed, 1110 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_fetch_rule.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/index.ts create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx create mode 100644 x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx create mode 100644 x-pack/plugins/observability/public/pages/rule_details/config.ts create mode 100644 x-pack/plugins/observability/public/pages/rule_details/index.tsx create mode 100644 x-pack/plugins/observability/public/pages/rule_details/translations.ts create mode 100644 x-pack/plugins/observability/public/pages/rule_details/types.ts create mode 100644 x-pack/plugins/observability/public/pages/rule_details/utils.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index c9460f7bab4ea..ae6c2f58fa28d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -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 diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts new file mode 100644 index 0000000000000..cc1313be29340 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts @@ -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({ + 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 { + 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>(`${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, + }; + } +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts new file mode 100644 index 0000000000000..07f13b4c80e7e --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts @@ -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({ + 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 }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts new file mode 100644 index 0000000000000..eaf01ed5ba59d --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts @@ -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>>; + errorActions: string | undefined; +} + +export function useFetchRuleActions({ http }: FetchRuleActionsProps) { + const [ruleActions, setRuleActions] = useState({ + isLoadingActions: true, + allActions: [] as Array>>, + 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 }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts new file mode 100644 index 0000000000000..7e7c71e503329 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts @@ -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({ + 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 }; +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx new file mode 100644 index 0000000000000..e3aadb60f8c4c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -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 0; + const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); + if (isLoadingActions) return ; + return ( + + {actions.map((action) => ( + <> + + + + + + {action.name} + + + + + ))} + {errorActions && toasts.addDanger({ title: errorActions })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts new file mode 100644 index 0000000000000..8020af09dedc2 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts @@ -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'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx new file mode 100644 index 0000000000000..d2a4805938305 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx new file mode 100644 index 0000000000000..6e178250c53ff --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx @@ -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 ( + + {itemValue} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx new file mode 100644 index 0000000000000..478fbf69a226c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx @@ -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(false); + const tagsClicked = () => + setIsTagsPopoverOpen( + (oldStateIsTagsPopoverOpen) => rule.tags.length > 0 && !oldStateIsTagsPopoverOpen + ); + const closeTagsPopover = () => setIsTagsPopoverOpen(false); + return ( + <> + {rule.name} + + + + {LAST_UPDATED_MESSAGE} {BY_WORD} {rule.updatedBy} {ON_WORD}  + {moment(rule.updatedAt).format('ll')}   + {CREATED_WORD} {BY_WORD} {rule.createdBy} {ON_WORD}  + {moment(rule.createdAt).format('ll')} + + + + {rule.tags.length > 0 && + triggersActionsUi.getRuleTagBadge({ + isOpen: isTagsPopoverOpen, + tags: rule.tags, + onClick: () => tagsClicked(), + onClose: () => closeTagsPopover(), + })} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts new file mode 100644 index 0000000000000..e73849f47e7b3 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/config.ts @@ -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; + +export type InitialRule = Partial & + Pick; + +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'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx new file mode 100644 index 0000000000000..ce7049bd61056 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -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(); + const { ObservabilityPageTemplate } = usePluginContext(); + const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); + const { ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + + const [features, setFeatures] = useState(''); + const [ruleType, setRuleType] = useState>(); + const [ruleToDelete, setRuleToDelete] = useState([]); + const [isPageLoading, setIsPageLoading] = useState(false); + const { last24hAlerts } = useFetchLast24hAlerts({ + http, + features, + ruleId, + }); + + const [editFlyoutVisible, setEditFlyoutVisible] = useState(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}  + {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: Execution history, + }, + { + id: ALERT_LIST_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', { + defaultMessage: 'Alerts', + }), + 'data-test-subj': 'ruleAlertListTab', + content: Alerts, + }, + { + id: EVENT_ERROR_LOG_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.errorLogTabText', { + defaultMessage: 'Error log', + }), + 'data-test-subj': 'errorLogTab', + content: Error log, + }, + ]; + + if (isPageLoading || isRuleLoading) return ; + if (!rule || errorRule) + return ( + + + {i18n.translate('xpack.observability.ruleDetails.errorPromptTitle', { + defaultMessage: 'Unable to load rule details', + })} + + } + body={ +

+ {i18n.translate('xpack.observability.ruleDetails.errorPromptBody', { + defaultMessage: 'There was an error loading the rule details.', + })} +

+ } + /> +
+ ); + return ( + , + bottomBorder: false, + rightSideItems: hasEditButton + ? [ + + + + } + > + + + + + {i18n.translate('xpack.observability.ruleDetails.editRule', { + defaultMessage: 'Edit rule', + })} + + + + + + {i18n.translate('xpack.observability.ruleDetails.deleteRule', { + defaultMessage: 'Delete rule', + })} + + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.triggreAction.status', { + defaultMessage: 'Status', + })} + + + + {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 }), + })} + + , + ] + : [], + }} + > + + {/* Left side of Rule Summary */} + + + + + + + {rule.executionStatus.status.charAt(0).toUpperCase() + + rule.executionStatus.status.slice(1)} + + + + + + + {i18n.translate('xpack.observability.ruleDetails.lastRun', { + defaultMessage: 'Last Run', + })} + + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.alerts', { + defaultMessage: 'Alerts', + })} + + + + + + + + + + + {/* Right side of Rule Summary */} + + + + + + + {i18n.translate('xpack.observability.ruleDetails.definition', { + defaultMessage: 'Definition', + })} + + + {hasEditButton && ( + + setEditFlyoutVisible(true)} /> + + )} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.ruleType', { + defaultMessage: 'Rule type', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.description', { + defaultMessage: 'Description', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.conditionsTitle', { + defaultMessage: 'Conditions', + })} + + + + {hasEditButton ? ( + setEditFlyoutVisible(true)}> + {getRuleConditionsWording()} + + ) : ( + {getRuleConditionsWording()} + )} + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.runsEvery', { + defaultMessage: 'Runs every', + })} + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.notifyWhen', { + defaultMessage: 'Notify', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.actions', { + defaultMessage: 'Actions', + })} + + + + + + + + + + + + + + {editFlyoutVisible && + getEditAlertFlyout({ + initialRule: rule, + onClose: () => { + setEditFlyoutVisible(false); + }, + onSave: reloadRule, + })} + { + 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 })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts new file mode 100644 index 0000000000000..f162f30906c21 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -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', +}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts new file mode 100644 index 0000000000000..9855bf2c7f184 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -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'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/utils.ts b/x-pack/plugins/observability/public/pages/rule_details/utils.ts new file mode 100644 index 0000000000000..0c907d93228a6 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/utils.ts @@ -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); +}; diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index cbde68ea27eb4..15cb44412d880 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -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 = ( diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 573eb0b7308e4..867e44613e07c 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -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 = DecodeParams; @@ -109,4 +110,11 @@ export const routes = { params: {}, exact: true, }, + '/alerts/rules/:ruleId': { + handler: () => { + return ; + }, + params: {}, + exact: true, + }, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index ad753a1708c3d..d328cbf303d61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -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';