diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 5116416b527a5..3bdf0f92b06f9 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -56,6 +56,7 @@ export const DETECTION_ENGINE_PREPACKAGED_URL = `${DETECTION_ENGINE_RULES_URL}/p export const DETECTION_ENGINE_PRIVILEGES_URL = `${DETECTION_ENGINE_URL}/privileges`; export const DETECTION_ENGINE_INDEX_URL = `${DETECTION_ENGINE_URL}/index`; export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`; +export const DETECTION_ENGINE_RULES_STATUS = `${DETECTION_ENGINE_URL}/rules/_find_statuses`; /** * Default signals index key for kibana.dev.yml diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index a13d6b75af630..fa663e5f487ca 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -19,12 +19,14 @@ import { ImportRulesProps, ExportRulesProps, RuleError, + RuleStatus, ImportRulesResponse, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; import { DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_PREPACKAGED_URL, + DETECTION_ENGINE_RULES_STATUS, } from '../../../../common/constants'; import * as i18n from '../../../pages/detection_engine/rules/translations'; @@ -302,3 +304,36 @@ export const exportRules = async ({ await throwIfNotOk(response); return response.blob(); }; + +/** + * Get Rule Status provided Rule ID + * + * @param id string of Rule ID's (not rule_id) + * + * @throws An error if response is not OK + */ +export const getRuleStatusById = async ({ + id, + signal, +}: { + id: string; + signal: AbortSignal; +}): Promise> => { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_STATUS}?ids=${encodeURIComponent( + JSON.stringify([id]) + )}`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal, + } + ); + + await throwIfNotOk(response); + return response.json(); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 7714779edf057..feef888c0d47f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -78,8 +78,12 @@ export const RuleSchema = t.intersection([ updated_by: t.string, }), t.partial({ + last_failure_at: t.string, + last_failure_message: t.string, output_index: t.string, saved_id: t.string, + status: t.string, + status_date: t.string, timeline_id: t.string, timeline_title: t.string, version: t.number, @@ -175,3 +179,13 @@ export interface ExportRulesProps { excludeExportDetails?: boolean; signal: AbortSignal; } + +export interface RuleStatus { + alert_id: string; + status_date: string; + status: string; + last_failure_at: string | null; + last_success_at: string | null; + last_failure_message: string | null; + last_success_message: string | null; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx new file mode 100644 index 0000000000000..216fbcea861a3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; + +import { useStateToaster } from '../../../components/toasters'; +import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; +import { getRuleStatusById } from './api'; +import * as i18n from './translations'; +import { RuleStatus } from './types'; + +type Return = [boolean, RuleStatus[] | null]; + +/** + * Hook for using to get a Rule from the Detection Engine API + * + * @param id desired Rule ID's (not rule_id) + * + */ +export const useRuleStatus = (id: string | undefined | null): Return => { + const [ruleStatus, setRuleStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + async function fetchData(idToFetch: string) { + try { + setLoading(true); + const ruleStatusResponse = await getRuleStatusById({ + id: idToFetch, + signal: abortCtrl.signal, + }); + + if (isSubscribed) { + setRuleStatus(ruleStatusResponse[id ?? '']); + } + } catch (error) { + if (isSubscribed) { + setRuleStatus(null); + errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + } + } + if (isSubscribed) { + setLoading(false); + } + } + if (id != null) { + fetchData(id); + } + return () => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [id]); + + return [loading, ruleStatus]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx index f7c2d067a29f5..3d4b878d9bf63 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr } from 'lodash/fp'; +import { isEmpty, getOr } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import React from 'react'; import { Query } from 'react-apollo'; @@ -79,15 +79,17 @@ class TimelineQueryComponent extends QueryTemplate< sourceId, sortField, } = this.props; + const defaultIndex = + indexPattern == null || isEmpty(indexPattern) + ? kibana.services.uiSettings.get(DEFAULT_INDEX_KEY) + : indexPattern?.title.split(','); const variables: GetTimelineQuery.Variables = { fieldRequested: fields, filterQuery: createFilter(filterQuery), sourceId, pagination: { limit, cursor: null, tiebreaker: null }, sortField, - defaultIndex: - indexPattern?.title.split(',') ?? - kibana.services.uiSettings.get(DEFAULT_INDEX_KEY), + defaultIndex, inspect: isInspected, }; return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index d149eb700ad03..66003cf55fc38 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -5,6 +5,7 @@ */ import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; @@ -299,7 +300,7 @@ export const SignalsTableComponent = React.memo( [additionalActions, canUserCRUD, selectAll] ); - if (loading) { + if (loading || isEmpty(signalsIndex)) { return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/failure_history.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/failure_history.tsx new file mode 100644 index 0000000000000..3b49cd30c9aab --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/failure_history.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/display-name */ + +import { + EuiBasicTable, + EuiPanel, + EuiLoadingContent, + EuiHealth, + EuiBasicTableColumn, +} from '@elastic/eui'; +import React, { memo } from 'react'; + +import { useRuleStatus } from '../../../../containers/detection_engine/rules/use_rule_status'; +import { RuleStatus } from '../../../../containers/detection_engine/rules'; +import { HeaderSection } from '../../../../components/header_section'; +import * as i18n from './translations'; +import { FormattedDate } from '../../../../components/formatted_date'; + +interface FailureHistoryProps { + id?: string | null; +} + +const FailureHistoryComponent: React.FC = ({ id }) => { + const [loading, ruleStatus] = useRuleStatus(id); + if (loading) { + return ( + + + + + ); + } + const columns: Array> = [ + { + name: i18n.COLUMN_STATUS_TYPE, + render: () => {i18n.TYPE_FAILED}, + truncateText: false, + width: '16%', + }, + { + field: 'last_failure_at', + name: i18n.COLUMN_FAILED_AT, + render: (value: string) => , + sortable: false, + truncateText: false, + width: '24%', + }, + { + field: 'last_failure_message', + name: i18n.COLUMN_FAILED_MSG, + render: (value: string) => <>{value}, + sortable: false, + truncateText: false, + width: '60%', + }, + ]; + return ( + + + rs.last_failure_at != null) : []} + sorting={{ sort: { field: 'status_date', direction: 'desc' } }} + /> + + ); +}; + +export const FailureHistory = memo(FailureHistoryComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index cdb08a0bbeffe..8839185f11427 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -4,9 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { + EuiButton, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiHealth, + EuiTab, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { memo, useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { Redirect, useParams } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; @@ -52,6 +60,9 @@ import { inputsSelectors } from '../../../../store/inputs'; import { State } from '../../../../store'; import { InputsRange } from '../../../../store/inputs/model'; import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; +import { getEmptyTagValue } from '../../../../components/empty_value'; +import { RuleStatusFailedCallOut } from './status_failed_callout'; +import { FailureHistory } from './failure_history'; interface ReduxProps { filters: esFilters.Filter[]; @@ -66,6 +77,19 @@ export interface DispatchProps { }>; } +const ruleDetailTabs = [ + { + id: 'signal', + name: detectionI18n.SIGNAL, + disabled: false, + }, + { + id: 'failure', + name: i18n.FAILURE_HISTORY_TAB, + disabled: false, + }, +]; + type RuleDetailsComponentProps = ReduxProps & DispatchProps; const RuleDetailsComponent = memo( @@ -81,6 +105,7 @@ const RuleDetailsComponent = memo( } = useUserInfo(); const { ruleId } = useParams(); const [isLoading, rule] = useRule(ruleId); + const [ruleDetailTab, setRuleDetailTab] = useState('signal'); const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule, detailsView: true, @@ -149,6 +174,42 @@ const RuleDetailsComponent = memo( filters, ]); + const statusColor = + rule?.status == null + ? 'subdued' + : rule?.status === 'succeeded' + ? 'success' + : rule?.status === 'failed' + ? 'danger' + : rule?.status === 'executing' + ? 'warning' + : 'subdued'; + + const tabs = useMemo( + () => + ruleDetailTabs.map(tab => ( + setRuleDetailTab(tab.id)} + isSelected={tab.id === ruleDetailTab} + disabled={tab.disabled} + key={tab.name} + > + {tab.name} + + )), + [ruleDetailTabs, ruleDetailTab, setRuleDetailTab] + ); + const ruleError = useMemo( + () => + rule?.status === 'failed' && ruleDetailTab === 'signal' && rule?.last_failure_at != null ? ( + + ) : null, + [rule, ruleDetailTab] + ); + const updateDateRangeCallback = useCallback( (min: number, max: number) => { setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); @@ -180,14 +241,43 @@ const RuleDetailsComponent = memo( border subtitle={subTitle} subtitle2={[ - lastSignals != null ? ( - <> - {detectionI18n.LAST_SIGNAL} - {': '} - {lastSignals} - - ) : null, - 'Status: Comming Soon', + ...(lastSignals != null + ? [ + <> + {detectionI18n.LAST_SIGNAL} + {': '} + {lastSignals} + , + ] + : []), + + + {i18n.STATUS} + {':'} + + + + {rule?.status ?? getEmptyTagValue()} + + + {rule?.status_date && ( + <> + + <>{i18n.STATUS_AT} + + + + + + )} + , ]} title={title} > @@ -216,73 +306,75 @@ const RuleDetailsComponent = memo( - + {ruleError} + {tabs} + {ruleDetailTab === 'signal' && ( + <> + + + + {defineRuleData != null && ( + + )} + + - - - - {defineRuleData != null && ( - - )} - - - - - - {aboutRuleData != null && ( - - )} - - - - - - {scheduleRuleData != null && ( - - )} - - - + + + {aboutRuleData != null && ( + + )} + + - - - - - - {ruleId != null && ( - + + + {scheduleRuleData != null && ( + + )} + + + + + + + {ruleId != null && ( + + )} + )} + {ruleDetailTab === 'failure' && } )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/status_failed_callout.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/status_failed_callout.tsx new file mode 100644 index 0000000000000..d1699a83becaf --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/status_failed_callout.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { memo } from 'react'; + +import { FormattedDate } from '../../../../components/formatted_date'; +import * as i18n from './translations'; + +interface RuleStatusFailedCallOutComponentProps { + date: string; + message: string; +} + +const RuleStatusFailedCallOutComponent: React.FC = ({ + date, + message, +}) => ( + + {i18n.ERROR_CALLOUT_TITLE} + + + + + } + color="danger" + iconType="alert" + > +

{message}

+
+); + +export const RuleStatusFailedCallOut = memo(RuleStatusFailedCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/translations.ts index 9dbb3b0079b0b..9976abc8412bf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/translations.ts @@ -34,3 +34,70 @@ export const ACTIVATE_RULE = i18n.translate( export const UNKNOWN = i18n.translate('xpack.siem.detectionEngine.ruleDetails.unknownDescription', { defaultMessage: 'Unknown', }); + +export const STATUS = i18n.translate('xpack.siem.detectionEngine.ruleDetails.statusDescription', { + defaultMessage: 'Status', +}); + +export const STATUS_AT = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.statusAtDescription', + { + defaultMessage: 'at', + } +); + +export const STATUS_DATE = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.statusDateDescription', + { + defaultMessage: 'Status date', + } +); + +export const ERROR_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.errorCalloutTitle', + { + defaultMessage: 'Rule failure at', + } +); + +export const FAILURE_HISTORY_TAB = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.failureHistoryTab', + { + defaultMessage: 'Failure History', + } +); + +export const LAST_FIVE_ERRORS = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.lastFiveErrorsTitle', + { + defaultMessage: 'Last five errors', + } +); + +export const COLUMN_STATUS_TYPE = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.statusTypeColumn', + { + defaultMessage: 'Type', + } +); + +export const COLUMN_FAILED_AT = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.statusFailedAtColumn', + { + defaultMessage: 'Failed at', + } +); + +export const COLUMN_FAILED_MSG = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.statusFailedMsgColumn', + { + defaultMessage: 'Failed message', + } +); + +export const TYPE_FAILED = i18n.translate( + 'xpack.siem.detectionEngine.ruleDetails.statusFailedDescription', + { + defaultMessage: 'Failed', + } +);