add status to detail page with failure history (#54812)

This commit is contained in:
Xavier Mouligneau 2020-01-14 17:22:18 -05:00 committed by GitHub
parent c3430fefd9
commit daeddfdd78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 466 additions and 78 deletions

View file

@ -51,6 +51,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

View file

@ -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<Record<string, RuleStatus[]>> => {
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();
};

View file

@ -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;
}

View file

@ -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<RuleStatus[] | null>(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];
};

View file

@ -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<string[]>(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<string[]>(DEFAULT_INDEX_KEY),
defaultIndex,
inspect: isInspected,
};
return (

View file

@ -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<SignalsTableComponentProps>(
[additionalActions, canUserCRUD, selectAll]
);
if (loading) {
if (loading || isEmpty(signalsIndex)) {
return (
<EuiPanel>
<HeaderSection title={i18n.SIGNALS_TABLE_TITLE} />

View file

@ -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<FailureHistoryProps> = ({ id }) => {
const [loading, ruleStatus] = useRuleStatus(id);
if (loading) {
return (
<EuiPanel>
<HeaderSection title={i18n.LAST_FIVE_ERRORS} />
<EuiLoadingContent />
</EuiPanel>
);
}
const columns: Array<EuiBasicTableColumn<RuleStatus>> = [
{
name: i18n.COLUMN_STATUS_TYPE,
render: () => <EuiHealth color="danger">{i18n.TYPE_FAILED}</EuiHealth>,
truncateText: false,
width: '16%',
},
{
field: 'last_failure_at',
name: i18n.COLUMN_FAILED_AT,
render: (value: string) => <FormattedDate value={value} fieldName="last_failure_at" />,
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 (
<EuiPanel>
<HeaderSection title={i18n.LAST_FIVE_ERRORS} />
<EuiBasicTable
columns={columns}
loading={loading}
items={ruleStatus != null ? ruleStatus?.filter(rs => rs.last_failure_at != null) : []}
sorting={{ sort: { field: 'status_date', direction: 'desc' } }}
/>
</EuiPanel>
);
};
export const FailureHistory = memo(FailureHistoryComponent);

View file

@ -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<RuleDetailsComponentProps>(
@ -81,6 +105,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
} = 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<RuleDetailsComponentProps>(
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 => (
<EuiTab
onClick={() => setRuleDetailTab(tab.id)}
isSelected={tab.id === ruleDetailTab}
disabled={tab.disabled}
key={tab.name}
>
{tab.name}
</EuiTab>
)),
[ruleDetailTabs, ruleDetailTab, setRuleDetailTab]
);
const ruleError = useMemo(
() =>
rule?.status === 'failed' && ruleDetailTab === 'signal' && rule?.last_failure_at != null ? (
<RuleStatusFailedCallOut
message={rule?.last_failure_message ?? ''}
date={rule?.last_failure_at}
/>
) : null,
[rule, ruleDetailTab]
);
const updateDateRangeCallback = useCallback(
(min: number, max: number) => {
setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max });
@ -180,14 +241,43 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
border
subtitle={subTitle}
subtitle2={[
lastSignals != null ? (
<>
{detectionI18n.LAST_SIGNAL}
{': '}
{lastSignals}
</>
) : null,
'Status: Comming Soon',
...(lastSignals != null
? [
<>
{detectionI18n.LAST_SIGNAL}
{': '}
{lastSignals}
</>,
]
: []),
<EuiFlexGroup
gutterSize="xs"
alignItems="center"
justifyContent="flexStart"
>
<EuiFlexItem grow={false}>
{i18n.STATUS}
{':'}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color={statusColor}>
{rule?.status ?? getEmptyTagValue()}
</EuiHealth>
</EuiFlexItem>
{rule?.status_date && (
<>
<EuiFlexItem grow={false}>
<>{i18n.STATUS_AT}</>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<FormattedDate
value={rule?.status_date}
fieldName={i18n.STATUS_DATE}
/>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>,
]}
title={title}
>
@ -216,73 +306,75 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
</EuiFlexItem>
</EuiFlexGroup>
</HeaderPage>
{ruleError}
{tabs}
<EuiSpacer />
{ruleDetailTab === 'signal' && (
<>
<EuiFlexGroup>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={isLoading} title={ruleI18n.DEFINITION}>
{defineRuleData != null && (
<StepDefineRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={defineRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={isLoading} title={ruleI18n.DEFINITION}>
{defineRuleData != null && (
<StepDefineRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={defineRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem component="section" grow={2}>
<StepPanel loading={isLoading} title={ruleI18n.ABOUT}>
{aboutRuleData != null && (
<StepAboutRule
descriptionDirection="row"
isReadOnlyView={true}
isLoading={false}
defaultValues={aboutRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem component="section" grow={2}>
<StepPanel loading={isLoading} title={ruleI18n.ABOUT}>
{aboutRuleData != null && (
<StepAboutRule
descriptionDirection="row"
isReadOnlyView={true}
isLoading={false}
defaultValues={aboutRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={isLoading} title={ruleI18n.SCHEDULE}>
{scheduleRuleData != null && (
<StepScheduleRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={scheduleRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<SignalsHistogramPanel
filters={signalMergedFilters}
query={query}
from={from}
stackByOptions={signalsHistogramOptions}
to={to}
updateDateRange={updateDateRangeCallback}
/>
<EuiSpacer />
{ruleId != null && (
<SignalsTable
canUserCRUD={canUserCRUD ?? false}
defaultFilters={signalDefaultFilters}
hasIndexWrite={hasIndexWrite ?? false}
from={from}
loading={loading}
signalsIndex={signalIndexName ?? ''}
to={to}
/>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={isLoading} title={ruleI18n.SCHEDULE}>
{scheduleRuleData != null && (
<StepScheduleRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={scheduleRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<SignalsHistogramPanel
filters={signalMergedFilters}
query={query}
from={from}
stackByOptions={signalsHistogramOptions}
to={to}
updateDateRange={updateDateRangeCallback}
/>
<EuiSpacer />
{ruleId != null && (
<SignalsTable
canUserCRUD={canUserCRUD ?? false}
defaultFilters={signalDefaultFilters}
hasIndexWrite={hasIndexWrite ?? false}
from={from}
loading={loading}
signalsIndex={signalIndexName ?? ''}
to={to}
/>
)}
</>
)}
{ruleDetailTab === 'failure' && <FailureHistory id={rule?.id} />}
</WrapperPage>
</StickyContainer>
)}

View file

@ -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<RuleStatusFailedCallOutComponentProps> = ({
date,
message,
}) => (
<EuiCallOut
title={
<EuiFlexGroup gutterSize="xs" alignItems="center" justifyContent="flexStart">
<EuiFlexItem grow={false}>{i18n.ERROR_CALLOUT_TITLE}</EuiFlexItem>
<EuiFlexItem grow={true}>
<FormattedDate value={date} fieldName="last_failure_at" />
</EuiFlexItem>
</EuiFlexGroup>
}
color="danger"
iconType="alert"
>
<p>{message}</p>
</EuiCallOut>
);
export const RuleStatusFailedCallOut = memo(RuleStatusFailedCallOutComponent);

View file

@ -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',
}
);