[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;
signal: AbortSignal;
}): Promise<Record<string, RuleStatus[]>> => {
}): Promise<Record<string, RuleStatus>> => {
const response = await fetch(
`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_STATUS}?ids=${encodeURIComponent(
JSON.stringify([id])

View file

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

View file

@ -181,9 +181,15 @@ export interface ExportRulesProps {
}
export interface RuleStatus {
current_status: RuleInfoStatus;
failures: RuleInfoStatus[];
}
export type RuleStatusType = 'executing' | 'failed' | 'going to run' | 'succeeded';
export interface RuleInfoStatus {
alert_id: string;
status_date: string;
status: string;
status: RuleStatusType | null;
last_failure_at: string | null;
last_success_at: 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.
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useStateToaster } from '../../../components/toasters';
import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
@ -12,7 +12,8 @@ import { getRuleStatusById } from './api';
import * as i18n from './translations';
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
@ -21,7 +22,8 @@ type Return = [boolean, RuleStatus[] | null];
*
*/
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 [, dispatchToaster] = useStateToaster();
@ -29,7 +31,7 @@ export const useRuleStatus = (id: string | undefined | null): Return => {
let isSubscribed = true;
const abortCtrl = new AbortController();
async function fetchData(idToFetch: string) {
const fetchData = async (idToFetch: string) => {
try {
setLoading(true);
const ruleStatusResponse = await getRuleStatusById({
@ -49,15 +51,16 @@ export const useRuleStatus = (id: string | undefined | null): Return => {
if (isSubscribed) {
setLoading(false);
}
}
};
if (id != null) {
fetchData(id);
}
fetchRuleStatus.current = fetchData;
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [id]);
return [loading, ruleStatus];
return [loading, ruleStatus, fetchRuleStatus.current];
};

View file

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

View file

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

View file

@ -30,6 +30,7 @@ import { FormattedDate } from '../../../../components/formatted_date';
import { RuleSwitch } from '../components/rule_switch';
import { SeverityBadge } from '../components/severity_badge';
import { ActionToaster } from '../../../../components/toasters';
import { getStatusColor } from '../components/rule_status/helpers';
const getActions = (
dispatch: React.Dispatch<Action>,
@ -113,19 +114,11 @@ export const getColumns = (
field: 'status',
name: i18n.COLUMN_LAST_RESPONSE,
render: (value: TableData['status']) => {
const color =
value == null
? 'subdued'
: value === 'succeeded'
? 'success'
: value === 'failed'
? 'danger'
: value === 'executing'
? 'warning'
: 'subdued';
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;
isLoading?: boolean;
optionLabel?: string;
onChange?: (enabled: boolean) => void;
}
/**
@ -48,6 +49,7 @@ export const RuleSwitchComponent = ({
isLoading,
enabled,
optionLabel,
onChange,
}: RuleSwitchProps) => {
const [myIsLoading, setMyIsLoading] = useState(false);
const [myEnabled, setMyEnabled] = useState(enabled ?? false);
@ -65,6 +67,9 @@ export const RuleSwitchComponent = ({
enabled: event.target.checked!,
});
setMyEnabled(updatedRules[0].enabled);
if (onChange != null) {
onChange(updatedRules[0].enabled);
}
} catch {
setMyIsLoading(false);
}

View file

@ -15,8 +15,7 @@ import {
} 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 { useRuleStatus, RuleInfoStatus } from '../../../../containers/detection_engine/rules';
import { HeaderSection } from '../../../../components/header_section';
import * as i18n from './translations';
import { FormattedDate } from '../../../../components/formatted_date';
@ -35,7 +34,7 @@ const FailureHistoryComponent: React.FC<FailureHistoryProps> = ({ id }) => {
</EuiPanel>
);
}
const columns: Array<EuiBasicTableColumn<RuleStatus>> = [
const columns: Array<EuiBasicTableColumn<RuleInfoStatus>> = [
{
name: i18n.COLUMN_STATUS_TYPE,
render: () => <EuiHealth color="danger">{i18n.TYPE_FAILED}</EuiHealth>,
@ -65,7 +64,9 @@ const FailureHistoryComponent: React.FC<FailureHistoryProps> = ({ id }) => {
<EuiBasicTable
columns={columns}
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' } }}
/>
</EuiPanel>

View file

@ -10,9 +10,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiHealth,
EuiTab,
EuiText,
EuiTabs,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
@ -62,10 +60,10 @@ 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 { RuleActionsOverflow } from '../components/rule_actions_overflow';
import { RuleStatusFailedCallOut } from './status_failed_callout';
import { FailureHistory } from './failure_history';
import { RuleActionsOverflow } from '../components/rule_actions_overflow';
import { RuleStatus } from '../components/rule_status';
interface ReduxProps {
filters: esFilters.Filter[];
@ -113,6 +111,8 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
} = useUserInfo();
const { ruleId } = useParams();
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 { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({
rule,
@ -182,17 +182,6 @@ 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(
() => (
<EuiTabs>
@ -230,6 +219,15 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
[setAbsoluteRangeDatePicker]
);
const handleOnChangeEnabledRule = useCallback(
(enabled: boolean) => {
if (ruleEnabled == null || enabled !== ruleEnabled) {
setRuleEnabled(enabled);
}
},
[ruleEnabled, setRuleEnabled]
);
return (
<>
{hasIndexWrite != null && !hasIndexWrite && <NoWriteSignalsCallOut />}
@ -262,34 +260,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
</>,
]
: []),
<EuiFlexGroup
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>,
<RuleStatus ruleId={ruleId ?? null} ruleEnabled={ruleEnabled} />,
]}
title={title}
>
@ -300,6 +271,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
isDisabled={userHasNoPermissions}
enabled={rule?.enabled ?? false}
optionLabel={i18n.ACTIVATE_RULE}
onChange={handleOnChangeEnabledRule}
/>
</EuiFlexItem>

View file

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

View file

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

View file

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

View file

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

View file

@ -13,10 +13,16 @@ import { findRulesStatusesSchema } from '../schemas/find_rules_statuses_schema';
import {
FindRulesStatusesRequest,
IRuleSavedAttributesSavedObjectAttributes,
RuleStatusResponse,
IRuleStatusAttributes,
} from '../../rules/types';
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) => {
const newKey = snakeCase(item);
return { ...acc, [newKey]: obj[item] };
@ -53,7 +59,7 @@ export const createFindRulesStatusRoute: Hapi.ServerRoute = {
"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<
IRuleSavedAttributesSavedObjectAttributes
>({
@ -64,15 +70,21 @@ export const createFindRulesStatusRoute: Hapi.ServerRoute = {
search: id,
searchFields: ['alertId'],
});
const toDisplay =
lastFiveErrorsForId.saved_objects.length <= 5
? lastFiveErrorsForId.saved_objects
: lastFiveErrorsForId.saved_objects.slice(1);
const accumulated = await acc;
const currentStatus = convertToSnakeCase<IRuleStatusAttributes>(
lastFiveErrorsForId.saved_objects[0]?.attributes
);
const failures = lastFiveErrorsForId.saved_objects
.slice(1)
.map(errorItem => convertToSnakeCase<IRuleStatusAttributes>(errorItem.attributes));
return {
...(await acc),
[id]: toDisplay.map(errorItem => convertToSnakeCase(errorItem.attributes)),
...accumulated,
[id]: {
current_status: currentStatus,
failures,
},
};
}, {});
}, Promise.resolve<RuleStatusResponse>({}));
return statuses;
},
};

View file

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

View file

@ -7,12 +7,16 @@
import Hapi from 'hapi';
import { isFunction } from 'lodash/fp';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { BulkUpdateRulesRequest } from '../../rules/types';
import {
BulkUpdateRulesRequest,
IRuleSavedAttributesSavedObjectAttributes,
} from '../../rules/types';
import { ServerFacade } from '../../../../types';
import { transformOrBulkError, getIdBulkError } from './utils';
import { transformBulkError } from '../utils';
import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema';
import { updateRules } from '../../rules/update_rules';
import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings';
export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRoute => {
return {
@ -32,8 +36,10 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
const actionsClient = isFunction(request.getActionsClient)
? request.getActionsClient()
: null;
if (!alertsClient || !actionsClient) {
const savedObjectsClient = isFunction(request.getSavedObjectsClient)
? request.getSavedObjectsClient()
: null;
if (!alertsClient || !actionsClient || !savedObjectsClient) {
return headers.response().code(404);
}
@ -80,6 +86,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
language,
outputIndex,
savedId,
savedObjectsClient,
timelineId,
timelineTitle,
meta,
@ -100,7 +107,17 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
version,
});
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 {
return getIdBulkError({ id, ruleId });
}

View file

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

View file

@ -7,7 +7,12 @@
import { get } from 'lodash/fp';
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 { AlertsClient } from '../../../../../alerting/server/alerts_client';
import { ActionsClient } from '../../../../../actions/server/actions_client';
@ -41,14 +46,22 @@ export interface RuleAlertType extends Alert {
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.
statusDate: string;
lastFailureAt: string | null | undefined;
lastFailureMessage: string | null | undefined;
lastSuccessAt: 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
@ -142,6 +155,7 @@ export interface Clients {
export type UpdateRuleParams = Partial<RuleAlertParams> & {
id: string | undefined | null;
savedObjectsClient: SavedObjectsClientContract;
} & Clients;
export type DeleteRuleParams = Clients & {

View file

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

View file

@ -7,8 +7,9 @@
import { defaults } from 'lodash/fp';
import { AlertAction, IntervalSchedule } from '../../../../../alerting/server/types';
import { readRules } from './read_rules';
import { UpdateRuleParams } from './types';
import { UpdateRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types';
import { addTags } from './add_tags';
import { ruleStatusSavedObjectType } from './saved_object_mappings';
export const calculateInterval = (
interval: string | undefined,
@ -66,6 +67,7 @@ export const calculateName = ({
export const updateRules = async ({
alertsClient,
actionsClient, // TODO: Use this whenever we add feature support for different action types
savedObjectsClient,
description,
falsePositives,
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) {
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) {
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 {
// enabled is null or undefined and we do not touch the rule
}

View file

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