Modify open_close_signals route to avoid using update_by_query when possible (#159703)

issue: https://github.com/elastic/security-team/issues/6829

## Summary

* Update `open_close_signals_route` to call `_update_by_query` API only
when a query is provided. When the alert ids are provided it will call
`_bulk` update instead.
* It also unifies the response so both `updateAlerts` calls return the
same interface.

### Context
We still can call updateByQuery as many times as we need if the feature works with eventual consistency (search after writing returns stale data). If we need to show the updated data after writing, we need refresh: true, which is expensive (updateByQuery doesn't support refresh: wait_for). So, it makes sense to avoid calling refresh: true when we have the ids by using _bulk update with refresh: wait_for.


### How to test it?
#### Old Alerts Table/Grouped Alerts Table
* Select one or more rows
* Open the bulk action menu and update the alert status
<img width="531" alt="Screenshot 2023-06-27 at 16 51 42"
src="b0628921-b034-4dee-9f75-9b760e33f512">

* It should update Alerts by id instead of sending a query and calling
`update_by_query` API
* Bulk updating all alert statuses doesn't change

#### Close Alerts From Exceptions
* Click on the row of actions for an alert
* Select the 'Add rule exception' option

<img width="366" alt="Screenshot 2023-06-27 at 16 55 54"
src="2faac947-a004-4a80-b026-ffeeabbf1695">


* Fill all required fields on the flyout + `Close this alert`
* Click on "Add rule exception"
* It should update the Alerts by id instead of sending a query and
calling the `update_by_query` API
* Bulk updating all alert statuses doesn't change

### Checklist


- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Pablo Machado 2023-07-04 12:20:49 +02:00 committed by GitHub
parent 023b23f2a6
commit 9685de25e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 466 additions and 238 deletions

View file

@ -86,7 +86,6 @@ interface Props {
data: TimelineEventsDetailsItem[];
detailsEcsData: Ecs | null;
id: string;
indexName: string;
isAlert: boolean;
isDraggable?: boolean;
rawEventData: object | undefined;
@ -154,7 +153,6 @@ const EventDetailsComponent: React.FC<Props> = ({
data,
detailsEcsData,
id,
indexName,
isAlert,
isDraggable,
rawEventData,
@ -235,7 +233,6 @@ const EventDetailsComponent: React.FC<Props> = ({
contextId={scopeId}
data={data}
eventId={id}
indexName={indexName}
scopeId={scopeId}
handleOnEventClosed={handleOnEventClosed}
isReadOnly={isReadOnly}
@ -328,7 +325,6 @@ const EventDetailsComponent: React.FC<Props> = ({
scopeId,
data,
id,
indexName,
handleOnEventClosed,
isReadOnly,
renderer,

View file

@ -42,22 +42,12 @@ interface Props {
data: TimelineEventsDetailsItem[];
eventId: string;
handleOnEventClosed: () => void;
indexName: string;
scopeId: string;
isReadOnly?: boolean;
}
export const Overview = React.memo<Props>(
({
browserFields,
contextId,
data,
eventId,
handleOnEventClosed,
indexName,
scopeId,
isReadOnly,
}) => {
({ browserFields, contextId, data, eventId, handleOnEventClosed, scopeId, isReadOnly }) => {
const statusData = useMemo(() => {
const item = find({ field: SIGNAL_STATUS_FIELD_NAME, category: 'kibana' }, data);
return (
@ -128,7 +118,6 @@ export const Overview = React.memo<Props>(
eventId={eventId}
contextId={contextId}
enrichedFieldInfo={statusData}
indexName={indexName}
scopeId={scopeId}
handleOnEventClosed={handleOnEventClosed}
/>

View file

@ -45,7 +45,6 @@ const props = {
fields: {},
},
},
indexName: '.internal.alerts-security.alerts-default-000001',
scopeId: 'alerts-page',
handleOnEventClosed: jest.fn(),
};

View file

@ -24,13 +24,12 @@ interface StatusPopoverButtonProps {
eventId: string;
contextId: string;
enrichedFieldInfo: EnrichedFieldInfoWithValues;
indexName: string;
scopeId: string;
handleOnEventClosed: () => void;
}
export const StatusPopoverButton = React.memo<StatusPopoverButtonProps>(
({ eventId, contextId, enrichedFieldInfo, indexName, scopeId, handleOnEventClosed }) => {
({ eventId, contextId, enrichedFieldInfo, scopeId, handleOnEventClosed }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
@ -51,7 +50,6 @@ export const StatusPopoverButton = React.memo<StatusPopoverButtonProps>(
closePopover: closeAfterAction,
eventId,
scopeId,
indexName,
alertStatus: enrichedFieldInfo.values[0] as Status,
refetch: refetchGlobalQuery,
});

View file

@ -496,7 +496,6 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
tableId,
data: nonDeletedEvents,
totalItems: totalCountMinusDeleted,
indexNames: selectedPatterns,
hasAlertsCrud: hasCrudPermissions,
showCheckboxes,
filterStatus: currentFilter,

View file

@ -18,7 +18,6 @@ interface OwnProps {
tableId: TableId;
data: TimelineItem[];
totalItems: number;
indexNames: string[];
hasAlertsCrud: boolean;
showCheckboxes: boolean;
filterStatus?: AlertWorkflowStatus;
@ -31,7 +30,6 @@ export const useAlertBulkActions = ({
tableId,
data,
totalItems,
indexNames,
hasAlertsCrud,
showCheckboxes,
filterStatus,
@ -102,7 +100,6 @@ export const useAlertBulkActions = ({
totalItems={totalItems}
filterStatus={filterStatus}
query={filterQuery}
indexName={indexNames.join()}
onActionSuccess={onAlertStatusActionSuccess}
onActionFailure={onAlertStatusActionFailure}
customBulkActions={additionalBulkActions}
@ -115,7 +112,6 @@ export const useAlertBulkActions = ({
additionalBulkActions,
filterQuery,
filterStatus,
indexNames,
onAlertStatusActionFailure,
onAlertStatusActionSuccess,
showAlertStatusActions,

View file

@ -37,7 +37,6 @@ interface OwnProps {
totalItems: number;
filterStatus?: AlertWorkflowStatus;
query?: string;
indexName: string;
showAlertStatusActions?: boolean;
onActionSuccess?: OnUpdateAlertStatusSuccess;
onActionFailure?: OnUpdateAlertStatusError;
@ -59,7 +58,6 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
selectedEventIds,
isSelectAllChecked,
clearSelected,
indexName,
showAlertStatusActions,
onActionSuccess,
onActionFailure,
@ -177,7 +175,6 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
);
const bulkActionItems = useBulkActionItems({
indexName,
eventIds: Object.keys(selectedEventIds),
currentStatus: filterStatus,
...(showClearSelection ? { query } : {}),

View file

@ -14,12 +14,12 @@ import { BulkAlertTagsPanel } from './alert_bulk_tags';
import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useSetAlertTags } from './use_set_alert_tags';
import { getUpdateAlertsQuery } from '../../../../detections/components/alerts_table/actions';
import { getUpdateAlertsQuery } from './helpers';
jest.mock('../../../lib/kibana');
jest.mock('../../../hooks/use_app_toasts');
jest.mock('./use_set_alert_tags');
jest.mock('../../../../detections/components/alerts_table/actions');
jest.mock('./helpers');
const mockTagItems = [
{

View file

@ -45,3 +45,21 @@ export const createInitialTagsState = (existingTags: string[][], defaultTags: st
})
.sort(checkedSortCallback);
};
/**
* @deprecated
* Please avoid using update_by_query API with `refresh:true` on serverless, use `_bulk` update by id instead.
*/
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
return {
query: {
bool: {
filter: {
terms: {
_id: eventIds,
},
},
},
},
};
};

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { updateAlertStatus } from './update_alerts';
const mockUpdateAlertStatusByIds = jest.fn().mockReturnValue(new Promise(() => {}));
const mockUpdateAlertStatusByQuery = jest.fn().mockReturnValue(new Promise(() => {}));
jest.mock('../../../../detections/containers/detection_engine/alerts/api', () => {
return {
updateAlertStatusByQuery: (params: unknown) => mockUpdateAlertStatusByQuery(params),
updateAlertStatusByIds: (params: unknown) => mockUpdateAlertStatusByIds(params),
};
});
const status = 'open';
describe('updateAlertStatus', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should throw an error if neither query nor signalIds are provided', () => {
expect(() => {
updateAlertStatus({ status });
}).toThrowError('Either query or signalIds must be provided');
});
it('should call updateAlertStatusByIds if signalIds are provided', () => {
const signalIds = ['1', '2'];
updateAlertStatus({
status,
signalIds,
});
expect(mockUpdateAlertStatusByIds).toHaveBeenCalledWith({
status,
signalIds,
});
expect(mockUpdateAlertStatusByQuery).not.toHaveBeenCalled();
});
it('should call mockUpdateAlertStatusByQuery if query is provided', () => {
const query = { query: 'query' };
updateAlertStatus({
status,
query,
});
expect(mockUpdateAlertStatusByIds).not.toHaveBeenCalled();
expect(mockUpdateAlertStatusByQuery).toHaveBeenCalledWith({
status,
query,
});
});
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UpdateByQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { Status } from '../../../../../common/detection_engine/schemas/common';
import {
updateAlertStatusByIds,
updateAlertStatusByQuery,
} from '../../../../detections/containers/detection_engine/alerts/api';
interface UpdatedAlertsResponse {
updated: number;
version_conflicts: UpdateByQueryResponse['version_conflicts'];
}
interface UpdatedAlertsProps {
status: Status;
query?: object;
signalIds?: string[];
signal?: AbortSignal;
}
/**
* Update alert status by query or signalIds.
* Either query or signalIds must be provided
* `signalIds` is the preferred way to update alerts because it is more cost effective on Serverless.
*
* @param status to update to('open' / 'closed' / 'acknowledged')
* @param index index to be updated
* @param query optional query object to update alerts by query.
* @param signalIds optional signalIds to update alerts by signalIds.
* @param signal to cancel request
*
* @throws An error if response is not OK
*/
export const updateAlertStatus = ({
status,
query,
signalIds,
signal,
}: UpdatedAlertsProps): Promise<UpdatedAlertsResponse> => {
if (signalIds && signalIds.length > 0) {
return updateAlertStatusByIds({ status, signalIds, signal }).then(({ items }) => ({
updated: items.length,
version_conflicts: 0,
}));
} else if (query) {
return updateAlertStatusByQuery({ status, query, signal }).then(
({ updated, version_conflicts: conflicts }) => ({
updated: updated ?? 0,
version_conflicts: conflicts,
})
);
}
throw new Error('Either query or signalIds must be provided');
};

View file

@ -14,22 +14,17 @@ import type {
SetEventsLoading,
} from '../../../../../common/types';
import * as i18n from './translations';
import { useUpdateAlertsStatus } from './use_update_alerts';
import { updateAlertStatus } from './update_alerts';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useStartTransaction } from '../../../lib/apm/use_start_transaction';
import { APM_USER_INTERACTIONS } from '../../../lib/apm/constants';
import type { AlertWorkflowStatus } from '../../../types';
import type { OnUpdateAlertStatusError, OnUpdateAlertStatusSuccess } from './types';
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
return { bool: { filter: { terms: { _id: eventIds } } } };
};
export interface BulkActionsProps {
eventIds: string[];
currentStatus?: AlertWorkflowStatus;
query?: string;
indexName: string;
setEventsLoading: SetEventsLoading;
setEventsDeleted: SetEventsDeleted;
showAlertStatusActions?: boolean;
@ -42,7 +37,6 @@ export const useBulkActionItems = ({
eventIds,
currentStatus,
query,
indexName,
setEventsLoading,
showAlertStatusActions = true,
setEventsDeleted,
@ -50,7 +44,6 @@ export const useBulkActionItems = ({
onUpdateFailure,
customBulkActions,
}: BulkActionsProps) => {
const { updateAlertStatus } = useUpdateAlertsStatus();
const { addSuccess, addError, addWarning } = useAppToasts();
const { startTransaction } = useStartTransaction();
@ -116,11 +109,10 @@ export const useBulkActionItems = ({
try {
setEventsLoading({ eventIds, isLoading: true });
const response = await updateAlertStatus({
index: indexName,
status,
query: query ? JSON.parse(query) : getUpdateAlertsQuery(eventIds),
query: query && JSON.parse(query),
signalIds: eventIds,
});
// TODO: Only delete those that were successfully updated from updatedRules
@ -140,8 +132,6 @@ export const useBulkActionItems = ({
[
setEventsLoading,
eventIds,
updateAlertStatus,
indexName,
query,
setEventsDeleted,
onAlertStatusUpdateSuccess,

View file

@ -10,11 +10,11 @@ import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getUpdateAlertsQuery } from '../../../../detections/components/alerts_table/actions';
import type { AlertTags } from '../../../../../common/detection_engine/schemas/common';
import { DETECTION_ENGINE_ALERT_TAGS_URL } from '../../../../../common/constants';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import * as i18n from './translations';
import { getUpdateAlertsQuery } from './helpers';
export type SetAlertTagsFunc = (
tags: AlertTags,

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { DETECTION_ENGINE_SIGNALS_STATUS_URL } from '../../../../../common/constants';
import type { AlertWorkflowStatus } from '../../../types';
/**
* Update alert status by query
*
* @param status to update to('open' / 'closed' / 'acknowledged')
* @param index index to be updated
* @param query optional query object to update alerts by query.
*
* @throws An error if response is not OK
*/
export const useUpdateAlertsStatus = (): {
updateAlertStatus: (params: {
status: AlertWorkflowStatus;
index: string;
query: object;
}) => Promise<estypes.UpdateByQueryResponse>;
} => {
const { http } = useKibana<CoreStart>().services;
return {
updateAlertStatus: async ({ status, index, query }) => {
return http.fetch<estypes.UpdateByQueryResponse>(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, query }),
});
},
};
};

View file

@ -8,9 +8,6 @@
import { useEffect, useRef, useState } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api';
import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions';
import {
buildAlertStatusesFilter,
buildAlertsFilter,
@ -21,6 +18,7 @@ import { prepareExceptionItemsForBulkClose } from '../utils/helpers';
import * as i18nCommon from '../../../common/translations';
import * as i18n from './translations';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { updateAlertStatus } from '../../../common/components/toolbar/bulk_actions/update_alerts';
/**
* Closes alerts.
@ -65,7 +63,7 @@ export const useCloseAlertsFromExceptions = (): ReturnUseCloseAlertsFromExceptio
let bulkResponse: estypes.UpdateByQueryResponse | undefined;
if (alertIdToClose != null) {
alertIdResponse = await updateAlertStatus({
query: getUpdateAlertsQuery([alertIdToClose]),
signalIds: [alertIdToClose],
status: 'closed',
signal: abortCtrl.signal,
});
@ -88,9 +86,7 @@ export const useCloseAlertsFromExceptions = (): ReturnUseCloseAlertsFromExceptio
);
bulkResponse = await updateAlertStatus({
query: {
query: filter,
},
query: filter,
status: 'closed',
signal: abortCtrl.signal,
});

View file

@ -44,7 +44,6 @@ import {
import type { TimelineResult } from '../../../../common/types/timeline/api';
import { TimelineId } from '../../../../common/types/timeline';
import { TimelineStatus, TimelineType } from '../../../../common/types/timeline/api';
import { updateAlertStatus } from '../../containers/detection_engine/alerts/api';
import type {
SendAlertToTimelineActionProps,
ThresholdAggregationData,
@ -83,20 +82,7 @@ import {
DEFAULT_FROM_MOMENT,
DEFAULT_TO_MOMENT,
} from '../../../common/utils/default_date_settings';
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
return {
query: {
bool: {
filter: {
terms: {
_id: eventIds,
},
},
},
},
};
};
import { updateAlertStatus } from '../../../common/components/toolbar/bulk_actions/update_alerts';
export const updateAlertStatusAction = async ({
query,
@ -110,8 +96,12 @@ export const updateAlertStatusAction = async ({
try {
setEventsLoading({ eventIds: alertIds, isLoading: true });
const queryObject = query ? { query: JSON.parse(query) } : getUpdateAlertsQuery(alertIds);
const response = await updateAlertStatus({ query: queryObject, status: selectedStatus });
const response = await updateAlertStatus({
query: query && JSON.parse(query),
status: selectedStatus,
signalIds: alertIds,
});
// TODO: Only delete those that were successfully updated from updatedRules
setEventsDeleted({ eventIds: alertIds, isDeleted: true });

View file

@ -235,7 +235,6 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
);
const takeActionItems = useGroupTakeActionsItems({
indexName: indexPattern.title,
currentStatus: currentAlertStatusFilterValue,
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
});

View file

@ -35,7 +35,6 @@ describe('useGroupTakeActionsItems', () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
@ -53,7 +52,6 @@ describe('useGroupTakeActionsItems', () => {
() =>
useGroupTakeActionsItems({
currentStatus: [],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
@ -71,7 +69,6 @@ describe('useGroupTakeActionsItems', () => {
() =>
useGroupTakeActionsItems({
currentStatus: ['open', 'closed'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
@ -89,7 +86,6 @@ describe('useGroupTakeActionsItems', () => {
() =>
useGroupTakeActionsItems({
currentStatus: ['open'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
@ -110,7 +106,6 @@ describe('useGroupTakeActionsItems', () => {
() =>
useGroupTakeActionsItems({
currentStatus: ['closed'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
@ -131,7 +126,6 @@ describe('useGroupTakeActionsItems', () => {
() =>
useGroupTakeActionsItems({
currentStatus: ['acknowledged'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
@ -151,7 +145,6 @@ describe('useGroupTakeActionsItems', () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: false,
}),
{
@ -167,7 +160,6 @@ describe('useGroupTakeActionsItems', () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{

View file

@ -15,7 +15,7 @@ import { useStartTransaction } from '../../../../common/lib/apm/use_start_transa
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { AlertWorkflowStatus } from '../../../../common/types';
import { APM_USER_INTERACTIONS } from '../../../../common/lib/apm/constants';
import { useUpdateAlertsStatus } from '../../../../common/components/toolbar/bulk_actions/use_update_alerts';
import { updateAlertStatus } from '../../../../common/components/toolbar/bulk_actions/update_alerts';
import {
BULK_ACTION_ACKNOWLEDGED_SELECTED,
BULK_ACTION_CLOSE_SELECTED,
@ -33,16 +33,13 @@ import type { StartServices } from '../../../../types';
export interface TakeActionsProps {
currentStatus?: Status[];
indexName: string;
showAlertStatusActions?: boolean;
}
export const useGroupTakeActionsItems = ({
currentStatus,
indexName,
showAlertStatusActions = true,
}: TakeActionsProps) => {
const { updateAlertStatus } = useUpdateAlertsStatus();
const { addSuccess, addError, addWarning } = useAppToasts();
const { startTransaction } = useStartTransaction();
const getGlobalQuerySelector = inputsSelectors.globalQuery();
@ -163,7 +160,6 @@ export const useGroupTakeActionsItems = ({
try {
const response = await updateAlertStatus({
index: indexName,
status,
query: query ? JSON.parse(query) : {},
});
@ -176,8 +172,6 @@ export const useGroupTakeActionsItems = ({
[
startTransaction,
reportAlertsGroupingTakeActionClick,
updateAlertStatus,
indexName,
onAlertStatusUpdateSuccess,
onAlertStatusUpdateFailure,
]

View file

@ -177,7 +177,6 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
const { actionItems: statusActionItems } = useAlertsActions({
alertStatus,
eventId: ecsRowData?._id,
indexName: ecsRowData?._index ?? '',
scopeId,
refetch: refetchAll,
closePopover,

View file

@ -19,7 +19,6 @@ interface Props {
closePopover: () => void;
eventId: string;
scopeId: string;
indexName: string;
refetch?: () => void;
}
@ -28,7 +27,6 @@ export const useAlertsActions = ({
closePopover,
eventId,
scopeId,
indexName,
refetch,
}: Props) => {
const dispatch = useDispatch();
@ -63,7 +61,6 @@ export const useAlertsActions = ({
const actionItems = useBulkActionItems({
eventIds: [eventId],
currentStatus: alertStatus as AlertWorkflowStatus,
indexName,
setEventsLoading: localSetEventsLoading,
setEventsDeleted,
onUpdateSuccess: onStatusUpdate,

View file

@ -99,7 +99,6 @@ describe('take action dropdown', () => {
detailsData: generateAlertDetailsDataMock() as TimelineEventsDetailsItem[],
ecsData: getDetectionAlertMock(),
handleOnEventClosed: jest.fn(),
indexName: 'index',
isHostIsolationPanelOpen: false,
loadingEventDetails: false,
onAddEventFilterClick: jest.fn(),

View file

@ -48,7 +48,6 @@ export interface TakeActionDropdownProps {
detailsData: TimelineEventsDetailsItem[] | null;
ecsData?: Ecs;
handleOnEventClosed: () => void;
indexName: string;
isHostIsolationPanelOpen: boolean;
loadingEventDetails: boolean;
onAddEventFilterClick: () => void;
@ -65,7 +64,6 @@ export const TakeActionDropdown = React.memo(
detailsData,
ecsData,
handleOnEventClosed,
indexName,
isHostIsolationPanelOpen,
loadingEventDetails,
onAddEventFilterClick,
@ -180,7 +178,6 @@ export const TakeActionDropdown = React.memo(
alertStatus: actionsData.alertStatus,
closePopover: closePopoverAndFlyout,
eventId: actionsData.eventId,
indexName,
refetch,
scopeId,
});

View file

@ -16,11 +16,12 @@ import {
} from './mock';
import {
fetchQueryAlerts,
updateAlertStatus,
getSignalIndex,
getUserPrivilege,
createSignalIndex,
createHostIsolation,
updateAlertStatusByQuery,
updateAlertStatusByIds,
} from './api';
import { coreMock } from '@kbn/core/public/mocks';
@ -57,40 +58,40 @@ describe('Detections Alerts API', () => {
});
});
describe('updateAlertStatus', () => {
describe('updateAlertStatusByQuery', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue({});
});
test('check parameter url, body when closing an alert', async () => {
await updateAlertStatus({
await updateAlertStatusByQuery({
query: mockStatusAlertQuery,
signal: abortCtrl.signal,
status: 'closed',
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', {
body: '{"conflicts":"proceed","status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}',
body: '{"conflicts":"proceed","status":"closed","query":{"bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}}',
method: 'POST',
signal: abortCtrl.signal,
});
});
test('check parameter url, body when opening an alert', async () => {
await updateAlertStatus({
await updateAlertStatusByQuery({
query: mockStatusAlertQuery,
signal: abortCtrl.signal,
status: 'open',
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', {
body: '{"conflicts":"proceed","status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}',
body: '{"conflicts":"proceed","status":"open","query":{"bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}}',
method: 'POST',
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
const alertsResp = await updateAlertStatus({
const alertsResp = await updateAlertStatusByQuery({
query: mockStatusAlertQuery,
signal: abortCtrl.signal,
status: 'open',
@ -99,6 +100,48 @@ describe('Detections Alerts API', () => {
});
});
describe('updateAlertStatusById', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue({});
});
test('check parameter url, body when closing an alert', async () => {
await updateAlertStatusByIds({
signalIds: ['123'],
signal: abortCtrl.signal,
status: 'closed',
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', {
body: '{"status":"closed","signal_ids":["123"]}',
method: 'POST',
signal: abortCtrl.signal,
});
});
test('check parameter url, body when opening an alert', async () => {
await updateAlertStatusByIds({
signalIds: ['123'],
signal: abortCtrl.signal,
status: 'open',
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', {
body: '{"status":"open","signal_ids":["123"]}',
method: 'POST',
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
const alertsResp = await updateAlertStatusByIds({
signalIds: ['123'],
signal: abortCtrl.signal,
status: 'open',
});
expect(alertsResp).toEqual({});
});
});
describe('getSignalIndex', () => {
beforeEach(() => {
fetchMock.mockClear();

View file

@ -24,9 +24,10 @@ import type {
QueryAlerts,
AlertSearchResponse,
AlertsIndex,
UpdateAlertStatusProps,
UpdateAlertStatusByQueryProps,
CasesFromAlertsResponse,
CheckSignalIndex,
UpdateAlertStatusByIdsProps,
} from './types';
import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isolation';
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
@ -84,14 +85,34 @@ export const fetchQueryRuleRegistryAlerts = async <Hit, Aggregations>({
*
* @throws An error if response is not OK
*/
export const updateAlertStatus = async ({
export const updateAlertStatusByQuery = async ({
query,
status,
signal,
}: UpdateAlertStatusProps): Promise<estypes.UpdateByQueryResponse> =>
}: UpdateAlertStatusByQueryProps): Promise<estypes.UpdateByQueryResponse> =>
KibanaServices.get().http.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ conflicts: 'proceed', status, ...query }),
body: JSON.stringify({ conflicts: 'proceed', status, query }),
signal,
});
/**
* Update alert status by signalIds
*
* @param signalIds List of signal ids to update
* @param status to update to('open' / 'closed' / 'acknowledged')
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const updateAlertStatusByIds = async ({
signalIds,
status,
signal,
}: UpdateAlertStatusByIdsProps): Promise<estypes.BulkResponse> =>
KibanaServices.get().http.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, signal_ids: signalIds }),
signal,
});

View file

@ -38,10 +38,16 @@ export interface AlertSearchResponse<Hit = {}, Aggregations = {} | undefined>
};
}
export interface UpdateAlertStatusProps {
export interface UpdateAlertStatusByQueryProps {
query: object;
status: Status;
signal?: AbortSignal; // TODO: implement cancelling
signal?: AbortSignal;
}
export interface UpdateAlertStatusByIdsProps {
signalIds: string[];
status: Status;
signal?: AbortSignal;
}
export interface AlertsIndex {

View file

@ -12,14 +12,12 @@ import { buildEsQuery } from '@kbn/es-query';
import type { TableId } from '@kbn/securitysolution-data-table';
import type { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { APM_USER_INTERACTIONS } from '../../../common/lib/apm/constants';
import { useUpdateAlertsStatus } from '../../../common/components/toolbar/bulk_actions/use_update_alerts';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { updateAlertStatus } from '../../../common/components/toolbar/bulk_actions/update_alerts';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { useStartTransaction } from '../../../common/lib/apm/use_start_transaction';
import type { AlertWorkflowStatus } from '../../../common/types';
import { FILTER_CLOSED, FILTER_OPEN, FILTER_ACKNOWLEDGED } from '../../../../common/types';
import * as i18n from '../translations';
import { getUpdateAlertsQuery } from '../../components/alerts_table/actions';
import { buildTimeRangeFilter } from '../../components/alerts_table/helpers';
interface UseBulkAlertActionItemsArgs {
@ -45,7 +43,6 @@ export const useBulkAlertActionItems = ({
}: UseBulkAlertActionItemsArgs) => {
const { startTransaction } = useStartTransaction();
const { updateAlertStatus } = useUpdateAlertsStatus();
const { addSuccess, addError, addWarning } = useAppToasts();
const onAlertStatusUpdateSuccess = useCallback(
@ -92,8 +89,6 @@ export const useBulkAlertActionItems = ({
[addError]
);
const { selectedPatterns } = useSourcererDataView(scopeId);
const getOnAction = useCallback(
(status: AlertWorkflowStatus) => {
const onActionClick: BulkActionsConfig['onClick'] = async (
@ -103,14 +98,13 @@ export const useBulkAlertActionItems = ({
clearSelection,
refresh
) => {
const ids = items.map((item) => item._id);
let query: Record<string, unknown> = getUpdateAlertsQuery(ids).query;
let ids: string[] | undefined = items.map((item) => item._id);
let query: Record<string, unknown> | undefined;
if (isSelectAllChecked) {
const timeFilter = buildTimeRangeFilter(from, to);
query = buildEsQuery(undefined, [], [...timeFilter, ...filters], undefined);
}
if (query) {
ids = undefined;
startTransaction({ name: APM_USER_INTERACTIONS.BULK_QUERY_STATUS_UPDATE });
} else if (items.length > 1) {
startTransaction({ name: APM_USER_INTERACTIONS.BULK_STATUS_UPDATE });
@ -121,9 +115,9 @@ export const useBulkAlertActionItems = ({
try {
setAlertLoading(true);
const response = await updateAlertStatus({
index: selectedPatterns.join(','),
status,
query,
signalIds: ids,
});
setAlertLoading(false);
@ -150,8 +144,6 @@ export const useBulkAlertActionItems = ({
[
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
updateAlertStatus,
selectedPatterns,
startTransaction,
filters,
from,

View file

@ -17,14 +17,8 @@ import { useHostIsolationTools } from '../../timelines/components/side_panel/eve
*/
export const PanelFooter: FC = memo(() => {
const { closeFlyout } = useExpandableFlyoutContext();
const {
eventId,
indexName,
dataFormattedForFieldBrowser,
dataAsNestedObject,
refetchFlyoutData,
scopeId,
} = useRightPanelContext();
const { dataFormattedForFieldBrowser, dataAsNestedObject, refetchFlyoutData, scopeId } =
useRightPanelContext();
const { isHostIsolationPanelOpen, showHostIsolationPanel } = useHostIsolationTools();
@ -36,7 +30,6 @@ export const PanelFooter: FC = memo(() => {
<FlyoutFooter
detailsData={dataFormattedForFieldBrowser}
detailsEcsData={dataAsNestedObject}
expandedEvent={{ eventId, indexName }}
handleOnEventClosed={closeFlyout}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isReadOnly={false}

View file

@ -220,7 +220,6 @@ export const ExpandableEvent = React.memo<Props>(
detailsEcsData={detailsEcsData}
id={event.eventId}
isAlert={isAlert}
indexName={event.indexName}
isDraggable={isDraggable}
rawEventData={rawEventData}
scopeId={scopeId}

View file

@ -115,7 +115,6 @@ const defaultProps = {
isHostIsolationPanelOpen: false,
handleOnEventClosed: jest.fn(),
onAddIsolationStatusClick: jest.fn(),
expandedEvent: { eventId: ecsData._id, indexName: '' },
detailsData: mockAlertDetailsDataWithIsObject,
refetchFlyoutData: jest.fn(),
};

View file

@ -26,11 +26,6 @@ import { OsqueryFlyout } from '../../../../../detections/components/osquery/osqu
interface FlyoutFooterProps {
detailsData: TimelineEventsDetailsItem[] | null;
detailsEcsData: Ecs | null;
expandedEvent: {
eventId: string;
indexName: string;
refetch?: () => void;
};
handleOnEventClosed: () => void;
isHostIsolationPanelOpen: boolean;
isReadOnly?: boolean;
@ -52,7 +47,6 @@ export const FlyoutFooterComponent = React.memo(
({
detailsData,
detailsEcsData,
expandedEvent,
handleOnEventClosed,
isHostIsolationPanelOpen,
isReadOnly,
@ -162,7 +156,6 @@ export const FlyoutFooterComponent = React.memo(
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetchFlyoutData={refetchFlyoutData}
refetch={refetchAll}
indexName={expandedEvent.indexName}
scopeId={scopeId}
onOsqueryClick={setOsqueryFlyoutOpenWithAgentId}
/>

View file

@ -136,7 +136,6 @@ export const useToGetInternalFlyout = () => {
<FlyoutFooter
detailsData={detailsData}
detailsEcsData={ecsData}
expandedEvent={{ eventId: localAlert._id, indexName: localAlert._index }}
refetchFlyoutData={refetchFlyoutData}
handleOnEventClosed={noop}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}

View file

@ -257,7 +257,6 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
<FlyoutFooter
detailsData={detailsData}
detailsEcsData={ecsData}
expandedEvent={expandedEvent}
refetchFlyoutData={refetchFlyoutData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}

View file

@ -84,6 +84,53 @@ describe('set signal status', () => {
status_code: 500,
});
});
test('calls "esClient.updateByQuery" with queryId when query is defined', async () => {
await server.inject(
getSetSignalStatusByQueryRequest(),
requestContextMock.convertContext(context)
);
expect(context.core.elasticsearch.client.asCurrentUser.updateByQuery).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
query: expect.objectContaining({
bool: { filter: typicalSetStatusSignalByQueryPayload().query },
}),
}),
})
);
});
test('calls "esClient.bulk" with signalIds when ids are defined', async () => {
await server.inject(
getSetSignalStatusByIdsRequest(),
requestContextMock.convertContext(context)
);
expect(context.core.elasticsearch.client.asCurrentUser.bulk).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.arrayContaining([
{
update: {
_id: 'somefakeid1',
_index: '.alerts-security.alerts-default',
},
},
{
script: expect.anything(),
},
{
update: {
_id: 'somefakeid2',
_index: '.alerts-security.alerts-default',
},
},
{
script: expect.anything(),
},
]),
})
);
});
});
describe('request validation', () => {

View file

@ -8,7 +8,7 @@
import { get } from 'lodash';
import { transformError } from '@kbn/securitysolution-es-utils';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import type { Logger } from '@kbn/core/server';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import { setSignalStatusValidateTypeDependents } from '../../../../../common/detection_engine/schemas/request/set_signal_status_type_dependents';
import type { SetSignalsStatusSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/set_signal_status_schema';
import { setSignalsStatusSchema } from '../../../../../common/detection_engine/schemas/request/set_signal_status_schema';
@ -87,40 +87,20 @@ export const setSignalsStatusRoute = (
}
}
let queryObject;
if (signalIds) {
queryObject = { ids: { values: signalIds } };
}
if (query) {
queryObject = {
bool: {
filter: query,
},
};
}
try {
const body = await esClient.updateByQuery({
index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`,
conflicts: conflicts ?? 'abort',
// https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update-by-query.html#_refreshing_shards_2
// Note: Before we tried to use "refresh: wait_for" but I do not think that was available and instead it defaulted to "refresh: true"
// but the tests do not pass with "refresh: false". If at some point a "refresh: wait_for" is implemented, we should use that instead.
refresh: true,
body: {
script: {
source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) {
ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}'
}
if (ctx._source.signal != null && ctx._source.signal.status != null) {
ctx._source.signal.status = '${status}'
}`,
lang: 'painless',
},
query: queryObject,
},
ignore_unavailable: true,
});
return response.ok({ body });
if (signalIds) {
const body = await updateSignalsStatusByIds(status, signalIds, spaceId, esClient);
return response.ok({ body });
} else {
const body = await updateSignalsStatusByQuery(
status,
query,
{ conflicts: conflicts ?? 'abort' },
spaceId,
esClient
);
return response.ok({ body });
}
} catch (err) {
// error while getting or updating signal with id: id in signal index .siem-signals
const error = transformError(err);
@ -132,3 +112,62 @@ export const setSignalsStatusRoute = (
}
);
};
const updateSignalsStatusByIds = async (
status: SetSignalsStatusSchemaDecoded['status'],
signalsId: string[],
spaceId: string,
esClient: ElasticsearchClient
) =>
esClient.bulk({
index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`,
refresh: 'wait_for',
body: signalsId.flatMap((signalId) => [
{
update: { _id: signalId, _index: `${DEFAULT_ALERTS_INDEX}-${spaceId}` },
},
{
script: getUpdateSignalStatusScript(status),
},
]),
});
/**
* Please avoid using `updateSignalsStatusByQuery` when possible, use `updateSignalsStatusByIds` instead.
*
* This method calls `updateByQuery` with `refresh: true` which is expensive on serverless.
*/
const updateSignalsStatusByQuery = async (
status: SetSignalsStatusSchemaDecoded['status'],
query: object | undefined,
options: { conflicts: 'abort' | 'proceed' },
spaceId: string,
esClient: ElasticsearchClient
) =>
esClient.updateByQuery({
index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`,
conflicts: options.conflicts,
// https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update-by-query.html#_refreshing_shards_2
// Note: Before we tried to use "refresh: wait_for" but I do not think that was available and instead it defaulted to "refresh: true"
// but the tests do not pass with "refresh: false". If at some point a "refresh: wait_for" is implemented, we should use that instead.
refresh: true,
body: {
script: getUpdateSignalStatusScript(status),
query: {
bool: {
filter: query,
},
},
},
ignore_unavailable: true,
});
const getUpdateSignalStatusScript = (status: SetSignalsStatusSchemaDecoded['status']) => ({
source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) {
ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}'
}
if (ctx._source.signal != null && ctx._source.signal.status != null) {
ctx._source.signal.status = '${status}'
}`,
lang: 'painless',
});

View file

@ -20,7 +20,7 @@ import {
createSignalsIndex,
deleteAllAlerts,
setSignalStatus,
getAlertUpdateEmptyResponse,
getAlertUpdateByQueryEmptyResponse,
getQuerySignalIds,
deleteAllRules,
createRule,
@ -41,33 +41,106 @@ export default ({ getService }: FtrProviderContext) => {
describe('open_close_signals', () => {
describe('validation checks', () => {
it('should not give errors when querying and the signals index does not exist yet', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_SIGNALS_STATUS_URL)
.set('kbn-xsrf', 'true')
.send(setSignalStatus({ signalIds: ['123'], status: 'open' }))
.expect(200);
describe('update by ids', () => {
it('should not give errors when querying and the signals index does not exist yet', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_SIGNALS_STATUS_URL)
.set('kbn-xsrf', 'true')
.send(setSignalStatus({ signalIds: ['123'], status: 'open' }))
.expect(200);
// remove any server generated items that are indeterministic
delete body.took;
// remove any server generated items that are nondeterministic
body.items.forEach((_: any, index: number) => {
delete body.items[index].update.error.index_uuid;
});
delete body.took;
expect(body).to.eql(getAlertUpdateEmptyResponse());
expect(body).to.eql({
errors: true,
items: [
{
update: {
_id: '123',
_index: '.internal.alerts-security.alerts-default-000001',
error: {
index: '.internal.alerts-security.alerts-default-000001',
reason: '[123]: document missing',
shard: '0',
type: 'document_missing_exception',
},
status: 404,
},
},
],
});
});
it('should not give errors when querying and the signals index does exist and is empty', async () => {
await createSignalsIndex(supertest, log);
const { body } = await supertest
.post(DETECTION_ENGINE_SIGNALS_STATUS_URL)
.set('kbn-xsrf', 'true')
.send(setSignalStatus({ signalIds: ['123'], status: 'open' }))
.expect(200);
// remove any server generated items that are nondeterministic
body.items.forEach((_: any, index: number) => {
delete body.items[index].update.error.index_uuid;
});
delete body.took;
expect(body).to.eql({
errors: true,
items: [
{
update: {
_id: '123',
_index: '.internal.alerts-security.alerts-default-000001',
error: {
index: '.internal.alerts-security.alerts-default-000001',
reason: '[123]: document missing',
shard: '0',
type: 'document_missing_exception',
},
status: 404,
},
},
],
});
await deleteAllAlerts(supertest, log, es);
});
});
it('should not give errors when querying and the signals index does exist and is empty', async () => {
await createSignalsIndex(supertest, log);
const { body } = await supertest
.post(DETECTION_ENGINE_SIGNALS_STATUS_URL)
.set('kbn-xsrf', 'true')
.send(setSignalStatus({ signalIds: ['123'], status: 'open' }))
.expect(200);
describe('update by query', () => {
it('should not give errors when querying and the signals index does not exist yet', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_SIGNALS_STATUS_URL)
.set('kbn-xsrf', 'true')
.send(setSignalStatus({ query: { match_all: {} }, status: 'open' }))
.expect(200);
// remove any server generated items that are indeterministic
delete body.took;
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getAlertUpdateEmptyResponse());
expect(body).to.eql(getAlertUpdateByQueryEmptyResponse());
});
await deleteAllAlerts(supertest, log, es);
it('should not give errors when querying and the signals index does exist and is empty', async () => {
await createSignalsIndex(supertest, log);
const { body } = await supertest
.post(DETECTION_ENGINE_SIGNALS_STATUS_URL)
.set('kbn-xsrf', 'true')
.send(setSignalStatus({ query: { match_all: {} }, status: 'open' }))
.expect(200);
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getAlertUpdateByQueryEmptyResponse());
await deleteAllAlerts(supertest, log, es);
});
});
describe('tests with auditbeat data', () => {

View file

@ -17,7 +17,6 @@ import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
createSignalsIndex,
deleteAllAlerts,
getAlertUpdateEmptyResponse,
getQuerySignalIds,
deleteAllRules,
createRule,
@ -25,6 +24,7 @@ import {
getSignalsByIds,
waitForRuleSuccess,
getRuleForSignalTesting,
getAlertUpdateByQueryEmptyResponse,
} from '../../utils';
import { buildAlertTagsQuery, setAlertTags } from '../../utils/set_alert_tags';
@ -47,7 +47,7 @@ export default ({ getService }: FtrProviderContext) => {
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getAlertUpdateEmptyResponse());
expect(body).to.eql(getAlertUpdateByQueryEmptyResponse());
});
it('should give errors when duplicate tags exist in both tags_to_add and tags_to_remove', async () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const getAlertUpdateEmptyResponse = () => ({
export const getAlertUpdateByQueryEmptyResponse = () => ({
timed_out: false,
total: 0,
updated: 0,

View file

@ -12,11 +12,14 @@ import type {
export const setSignalStatus = ({
signalIds,
query,
status,
}: {
signalIds: SignalIds;
signalIds?: SignalIds;
query?: object;
status: Status;
}) => ({
signal_ids: signalIds,
query,
status,
});