[SIEM] [Detection Engine] Update status on rule details page (#55201)

* adds logic for returning / updating status when a rule is switched from enabled to disabled and vice versa.

* update response for find rules statuses to include current status and failures

* update status on demand and on enable/disable

* adds ternary to allow removal of 'let'

* adds savedObjectsClient to the add and upate prepackaged rules and import rules route.

* fix bug where convertToSnakeCase would throw error 'cannot convert null or undefined to object' if passed null

* genericize snake_case converter and updates isAuthorized to snake_case (different situation)

* renaming to 'going to run' instead of executing because when task manager exits because of api key error it won't write the error status so the actual status is 'going to run' on the next interval. This is more accurate than being stuck on 'executing' because of an error we don't control and can't write a status for.

* fix missed merge conflict

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Devin W. Hurley 2020-01-18 12:41:47 -05:00 committed by GitHub
parent 8ae51abe19
commit 9567cca7d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 310 additions and 112 deletions

View file

@ -322,7 +322,7 @@ export const getRuleStatusById = async ({
}: { }: {
id: string; id: string;
signal: AbortSignal; signal: AbortSignal;
}): Promise<Record<string, RuleStatus[]>> => { }): Promise<Record<string, RuleStatus>> => {
const response = await fetch( const response = await fetch(
`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_STATUS}?ids=${encodeURIComponent( `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_STATUS}?ids=${encodeURIComponent(
JSON.stringify([id]) JSON.stringify([id])

View file

@ -10,3 +10,4 @@ export * from './persist_rule';
export * from './types'; export * from './types';
export * from './use_rule'; export * from './use_rule';
export * from './use_rules'; export * from './use_rules';
export * from './use_rule_status';

View file

@ -181,9 +181,15 @@ export interface ExportRulesProps {
} }
export interface RuleStatus { export interface RuleStatus {
current_status: RuleInfoStatus;
failures: RuleInfoStatus[];
}
export type RuleStatusType = 'executing' | 'failed' | 'going to run' | 'succeeded';
export interface RuleInfoStatus {
alert_id: string; alert_id: string;
status_date: string; status_date: string;
status: string; status: RuleStatusType | null;
last_failure_at: string | null; last_failure_at: string | null;
last_success_at: string | null; last_success_at: string | null;
last_failure_message: string | null; last_failure_message: string | null;

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useStateToaster } from '../../../components/toasters'; import { useStateToaster } from '../../../components/toasters';
import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
@ -12,7 +12,8 @@ import { getRuleStatusById } from './api';
import * as i18n from './translations'; import * as i18n from './translations';
import { RuleStatus } from './types'; import { RuleStatus } from './types';
type Return = [boolean, RuleStatus[] | null]; type Func = (ruleId: string) => void;
type Return = [boolean, RuleStatus | null, Func | null];
/** /**
* Hook for using to get a Rule from the Detection Engine API * Hook for using to get a Rule from the Detection Engine API
@ -21,7 +22,8 @@ type Return = [boolean, RuleStatus[] | null];
* *
*/ */
export const useRuleStatus = (id: string | undefined | null): Return => { export const useRuleStatus = (id: string | undefined | null): Return => {
const [ruleStatus, setRuleStatus] = useState<RuleStatus[] | null>(null); const [ruleStatus, setRuleStatus] = useState<RuleStatus | null>(null);
const fetchRuleStatus = useRef<Func | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [, dispatchToaster] = useStateToaster(); const [, dispatchToaster] = useStateToaster();
@ -29,7 +31,7 @@ export const useRuleStatus = (id: string | undefined | null): Return => {
let isSubscribed = true; let isSubscribed = true;
const abortCtrl = new AbortController(); const abortCtrl = new AbortController();
async function fetchData(idToFetch: string) { const fetchData = async (idToFetch: string) => {
try { try {
setLoading(true); setLoading(true);
const ruleStatusResponse = await getRuleStatusById({ const ruleStatusResponse = await getRuleStatusById({
@ -49,15 +51,16 @@ export const useRuleStatus = (id: string | undefined | null): Return => {
if (isSubscribed) { if (isSubscribed) {
setLoading(false); setLoading(false);
} }
} };
if (id != null) { if (id != null) {
fetchData(id); fetchData(id);
} }
fetchRuleStatus.current = fetchData;
return () => { return () => {
isSubscribed = false; isSubscribed = false;
abortCtrl.abort(); abortCtrl.abort();
}; };
}, [id]); }, [id]);
return [loading, ruleStatus]; return [loading, ruleStatus, fetchRuleStatus.current];
}; };

View file

@ -96,5 +96,5 @@ export interface Privilege {
write: boolean; write: boolean;
}; };
}; };
isAuthenticated: boolean; is_authenticated: boolean;
} }

View file

@ -42,7 +42,7 @@ export const usePrivilegeUser = (): Return => {
}); });
if (isSubscribed && privilege != null) { if (isSubscribed && privilege != null) {
setAuthenticated(privilege.isAuthenticated); setAuthenticated(privilege.is_authenticated);
if (privilege.index != null && Object.keys(privilege.index).length > 0) { if (privilege.index != null && Object.keys(privilege.index).length > 0) {
const indexName = Object.keys(privilege.index)[0]; const indexName = Object.keys(privilege.index)[0];
setHasIndexManage(privilege.index[indexName].manage); setHasIndexManage(privilege.index[indexName].manage);

View file

@ -30,6 +30,7 @@ import { FormattedDate } from '../../../../components/formatted_date';
import { RuleSwitch } from '../components/rule_switch'; import { RuleSwitch } from '../components/rule_switch';
import { SeverityBadge } from '../components/severity_badge'; import { SeverityBadge } from '../components/severity_badge';
import { ActionToaster } from '../../../../components/toasters'; import { ActionToaster } from '../../../../components/toasters';
import { getStatusColor } from '../components/rule_status/helpers';
const getActions = ( const getActions = (
dispatch: React.Dispatch<Action>, dispatch: React.Dispatch<Action>,
@ -113,19 +114,11 @@ export const getColumns = (
field: 'status', field: 'status',
name: i18n.COLUMN_LAST_RESPONSE, name: i18n.COLUMN_LAST_RESPONSE,
render: (value: TableData['status']) => { render: (value: TableData['status']) => {
const color =
value == null
? 'subdued'
: value === 'succeeded'
? 'success'
: value === 'failed'
? 'danger'
: value === 'executing'
? 'warning'
: 'subdued';
return ( return (
<> <>
<EuiHealth color={color}>{value ?? getEmptyTagValue()}</EuiHealth> <EuiHealth color={getStatusColor(value ?? null)}>
{value ?? getEmptyTagValue()}
</EuiHealth>
</> </>
); );
}, },

View file

@ -0,0 +1,18 @@
/*
* 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 { RuleStatusType } from '../../../../../containers/detection_engine/rules';
export const getStatusColor = (status: RuleStatusType | string | null) =>
status == null
? 'subdued'
: status === 'succeeded'
? 'success'
: status === 'failed'
? 'danger'
: status === 'executing' || status === 'going to run'
? 'warning'
: 'subdued';

View file

@ -0,0 +1,99 @@
/*
* 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 {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import { isEqual } from 'lodash/fp';
import React, { memo, useCallback, useEffect, useState } from 'react';
import { useRuleStatus, RuleInfoStatus } from '../../../../../containers/detection_engine/rules';
import { FormattedDate } from '../../../../../components/formatted_date';
import { getEmptyTagValue } from '../../../../../components/empty_value';
import { getStatusColor } from './helpers';
import * as i18n from './translations';
interface RuleStatusProps {
ruleId: string | null;
ruleEnabled?: boolean | null;
}
const RuleStatusComponent: React.FC<RuleStatusProps> = ({ ruleId, ruleEnabled }) => {
const [loading, ruleStatus, fetchRuleStatus] = useRuleStatus(ruleId);
const [myRuleEnabled, setMyRuleEnabled] = useState<boolean | null>(ruleEnabled ?? null);
const [currentStatus, setCurrentStatus] = useState<RuleInfoStatus | null>(
ruleStatus?.current_status ?? null
);
useEffect(() => {
if (myRuleEnabled !== ruleEnabled && fetchRuleStatus != null && ruleId != null) {
fetchRuleStatus(ruleId);
if (myRuleEnabled !== ruleEnabled) {
setMyRuleEnabled(ruleEnabled ?? null);
}
}
}, [fetchRuleStatus, myRuleEnabled, ruleId, ruleEnabled, setMyRuleEnabled]);
useEffect(() => {
if (!isEqual(currentStatus, ruleStatus?.current_status)) {
setCurrentStatus(ruleStatus?.current_status ?? null);
}
}, [currentStatus, ruleStatus, setCurrentStatus]);
const handleRefresh = useCallback(() => {
if (fetchRuleStatus != null && ruleId != null) {
fetchRuleStatus(ruleId);
}
}, [fetchRuleStatus, ruleId]);
return (
<EuiFlexGroup gutterSize="xs" alignItems="center" justifyContent="flexStart">
<EuiFlexItem grow={false}>
{i18n.STATUS}
{':'}
</EuiFlexItem>
{loading && (
<EuiFlexItem>
<EuiLoadingSpinner size="m" data-test-subj="rule-status-loader" />
</EuiFlexItem>
)}
{!loading && (
<>
<EuiFlexItem grow={false}>
<EuiHealth color={getStatusColor(currentStatus?.status ?? null)}>
<EuiText size="xs">{currentStatus?.status ?? getEmptyTagValue()}</EuiText>
</EuiHealth>
</EuiFlexItem>
{currentStatus?.status_date != null && currentStatus?.status != null && (
<>
<EuiFlexItem grow={false}>
<>{i18n.STATUS_AT}</>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<FormattedDate value={currentStatus?.status_date} fieldName={i18n.STATUS_DATE} />
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="primary"
onClick={handleRefresh}
iconType="refresh"
aria-label={i18n.REFRESH}
/>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
);
};
export const RuleStatus = memo(RuleStatusComponent);

View file

@ -0,0 +1,29 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const STATUS = i18n.translate('xpack.siem.detectionEngine.ruleStatus.statusDescription', {
defaultMessage: 'Status',
});
export const STATUS_AT = i18n.translate(
'xpack.siem.detectionEngine.ruleStatus.statusAtDescription',
{
defaultMessage: 'at',
}
);
export const STATUS_DATE = i18n.translate(
'xpack.siem.detectionEngine.ruleStatus.statusDateDescription',
{
defaultMessage: 'Status date',
}
);
export const REFRESH = i18n.translate('xpack.siem.detectionEngine.ruleStatus.refreshButton', {
defaultMessage: 'Refresh',
});

View file

@ -36,6 +36,7 @@ export interface RuleSwitchProps {
isDisabled?: boolean; isDisabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
optionLabel?: string; optionLabel?: string;
onChange?: (enabled: boolean) => void;
} }
/** /**
@ -48,6 +49,7 @@ export const RuleSwitchComponent = ({
isLoading, isLoading,
enabled, enabled,
optionLabel, optionLabel,
onChange,
}: RuleSwitchProps) => { }: RuleSwitchProps) => {
const [myIsLoading, setMyIsLoading] = useState(false); const [myIsLoading, setMyIsLoading] = useState(false);
const [myEnabled, setMyEnabled] = useState(enabled ?? false); const [myEnabled, setMyEnabled] = useState(enabled ?? false);
@ -65,6 +67,9 @@ export const RuleSwitchComponent = ({
enabled: event.target.checked!, enabled: event.target.checked!,
}); });
setMyEnabled(updatedRules[0].enabled); setMyEnabled(updatedRules[0].enabled);
if (onChange != null) {
onChange(updatedRules[0].enabled);
}
} catch { } catch {
setMyIsLoading(false); setMyIsLoading(false);
} }

View file

@ -15,8 +15,7 @@ import {
} from '@elastic/eui'; } from '@elastic/eui';
import React, { memo } from 'react'; import React, { memo } from 'react';
import { useRuleStatus } from '../../../../containers/detection_engine/rules/use_rule_status'; import { useRuleStatus, RuleInfoStatus } from '../../../../containers/detection_engine/rules';
import { RuleStatus } from '../../../../containers/detection_engine/rules';
import { HeaderSection } from '../../../../components/header_section'; import { HeaderSection } from '../../../../components/header_section';
import * as i18n from './translations'; import * as i18n from './translations';
import { FormattedDate } from '../../../../components/formatted_date'; import { FormattedDate } from '../../../../components/formatted_date';
@ -35,7 +34,7 @@ const FailureHistoryComponent: React.FC<FailureHistoryProps> = ({ id }) => {
</EuiPanel> </EuiPanel>
); );
} }
const columns: Array<EuiBasicTableColumn<RuleStatus>> = [ const columns: Array<EuiBasicTableColumn<RuleInfoStatus>> = [
{ {
name: i18n.COLUMN_STATUS_TYPE, name: i18n.COLUMN_STATUS_TYPE,
render: () => <EuiHealth color="danger">{i18n.TYPE_FAILED}</EuiHealth>, render: () => <EuiHealth color="danger">{i18n.TYPE_FAILED}</EuiHealth>,
@ -65,7 +64,9 @@ const FailureHistoryComponent: React.FC<FailureHistoryProps> = ({ id }) => {
<EuiBasicTable <EuiBasicTable
columns={columns} columns={columns}
loading={loading} loading={loading}
items={ruleStatus != null ? ruleStatus?.filter(rs => rs.last_failure_at != null) : []} items={
ruleStatus != null ? ruleStatus?.failures.filter(rs => rs.last_failure_at != null) : []
}
sorting={{ sort: { field: 'status_date', direction: 'desc' } }} sorting={{ sort: { field: 'status_date', direction: 'desc' } }}
/> />
</EuiPanel> </EuiPanel>

View file

@ -10,9 +10,7 @@ import {
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiSpacer, EuiSpacer,
EuiHealth,
EuiTab, EuiTab,
EuiText,
EuiTabs, EuiTabs,
} from '@elastic/eui'; } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
@ -62,10 +60,10 @@ import { inputsSelectors } from '../../../../store/inputs';
import { State } from '../../../../store'; import { State } from '../../../../store';
import { InputsRange } from '../../../../store/inputs/model'; import { InputsRange } from '../../../../store/inputs/model';
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions';
import { getEmptyTagValue } from '../../../../components/empty_value'; import { RuleActionsOverflow } from '../components/rule_actions_overflow';
import { RuleStatusFailedCallOut } from './status_failed_callout'; import { RuleStatusFailedCallOut } from './status_failed_callout';
import { FailureHistory } from './failure_history'; import { FailureHistory } from './failure_history';
import { RuleActionsOverflow } from '../components/rule_actions_overflow'; import { RuleStatus } from '../components/rule_status';
interface ReduxProps { interface ReduxProps {
filters: esFilters.Filter[]; filters: esFilters.Filter[];
@ -113,6 +111,8 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
} = useUserInfo(); } = useUserInfo();
const { ruleId } = useParams(); const { ruleId } = useParams();
const [isLoading, rule] = useRule(ruleId); const [isLoading, rule] = useRule(ruleId);
// This is used to re-trigger api rule status when user de/activate rule
const [ruleEnabled, setRuleEnabled] = useState<boolean | null>(null);
const [ruleDetailTab, setRuleDetailTab] = useState(RuleDetailTabs.signals); const [ruleDetailTab, setRuleDetailTab] = useState(RuleDetailTabs.signals);
const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({
rule, rule,
@ -182,17 +182,6 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
filters, filters,
]); ]);
const statusColor =
rule?.status == null
? 'subdued'
: rule?.status === 'succeeded'
? 'success'
: rule?.status === 'failed'
? 'danger'
: rule?.status === 'executing'
? 'warning'
: 'subdued';
const tabs = useMemo( const tabs = useMemo(
() => ( () => (
<EuiTabs> <EuiTabs>
@ -230,6 +219,15 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
[setAbsoluteRangeDatePicker] [setAbsoluteRangeDatePicker]
); );
const handleOnChangeEnabledRule = useCallback(
(enabled: boolean) => {
if (ruleEnabled == null || enabled !== ruleEnabled) {
setRuleEnabled(enabled);
}
},
[ruleEnabled, setRuleEnabled]
);
return ( return (
<> <>
{hasIndexWrite != null && !hasIndexWrite && <NoWriteSignalsCallOut />} {hasIndexWrite != null && !hasIndexWrite && <NoWriteSignalsCallOut />}
@ -262,34 +260,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
</>, </>,
] ]
: []), : []),
<EuiFlexGroup <RuleStatus ruleId={ruleId ?? null} ruleEnabled={ruleEnabled} />,
gutterSize="xs"
alignItems="center"
justifyContent="flexStart"
>
<EuiFlexItem grow={false}>
{i18n.STATUS}
{':'}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color={statusColor}>
<EuiText size="xs">{rule?.status ?? getEmptyTagValue()}</EuiText>
</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} title={title}
> >
@ -300,6 +271,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
isDisabled={userHasNoPermissions} isDisabled={userHasNoPermissions}
enabled={rule?.enabled ?? false} enabled={rule?.enabled ?? false}
optionLabel={i18n.ACTIVATE_RULE} optionLabel={i18n.ACTIVATE_RULE}
onChange={handleOnChangeEnabledRule}
/> />
</EuiFlexItem> </EuiFlexItem>

View file

@ -35,24 +35,6 @@ export const UNKNOWN = i18n.translate('xpack.siem.detectionEngine.ruleDetails.un
defaultMessage: 'Unknown', 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( export const ERROR_CALLOUT_TITLE = i18n.translate(
'xpack.siem.detectionEngine.ruleDetails.errorCalloutTitle', 'xpack.siem.detectionEngine.ruleDetails.errorCalloutTitle',
{ {

View file

@ -391,7 +391,7 @@ export const getMockPrivileges = () => ({
}, },
}, },
application: {}, application: {},
isAuthenticated: false, is_authenticated: false,
}); });
export const getFindResultStatus = (): SavedObjectsFindResponse<IRuleSavedAttributesSavedObjectAttributes> => ({ export const getFindResultStatus = (): SavedObjectsFindResponse<IRuleSavedAttributesSavedObjectAttributes> => ({

View file

@ -30,7 +30,7 @@ export const createReadPrivilegesRulesRoute = (server: ServerFacade): Hapi.Serve
const index = getIndex(request, server); const index = getIndex(request, server);
const permissions = await readPrivileges(callWithRequest, index); const permissions = await readPrivileges(callWithRequest, index);
return merge(permissions, { return merge(permissions, {
isAuthenticated: request?.auth?.isAuthenticated ?? false, is_authenticated: request?.auth?.isAuthenticated ?? false,
}); });
} catch (err) { } catch (err) {
return transformError(err); return transformError(err);

View file

@ -36,8 +36,10 @@ export const createAddPrepackedRulesRoute = (server: ServerFacade): Hapi.ServerR
const actionsClient = isFunction(request.getActionsClient) const actionsClient = isFunction(request.getActionsClient)
? request.getActionsClient() ? request.getActionsClient()
: null; : null;
const savedObjectsClient = isFunction(request.getSavedObjectsClient)
if (!alertsClient || !actionsClient) { ? request.getSavedObjectsClient()
: null;
if (!alertsClient || !actionsClient || !savedObjectsClient) {
return headers.response().code(404); return headers.response().code(404);
} }
@ -59,7 +61,13 @@ export const createAddPrepackedRulesRoute = (server: ServerFacade): Hapi.ServerR
} }
} }
await installPrepackagedRules(alertsClient, actionsClient, rulesToInstall, spaceIndex); await installPrepackagedRules(alertsClient, actionsClient, rulesToInstall, spaceIndex);
await updatePrepackagedRules(alertsClient, actionsClient, rulesToUpdate, spaceIndex); await updatePrepackagedRules(
alertsClient,
actionsClient,
savedObjectsClient,
rulesToUpdate,
spaceIndex
);
return { return {
rules_installed: rulesToInstall.length, rules_installed: rulesToInstall.length,
rules_updated: rulesToUpdate.length, rules_updated: rulesToUpdate.length,

View file

@ -13,10 +13,16 @@ import { findRulesStatusesSchema } from '../schemas/find_rules_statuses_schema';
import { import {
FindRulesStatusesRequest, FindRulesStatusesRequest,
IRuleSavedAttributesSavedObjectAttributes, IRuleSavedAttributesSavedObjectAttributes,
RuleStatusResponse,
IRuleStatusAttributes,
} from '../../rules/types'; } from '../../rules/types';
import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings';
const convertToSnakeCase = (obj: IRuleSavedAttributesSavedObjectAttributes) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const convertToSnakeCase = <T extends Record<string, any>>(obj: T): Partial<T> | null => {
if (!obj) {
return null;
}
return Object.keys(obj).reduce((acc, item) => { return Object.keys(obj).reduce((acc, item) => {
const newKey = snakeCase(item); const newKey = snakeCase(item);
return { ...acc, [newKey]: obj[item] }; return { ...acc, [newKey]: obj[item] };
@ -53,7 +59,7 @@ export const createFindRulesStatusRoute: Hapi.ServerRoute = {
"anotherAlertId": ... "anotherAlertId": ...
} }
*/ */
const statuses = await query.ids.reduce(async (acc, id) => { const statuses = await query.ids.reduce<Promise<RuleStatusResponse | {}>>(async (acc, id) => {
const lastFiveErrorsForId = await savedObjectsClient.find< const lastFiveErrorsForId = await savedObjectsClient.find<
IRuleSavedAttributesSavedObjectAttributes IRuleSavedAttributesSavedObjectAttributes
>({ >({
@ -64,15 +70,21 @@ export const createFindRulesStatusRoute: Hapi.ServerRoute = {
search: id, search: id,
searchFields: ['alertId'], searchFields: ['alertId'],
}); });
const toDisplay = const accumulated = await acc;
lastFiveErrorsForId.saved_objects.length <= 5 const currentStatus = convertToSnakeCase<IRuleStatusAttributes>(
? lastFiveErrorsForId.saved_objects lastFiveErrorsForId.saved_objects[0]?.attributes
: lastFiveErrorsForId.saved_objects.slice(1); );
const failures = lastFiveErrorsForId.saved_objects
.slice(1)
.map(errorItem => convertToSnakeCase<IRuleStatusAttributes>(errorItem.attributes));
return { return {
...(await acc), ...accumulated,
[id]: toDisplay.map(errorItem => convertToSnakeCase(errorItem.attributes)), [id]: {
current_status: currentStatus,
failures,
},
}; };
}, {}); }, Promise.resolve<RuleStatusResponse>({}));
return statuses; return statuses;
}, },
}; };

View file

@ -52,8 +52,10 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute =
const actionsClient = isFunction(request.getActionsClient) const actionsClient = isFunction(request.getActionsClient)
? request.getActionsClient() ? request.getActionsClient()
: null; : null;
const savedObjectsClient = isFunction(request.getSavedObjectsClient)
if (!alertsClient || !actionsClient) { ? request.getSavedObjectsClient()
: null;
if (!alertsClient || !actionsClient || !savedObjectsClient) {
return headers.response().code(404); return headers.response().code(404);
} }
const { filename } = request.payload.file.hapi; const { filename } = request.payload.file.hapi;
@ -161,6 +163,7 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute =
const updatedRule = await updateRules({ const updatedRule = await updateRules({
alertsClient, alertsClient,
actionsClient, actionsClient,
savedObjectsClient,
description, description,
enabled, enabled,
falsePositives, falsePositives,

View file

@ -7,12 +7,16 @@
import Hapi from 'hapi'; import Hapi from 'hapi';
import { isFunction } from 'lodash/fp'; import { isFunction } from 'lodash/fp';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { BulkUpdateRulesRequest } from '../../rules/types'; import {
BulkUpdateRulesRequest,
IRuleSavedAttributesSavedObjectAttributes,
} from '../../rules/types';
import { ServerFacade } from '../../../../types'; import { ServerFacade } from '../../../../types';
import { transformOrBulkError, getIdBulkError } from './utils'; import { transformOrBulkError, getIdBulkError } from './utils';
import { transformBulkError } from '../utils'; import { transformBulkError } from '../utils';
import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema';
import { updateRules } from '../../rules/update_rules'; import { updateRules } from '../../rules/update_rules';
import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings';
export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRoute => { export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRoute => {
return { return {
@ -32,8 +36,10 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
const actionsClient = isFunction(request.getActionsClient) const actionsClient = isFunction(request.getActionsClient)
? request.getActionsClient() ? request.getActionsClient()
: null; : null;
const savedObjectsClient = isFunction(request.getSavedObjectsClient)
if (!alertsClient || !actionsClient) { ? request.getSavedObjectsClient()
: null;
if (!alertsClient || !actionsClient || !savedObjectsClient) {
return headers.response().code(404); return headers.response().code(404);
} }
@ -80,6 +86,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
language, language,
outputIndex, outputIndex,
savedId, savedId,
savedObjectsClient,
timelineId, timelineId,
timelineTitle, timelineTitle,
meta, meta,
@ -100,7 +107,17 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
version, version,
}); });
if (rule != null) { if (rule != null) {
return transformOrBulkError(rule.id, rule); const ruleStatuses = await savedObjectsClient.find<
IRuleSavedAttributesSavedObjectAttributes
>({
type: ruleStatusSavedObjectType,
perPage: 1,
sortField: 'statusDate',
sortOrder: 'desc',
search: rule.id,
searchFields: ['alertId'],
});
return transformOrBulkError(rule.id, rule, ruleStatuses.saved_objects[0]);
} else { } else {
return getIdBulkError({ id, ruleId }); return getIdBulkError({ id, ruleId });
} }

View file

@ -78,6 +78,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = {
language, language,
outputIndex, outputIndex,
savedId, savedId,
savedObjectsClient,
timelineId, timelineId,
timelineTitle, timelineTitle,
meta, meta,

View file

@ -7,7 +7,12 @@
import { get } from 'lodash/fp'; import { get } from 'lodash/fp';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { SavedObject, SavedObjectAttributes, SavedObjectsFindResponse } from 'kibana/server'; import {
SavedObject,
SavedObjectAttributes,
SavedObjectsFindResponse,
SavedObjectsClientContract,
} from 'kibana/server';
import { SIGNALS_ID } from '../../../../common/constants'; import { SIGNALS_ID } from '../../../../common/constants';
import { AlertsClient } from '../../../../../alerting/server/alerts_client'; import { AlertsClient } from '../../../../../alerting/server/alerts_client';
import { ActionsClient } from '../../../../../actions/server/actions_client'; import { ActionsClient } from '../../../../../actions/server/actions_client';
@ -41,14 +46,22 @@ export interface RuleAlertType extends Alert {
params: RuleTypeParams; params: RuleTypeParams;
} }
export interface IRuleStatusAttributes { // eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IRuleStatusAttributes extends Record<string, any> {
alertId: string; // created alert id. alertId: string; // created alert id.
statusDate: string; statusDate: string;
lastFailureAt: string | null | undefined; lastFailureAt: string | null | undefined;
lastFailureMessage: string | null | undefined; lastFailureMessage: string | null | undefined;
lastSuccessAt: string | null | undefined; lastSuccessAt: string | null | undefined;
lastSuccessMessage: string | null | undefined; lastSuccessMessage: string | null | undefined;
status: RuleStatusString; status: RuleStatusString | null | undefined;
}
export interface RuleStatusResponse {
[key: string]: {
current_status: IRuleStatusAttributes | null | undefined;
failures: IRuleStatusAttributes[] | null | undefined;
};
} }
export interface IRuleSavedAttributesSavedObjectAttributes export interface IRuleSavedAttributesSavedObjectAttributes
@ -142,6 +155,7 @@ export interface Clients {
export type UpdateRuleParams = Partial<RuleAlertParams> & { export type UpdateRuleParams = Partial<RuleAlertParams> & {
id: string | undefined | null; id: string | undefined | null;
savedObjectsClient: SavedObjectsClientContract;
} & Clients; } & Clients;
export type DeleteRuleParams = Clients & { export type DeleteRuleParams = Clients & {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { SavedObjectsClientContract } from 'kibana/server';
import { ActionsClient } from '../../../../../actions'; import { ActionsClient } from '../../../../../actions';
import { AlertsClient } from '../../../../../alerting'; import { AlertsClient } from '../../../../../alerting';
import { updateRules } from './update_rules'; import { updateRules } from './update_rules';
@ -12,6 +13,7 @@ import { PrepackagedRules } from '../types';
export const updatePrepackagedRules = async ( export const updatePrepackagedRules = async (
alertsClient: AlertsClient, alertsClient: AlertsClient,
actionsClient: ActionsClient, actionsClient: ActionsClient,
savedObjectsClient: SavedObjectsClientContract,
rules: PrepackagedRules[], rules: PrepackagedRules[],
outputIndex: string outputIndex: string
): Promise<void> => { ): Promise<void> => {
@ -55,6 +57,7 @@ export const updatePrepackagedRules = async (
outputIndex, outputIndex,
id: undefined, // We never have an id when updating from pre-packaged rules id: undefined, // We never have an id when updating from pre-packaged rules
savedId, savedId,
savedObjectsClient,
meta, meta,
filters, filters,
ruleId, ruleId,

View file

@ -7,8 +7,9 @@
import { defaults } from 'lodash/fp'; import { defaults } from 'lodash/fp';
import { AlertAction, IntervalSchedule } from '../../../../../alerting/server/types'; import { AlertAction, IntervalSchedule } from '../../../../../alerting/server/types';
import { readRules } from './read_rules'; import { readRules } from './read_rules';
import { UpdateRuleParams } from './types'; import { UpdateRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types';
import { addTags } from './add_tags'; import { addTags } from './add_tags';
import { ruleStatusSavedObjectType } from './saved_object_mappings';
export const calculateInterval = ( export const calculateInterval = (
interval: string | undefined, interval: string | undefined,
@ -66,6 +67,7 @@ export const calculateName = ({
export const updateRules = async ({ export const updateRules = async ({
alertsClient, alertsClient,
actionsClient, // TODO: Use this whenever we add feature support for different action types actionsClient, // TODO: Use this whenever we add feature support for different action types
savedObjectsClient,
description, description,
falsePositives, falsePositives,
enabled, enabled,
@ -135,10 +137,39 @@ export const updateRules = async ({
} }
); );
const ruleCurrentStatus = savedObjectsClient
? await savedObjectsClient.find<IRuleSavedAttributesSavedObjectAttributes>({
type: ruleStatusSavedObjectType,
perPage: 1,
sortField: 'statusDate',
sortOrder: 'desc',
search: rule.id,
searchFields: ['alertId'],
})
: null;
if (rule.enabled && enabled === false) { if (rule.enabled && enabled === false) {
await alertsClient.disable({ id: rule.id }); await alertsClient.disable({ id: rule.id });
// set current status for this rule to null to represent disabled,
// but keep last_success_at / last_failure_at properties intact for
// use on frontend while rule is disabled.
if (ruleCurrentStatus && ruleCurrentStatus.saved_objects.length > 0) {
const currentStatusToDisable = ruleCurrentStatus.saved_objects[0];
currentStatusToDisable.attributes.status = null;
await savedObjectsClient?.update(ruleStatusSavedObjectType, currentStatusToDisable.id, {
...currentStatusToDisable.attributes,
});
}
} else if (!rule.enabled && enabled === true) { } else if (!rule.enabled && enabled === true) {
await alertsClient.enable({ id: rule.id }); await alertsClient.enable({ id: rule.id });
// set current status for this rule to be 'going to run'
if (ruleCurrentStatus && ruleCurrentStatus.saved_objects.length > 0) {
const currentStatusToDisable = ruleCurrentStatus.saved_objects[0];
currentStatusToDisable.attributes.status = 'going to run';
await savedObjectsClient?.update(ruleStatusSavedObjectType, currentStatusToDisable.id, {
...currentStatusToDisable.attributes,
});
}
} else { } else {
// enabled is null or undefined and we do not touch the rule // enabled is null or undefined and we do not touch the rule
} }

View file

@ -96,7 +96,7 @@ export const signalRulesAlertType = ({
>(ruleStatusSavedObjectType, { >(ruleStatusSavedObjectType, {
alertId, // do a search for this id. alertId, // do a search for this id.
statusDate: date, statusDate: date,
status: 'executing', status: 'going to run',
lastFailureAt: null, lastFailureAt: null,
lastSuccessAt: null, lastSuccessAt: null,
lastFailureMessage: null, lastFailureMessage: null,
@ -106,7 +106,7 @@ export const signalRulesAlertType = ({
// update 0th to executing. // update 0th to executing.
currentStatusSavedObject = ruleStatusSavedObjects.saved_objects[0]; currentStatusSavedObject = ruleStatusSavedObjects.saved_objects[0];
const sDate = new Date().toISOString(); const sDate = new Date().toISOString();
currentStatusSavedObject.attributes.status = 'executing'; currentStatusSavedObject.attributes.status = 'going to run';
currentStatusSavedObject.attributes.statusDate = sDate; currentStatusSavedObject.attributes.statusDate = sDate;
await services.savedObjectsClient.update( await services.savedObjectsClient.update(
ruleStatusSavedObjectType, ruleStatusSavedObjectType,