From 075806bffa78cc4f42e61483dcbd24de3c87d3c8 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Mon, 20 Jan 2025 10:03:33 -0500 Subject: [PATCH 1/6] [Response Ops][Alerting] Adding ability to run actions for backfill rule runs (#200784) Resolves https://github.com/elastic/response-ops-team/issues/251 ## Note This PR includes some saved object schema changes that I will pull out into their own separate PR in order to perform an intermediate release. I wanted to make sure all the schema changes made sense in the overall context of the PR before opening those separate PRs. Update: PR for intermediate release here: https://github.com/elastic/kibana/pull/203184 (Merged) ## Summary Adds ability to run actions for backfill rule runs. - Updates schedule backfill API to accept `run_actions` parameter to specify whether to run actions for backfill. - Schedule API accepts any action where `frequency.notifyWhen === 'onActiveAlert'`. If a rule has multiple actions where some are `onActiveAlert` and some are `onThrottleInterval`, the invalid actions will be stripped and a warning returned in the schedule response but valid actions will be scheduled. - Connector IDs are extracted and stored as references in the ad hoc run params saved object - Any actions that result from a backfill task run are scheduled as low priority tasks ## To Verify 1. Create a detection rule. Make sure you have some past data that the rule can run over in order to generate actions. Make sure you add actions to the rule. For testing, I added some conditional actions so I could see actions running only on backfill runs using `kibana.alert.rule.execution.type: "manual"`. Create actions with and without summaries. 2. Schedule a backfill either directly via the API or using the detection UI. Verify that actions are run for the backfill runs that generate alerts. --------- Co-authored-by: Elastic Machine --- .../server/create_execute_function.test.ts | 207 +++ .../actions/server/create_execute_function.ts | 9 +- .../methods/delete/delete_backfill.test.ts | 2 +- .../methods/find/find_backfill.test.ts | 203 ++- .../backfill/methods/find/find_backfill.ts | 7 +- .../backfill/methods/get/get_backfill.test.ts | 131 +- .../backfill/methods/get/get_backfill.ts | 6 +- .../schedule/schedule_backfill.test.ts | 9 +- .../methods/schedule/schedule_backfill.ts | 1 + .../schedule_backfill_params_schema.ts | 1 + .../backfill/result/schemas/index.ts | 3 + ...form_ad_hoc_run_to_backfill_result.test.ts | 311 ++++- ...transform_ad_hoc_run_to_backfill_result.ts | 49 +- ...sform_backfill_param_to_ad_hoc_run.test.ts | 103 +- .../transform_backfill_param_to_ad_hoc_run.ts | 5 + .../backfill_client/backfill_client.test.ts | 1187 ++++++++++++++++- .../server/backfill_client/backfill_client.ts | 121 +- .../data/ad_hoc_run/types/ad_hoc_run.ts | 6 +- .../invalidate_pending_api_keys/task.test.ts | 471 ++++++- .../invalidate_pending_api_keys/task.ts | 61 +- .../apis/find/find_backfill_route.test.ts | 2 + .../apis/get/get_backfill_route.test.ts | 1 + .../schedule/schedule_backfill_route.test.ts | 2 + .../transforms/transform_request/v1.ts | 7 +- .../v1.test.ts | 2 + .../rules_client/lib/denormalize_actions.ts | 10 +- .../rules_client/lib/extract_references.ts | 6 +- .../action_scheduler/action_scheduler.test.ts | 177 ++- .../action_scheduler/lib/build_rule_url.ts | 5 +- .../lib/format_action_to_enqueue.test.ts | 119 ++ .../lib/format_action_to_enqueue.ts | 17 +- .../per_alert_action_scheduler.test.ts | 69 +- .../schedulers/per_alert_action_scheduler.ts | 2 + .../summary_action_scheduler.test.ts | 113 +- .../schedulers/summary_action_scheduler.ts | 2 + .../system_action_scheduler.test.ts | 87 +- .../schedulers/system_action_scheduler.ts | 2 + .../task_runner/action_scheduler/types.ts | 9 +- .../task_runner/ad_hoc_task_runner.test.ts | 147 +- .../server/task_runner/ad_hoc_task_runner.ts | 52 +- .../alerting/server/task_runner/fixtures.ts | 12 +- .../task_runner/transform_action_params.ts | 4 +- .../mark_available_tasks_as_claimed.ts | 4 +- .../shared/task_manager/server/task.ts | 5 + .../strategy_update_by_query.test.ts | 4 +- .../group1/tests/alerting/backfill/index.ts | 1 + .../tests/alerting/backfill/schedule.ts | 315 ++++- .../tests/alerting/backfill/task_runner.ts | 170 +-- .../backfill/task_runner_with_actions.ts | 267 ++++ .../tests/alerting/backfill/test_utils.ts | 166 +++ 50 files changed, 4251 insertions(+), 421 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner_with_actions.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/test_utils.ts diff --git a/x-pack/platform/plugins/shared/actions/server/create_execute_function.test.ts b/x-pack/platform/plugins/shared/actions/server/create_execute_function.test.ts index 7be187743e634..cb8057d771013 100644 --- a/x-pack/platform/plugins/shared/actions/server/create_execute_function.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/create_execute_function.test.ts @@ -16,6 +16,7 @@ import { asSavedObjectExecutionSource, } from './lib/action_execution_source'; import { actionsConfigMock } from './actions_config.mock'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; const mockTaskManager = taskManagerMock.createStart(); const savedObjectsClient = savedObjectsClientMock.create(); @@ -1189,4 +1190,210 @@ describe('bulkExecute()', () => { ] `); }); + + test('uses priority if specified', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + const executeFn = createBulkExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry: actionTypeRegistryMock.create(), + isESOCanEncrypt: true, + inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, + logger: mockLogger, + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { id: '123', type: 'action', attributes: { actionTypeId: 'mock-action' }, references: [] }, + ], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { id: '234', type: 'action_task_params', attributes: { actionId: '123' }, references: [] }, + ], + }); + expect( + await executeFn(savedObjectsClient, [ + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '123abc', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + priority: TaskPriority.Low, + }, + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '456xyz', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + }, + ]) + ).toMatchInlineSnapshot(` + Object { + "errors": true, + "items": Array [ + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "success", + "uuid": undefined, + }, + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "queuedActionsLimitError", + "uuid": undefined, + }, + ], + } + `); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "params": Object { + "actionTaskParamsId": "234", + "spaceId": "default", + }, + "priority": 1, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action", + }, + ], + ] + `); + }); + + test('uses apiKeyId if specified', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + const executeFn = createBulkExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry: actionTypeRegistryMock.create(), + isESOCanEncrypt: true, + inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, + logger: mockLogger, + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { id: '123', type: 'action', attributes: { actionTypeId: 'mock-action' }, references: [] }, + ], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { id: '234', type: 'action_task_params', attributes: { actionId: '123' }, references: [] }, + ], + }); + expect( + await executeFn(savedObjectsClient, [ + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '123abc', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + apiKeyId: '235qgbdbqet', + }, + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '456xyz', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + apiKeyId: '235qgbdbqet', + }, + ]) + ).toMatchInlineSnapshot(` + Object { + "errors": true, + "items": Array [ + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "success", + "uuid": undefined, + }, + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "queuedActionsLimitError", + "uuid": undefined, + }, + ], + } + `); + + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + { + attributes: { + actionId: '123', + apiKey: null, + apiKeyId: '235qgbdbqet', + consumer: undefined, + executionId: '123abc', + params: { + baz: false, + }, + relatedSavedObjects: undefined, + source: 'HTTP_REQUEST', + }, + references: [ + { + id: '123', + name: 'actionRef', + type: 'action', + }, + ], + type: 'action_task_params', + }, + ], + { refresh: false } + ); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "params": Object { + "actionTaskParamsId": "234", + "spaceId": "default", + }, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action", + }, + ], + ] + `); + }); }); diff --git a/x-pack/platform/plugins/shared/actions/server/create_execute_function.ts b/x-pack/platform/plugins/shared/actions/server/create_execute_function.ts index 539ada46a094a..4bfa992aae15f 100644 --- a/x-pack/platform/plugins/shared/actions/server/create_execute_function.ts +++ b/x-pack/platform/plugins/shared/actions/server/create_execute_function.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsBulkResponse, SavedObjectsClientContract, Logger } from '@kbn/core/server'; -import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import { TaskPriority, TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { RawAction, ActionTypeRegistryContract, InMemoryConnector } from './types'; import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; @@ -28,9 +28,11 @@ export interface ExecuteOptions id: string; uuid?: string; spaceId: string; + apiKeyId?: string; apiKey: string | null; executionId: string; actionTypeId: string; + priority?: TaskPriority; } interface ActionTaskParams @@ -166,15 +168,17 @@ export function createBulkExecutionEnqueuerFunction({ executionId: actionToExecute.executionId, consumer: actionToExecute.consumer, relatedSavedObjects: relatedSavedObjectWithRefs, + apiKeyId: actionToExecute.apiKeyId, ...(actionToExecute.source ? { source: actionToExecute.source.type } : {}), }, references: taskReferences, }; }); + const actionTaskParamsRecords: SavedObjectsBulkResponse = await unsecuredSavedObjectsClient.bulkCreate(actions, { refresh: false }); - const taskInstances = actionTaskParamsRecords.saved_objects.map((so) => { + const taskInstances = actionTaskParamsRecords.saved_objects.map((so, index) => { const actionId = so.attributes.actionId; return { taskType: `actions:${actionTypeIds[actionId]}`, @@ -184,6 +188,7 @@ export function createBulkExecutionEnqueuerFunction({ }, state: {}, scope: ['actions'], + ...(runnableActions[index]?.priority ? { priority: runnableActions[index].priority } : {}), }; }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts index b0879613d069f..d31c830c970ca 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts @@ -85,8 +85,8 @@ const mockAdHocRunSO: SavedObject = { name: fakeRuleName, tags: ['foo'], alertTypeId: 'myType', - // @ts-expect-error params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.test.ts index ade1a1f35b59e..7f140622310be 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.test.ts @@ -6,7 +6,7 @@ */ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; -import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { actionsAuthorizationMock, actionsClientMock } from '@kbn/actions-plugin/server/mocks'; import { RULE_SAVED_OBJECT_TYPE } from '../../../..'; import { AlertingAuthorization } from '../../../../authorization'; import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; @@ -22,7 +22,7 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s import { fromKueryExpression } from '@kbn/es-query'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ConstructorOptions, RulesClient } from '../../../../rules_client'; +import { RulesClient } from '../../../../rules_client'; import { adHocRunStatus } from '../../../../../common/constants'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { SavedObject } from '@kbn/core/server'; @@ -142,35 +142,7 @@ const authDslFilter = { type: 'function', }; -const rulesClientParams: jest.Mocked = { - taskManager, - ruleTypeRegistry, - unsecuredSavedObjectsClient, - authorization: authorization as unknown as AlertingAuthorization, - actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, - spaceId: 'default', - namespace: 'default', - getUserName: jest.fn(), - createAPIKey: jest.fn(), - logger: loggingSystemMock.create().get(), - internalSavedObjectsRepository, - encryptedSavedObjectsClient: encryptedSavedObjects, - getActionsClient: jest.fn(), - getEventLogClient: jest.fn(), - kibanaVersion, - auditLogger, - maxScheduledPerMinute: 10000, - minimumScheduleInterval: { value: '1m', enforce: false }, - isAuthenticationTypeAPIKey: jest.fn(), - getAuthenticationAPIKey: jest.fn(), - getAlertIndicesAlias: jest.fn(), - alertsService: null, - backfillClient, - isSystemAction: jest.fn(), - connectorAdapterRegistry: new ConnectorAdapterRegistry(), - uiSettings: uiSettingsServiceMock.createStartContract(), -}; - +const mockActionsClient = actionsClientMock.create(); const fakeRuleName = 'fakeRuleName'; const mockAdHocRunSO: SavedObject = { @@ -186,7 +158,6 @@ const mockAdHocRunSO: SavedObject = { name: fakeRuleName, tags: ['foo'], alertTypeId: 'myType', - // @ts-expect-error params: {}, apiKeyOwner: 'user', apiKeyCreatedByUser: false, @@ -195,6 +166,7 @@ const mockAdHocRunSO: SavedObject = { schedule: { interval: '12h', }, + actions: [], createdBy: 'user', updatedBy: 'user', createdAt: '2019-02-12T21:01:22.479Z', @@ -222,10 +194,41 @@ const mockAdHocRunSO: SavedObject = { describe('findBackfill()', () => { let rulesClient: RulesClient; + let isSystemAction: jest.Mock; beforeEach(async () => { jest.resetAllMocks(); - rulesClient = new RulesClient(rulesClientParams); + isSystemAction = jest.fn().mockReturnValue(false); + mockActionsClient.isSystemAction.mockImplementation(isSystemAction); + + rulesClient = new RulesClient({ + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn().mockResolvedValue(mockActionsClient), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + }); authorization.getFindAuthorizationFilter.mockResolvedValue({ filter, ensureRuleTypeIsAuthorized() {}, @@ -282,7 +285,7 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], }); }); @@ -331,7 +334,7 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], }); }); @@ -398,7 +401,7 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], }); }); @@ -465,7 +468,7 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], }); }); @@ -548,7 +551,7 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], }); }); @@ -633,7 +636,127 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], + }); + }); + + test('should successfully find backfill for rule with actions', async () => { + const mockAdHocRunSOWithActions = { + ...mockAdHocRunSO, + attributes: { + ...mockAdHocRunSO.attributes, + rule: { + ...mockAdHocRunSO.attributes.rule, + actions: [ + { + uuid: '123abc', + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: {}, + }, + ], + }, + }, + references: [ + { id: 'abc', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '4', name: 'action_0', type: 'action' }, + ], + score: 0, + }; + unsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [mockAdHocRunSOWithActions], + per_page: 10, + page: 1, + total: 1, + }); + + const result = await rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + ruleIds: 'abc', + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith({ + authorizationEntity: 'rule', + filterOpts: { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }, + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.start' }, + 'gte', + { isQuoted: true, type: 'literal', value: '2024-02-09T02:07:55Z' }, + ], + }, + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.end' }, + 'lte', + { isQuoted: true, type: 'literal', value: '2024-03-29T02:07:55Z' }, + ], + }, + ], + }, + authDslFilter, + ], + }, + hasReference: [{ id: 'abc', type: RULE_SAVED_OBJECT_TYPE }], + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: 'backfill for rule "fakeRuleName"', + }, + }, + message: + 'User has found ad hoc run for ad_hoc_run_params [id=1] backfill for rule "fakeRuleName"', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [ + transformAdHocRunToBackfillResult({ + adHocRunSO: mockAdHocRunSOWithActions, + isSystemAction, + }), + ], }); }); @@ -688,7 +811,7 @@ describe('findBackfill()', () => { page: 1, perPage: 10, total: 1, - data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + data: [transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction })], }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.ts index 3482d854bda1f..d6f723d31b06d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/find/find_backfill.ts @@ -98,6 +98,8 @@ export async function findBackfill( ...(params.sortOrder ? { sortOrder: params.sortOrder } : {}), }); + const actionsClient = await context.getActionsClient(); + const transformedData: Backfill[] = data.map((so: SavedObject) => { context.auditLogger?.log( adHocRunAuditEvent({ @@ -110,7 +112,10 @@ export async function findBackfill( }) ); - return transformAdHocRunToBackfillResult(so) as Backfill; + return transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction: (id: string) => actionsClient.isSystemAction(id), + }) as Backfill; }); return { diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.test.ts index cbf516bfd446e..7ed0db8a0a9fd 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.test.ts @@ -6,7 +6,7 @@ */ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; -import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { actionsAuthorizationMock, actionsClientMock } from '@kbn/actions-plugin/server/mocks'; import { RULE_SAVED_OBJECT_TYPE } from '../../../..'; import { AlertingAuthorization } from '../../../../authorization'; import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; @@ -21,7 +21,7 @@ import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ConstructorOptions, RulesClient } from '../../../../rules_client'; +import { RulesClient } from '../../../../rules_client'; import { adHocRunStatus } from '../../../../../common/constants'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; @@ -41,37 +41,9 @@ const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); const backfillClient = backfillClientMock.create(); const logger = loggingSystemMock.create().get(); -const rulesClientParams: jest.Mocked = { - taskManager, - ruleTypeRegistry, - unsecuredSavedObjectsClient, - authorization: authorization as unknown as AlertingAuthorization, - actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, - spaceId: 'default', - namespace: 'default', - getUserName: jest.fn(), - createAPIKey: jest.fn(), - logger, - internalSavedObjectsRepository, - encryptedSavedObjectsClient: encryptedSavedObjects, - getActionsClient: jest.fn(), - getEventLogClient: jest.fn(), - kibanaVersion, - auditLogger, - maxScheduledPerMinute: 10000, - minimumScheduleInterval: { value: '1m', enforce: false }, - isAuthenticationTypeAPIKey: jest.fn(), - getAuthenticationAPIKey: jest.fn(), - getAlertIndicesAlias: jest.fn(), - alertsService: null, - backfillClient, - isSystemAction: jest.fn(), - connectorAdapterRegistry: new ConnectorAdapterRegistry(), - uiSettings: uiSettingsServiceMock.createStartContract(), -}; - const fakeRuleName = 'fakeRuleName'; +const mockActionsClient = actionsClientMock.create(); const mockAdHocRunSO: SavedObject = { id: '1', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, @@ -85,7 +57,7 @@ const mockAdHocRunSO: SavedObject = { name: fakeRuleName, tags: ['foo'], alertTypeId: 'myType', - // @ts-expect-error + actions: [], params: {}, apiKeyOwner: 'user', apiKeyCreatedByUser: false, @@ -121,10 +93,41 @@ const mockAdHocRunSO: SavedObject = { describe('getBackfill()', () => { let rulesClient: RulesClient; + let isSystemAction: jest.Mock; beforeEach(async () => { jest.resetAllMocks(); - rulesClient = new RulesClient(rulesClientParams); + isSystemAction = jest.fn().mockReturnValue(false); + mockActionsClient.isSystemAction.mockImplementation(isSystemAction); + + rulesClient = new RulesClient({ + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger, + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn().mockResolvedValue(mockActionsClient), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + }); unsecuredSavedObjectsClient.get.mockResolvedValue(mockAdHocRunSO); }); @@ -158,7 +161,67 @@ describe('getBackfill()', () => { }); expect(logger.error).not.toHaveBeenCalled(); - expect(result).toEqual(transformAdHocRunToBackfillResult(mockAdHocRunSO)); + expect(result).toEqual( + transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSO, isSystemAction }) + ); + }); + + test('should successfully get backfill with actions', async () => { + const mockAdHocRunSOWithActions = { + ...mockAdHocRunSO, + attributes: { + ...mockAdHocRunSO.attributes, + rule: { + ...mockAdHocRunSO.attributes.rule, + actions: [ + { + uuid: '123abc', + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: {}, + }, + ], + }, + }, + references: [ + { id: 'abc', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '4', name: 'action_0', type: 'action' }, + ], + }; + unsecuredSavedObjectsClient.get.mockResolvedValue(mockAdHocRunSOWithActions); + const result = await rulesClient.getBackfill('1'); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith(AD_HOC_RUN_SAVED_OBJECT_TYPE, '1'); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'getBackfill', + ruleTypeId: 'myType', + }); + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenCalledWith({ + event: { + action: 'ad_hoc_run_get', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "fakeRuleName"`, + }, + }, + message: + 'User has got ad hoc run for ad_hoc_run_params [id=1] backfill for rule "fakeRuleName"', + }); + expect(logger.error).not.toHaveBeenCalled(); + + expect(result).toEqual( + transformAdHocRunToBackfillResult({ adHocRunSO: mockAdHocRunSOWithActions, isSystemAction }) + ); }); describe('error handling', () => { diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.ts index 6f14dba88684c..4bd2ff3a5d4b3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/get/get_backfill.ts @@ -73,7 +73,11 @@ export async function getBackfill(context: RulesClientContext, id: string): Prom }) ); - return transformAdHocRunToBackfillResult(result) as Backfill; + const actionsClient = await context.getActionsClient(); + return transformAdHocRunToBackfillResult({ + adHocRunSO: result, + isSystemAction: (connectorId: string) => actionsClient.isSystemAction(connectorId), + }) as Backfill; } catch (err) { const errorMessage = `Failed to get backfill by id: ${id}`; context.logger.error(`${errorMessage} - ${err}`); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts index a0a79421c7358..25df7048d4b44 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts @@ -180,6 +180,7 @@ function getMockData(overwrites: Record = {}): ScheduleBackfill return { ruleId: '1', start: '2023-11-16T08:00:00.000Z', + runActions: true, ...overwrites, }; } @@ -481,13 +482,13 @@ describe('scheduleBackfill()', () => { // @ts-expect-error rulesClient.scheduleBackfill(getMockData()) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error validating backfill schedule parameters \\"{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\"}\\" - expected value of type [array] but got [Object]"` + `"Error validating backfill schedule parameters \\"{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\",\\"runActions\\":true}\\" - expected value of type [array] but got [Object]"` ); await expect( rulesClient.scheduleBackfill([getMockData({ ruleId: 1 })]) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":1,\\"start\\":\\"2023-11-16T08:00:00.000Z\\"}]\\" - [0.ruleId]: expected value of type [string] but got [number]"` + `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":1,\\"start\\":\\"2023-11-16T08:00:00.000Z\\",\\"runActions\\":true}]\\" - [0.ruleId]: expected value of type [string] but got [number]"` ); }); @@ -502,7 +503,7 @@ describe('scheduleBackfill()', () => { }), ]) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\"},{\\"ruleId\\":\\"2\\",\\"start\\":\\"2023-11-17T08:00:00.000Z\\",\\"end\\":\\"2023-11-17T08:00:00.000Z\\"}]\\" - [1]: Backfill end must be greater than backfill start"` + `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\",\\"runActions\\":true},{\\"ruleId\\":\\"2\\",\\"start\\":\\"2023-11-17T08:00:00.000Z\\",\\"runActions\\":true,\\"end\\":\\"2023-11-17T08:00:00.000Z\\"}]\\" - [1]: Backfill end must be greater than backfill start"` ); await expect( @@ -515,7 +516,7 @@ describe('scheduleBackfill()', () => { }), ]) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\"},{\\"ruleId\\":\\"2\\",\\"start\\":\\"2023-11-17T08:00:00.000Z\\",\\"end\\":\\"2023-11-16T08:00:00.000Z\\"}]\\" - [1]: Backfill end must be greater than backfill start"` + `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\",\\"runActions\\":true},{\\"ruleId\\":\\"2\\",\\"start\\":\\"2023-11-17T08:00:00.000Z\\",\\"runActions\\":true,\\"end\\":\\"2023-11-16T08:00:00.000Z\\"}]\\" - [1]: Backfill end must be greater than backfill start"` ); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts index 2dec1a78e3171..5d2dd5f56a92e 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts @@ -145,6 +145,7 @@ export async function scheduleBackfill( const actionsClient = await context.getActionsClient(); return await context.backfillClient.bulkQueue({ + actionsClient, auditLogger: context.auditLogger, params, rules: rulesToSchedule.map(({ id, attributes, references }) => { diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts index c4a469da1b5db..ce1d14b7400af 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts @@ -14,6 +14,7 @@ export const scheduleBackfillParamSchema = schema.object( ruleId: schema.string(), start: schema.string(), end: schema.maybe(schema.string()), + runActions: schema.maybe(schema.boolean()), }, { validate({ start, end }) { diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/result/schemas/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/result/schemas/index.ts index b454d41dd40ca..ecba6cc45ae1c 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/result/schemas/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/result/schemas/index.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { ruleParamsSchema } from '@kbn/response-ops-rule-params'; import { adHocRunStatus } from '../../../../../common/constants'; +import { actionSchema as ruleActionSchema } from '../../../rule/schemas/action_schemas'; export const statusSchema = schema.oneOf([ schema.literal(adHocRunStatus.COMPLETE), @@ -32,6 +33,7 @@ export const backfillSchema = schema.object({ id: schema.string(), name: schema.string(), tags: schema.arrayOf(schema.string()), + actions: schema.arrayOf(ruleActionSchema), alertTypeId: schema.string(), params: ruleParamsSchema, apiKeyOwner: schema.nullable(schema.string()), @@ -50,4 +52,5 @@ export const backfillSchema = schema.object({ status: statusSchema, end: schema.maybe(schema.string()), schedule: schema.arrayOf(backfillScheduleSchema), + warnings: schema.maybe(schema.arrayOf(schema.string())), }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts index 995240cbbd023..2d0e6ff20da08 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts @@ -8,17 +8,24 @@ import { AdHocRunSO } from '../../../data/ad_hoc_run/types'; import { SavedObject } from '@kbn/core/server'; import { adHocRunStatus } from '../../../../common/constants'; -import { transformAdHocRunToBackfillResult } from './transform_ad_hoc_run_to_backfill_result'; +import { + transformAdHocRunToAdHocRunData, + transformAdHocRunToBackfillResult, +} from './transform_ad_hoc_run_to_backfill_result'; +import { RawRule } from '../../../types'; + +const isSystemAction = jest.fn().mockReturnValue(false); function getMockAdHocRunAttributes({ ruleId, - overwrites, omitApiKey = false, + actions, }: { ruleId?: string; - overwrites?: Record; omitApiKey?: boolean; + actions?: RawRule['actions']; } = {}): AdHocRunSO { + // @ts-expect-error return { ...(omitApiKey ? {} : { apiKeyId: '123', apiKeyToUse: 'MTIzOmFiYw==' }), createdAt: '2024-01-30T00:00:00.000Z', @@ -29,7 +36,7 @@ function getMockAdHocRunAttributes({ name: 'my rule name', tags: ['foo'], alertTypeId: 'myType', - // @ts-expect-error + actions: actions ? actions : [], params: {}, apiKeyOwner: 'user', apiKeyCreatedByUser: false, @@ -59,14 +66,14 @@ function getMockAdHocRunAttributes({ runAt: '2023-10-20T15:07:40.011Z', }, ], - ...overwrites, }; } function getBulkCreateResponse( id: string, ruleId: string, - attributes: AdHocRunSO + attributes: AdHocRunSO, + additionalReferences?: Array<{ id: string; name: string; type: string }> ): SavedObject { return { type: 'ad_hoc_rule_run_params', @@ -79,6 +86,7 @@ function getBulkCreateResponse( name: 'rule', type: 'alert', }, + ...(additionalReferences ?? []), ], managed: false, coreMigrationVersion: '8.8.0', @@ -91,9 +99,74 @@ function getBulkCreateResponse( describe('transformAdHocRunToBackfillResult', () => { test('should transform bulk create response', () => { expect( - transformAdHocRunToBackfillResult( - getBulkCreateResponse('abc', '1', getMockAdHocRunAttributes()) - ) + transformAdHocRunToBackfillResult({ + adHocRunSO: getBulkCreateResponse('abc', '1', getMockAdHocRunAttributes()), + isSystemAction, + }) + ).toEqual({ + id: 'abc', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + id: '1', + name: 'my rule name', + tags: ['foo'], + actions: [], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }); + }); + + test('should transform bulk create response with actions', () => { + expect( + transformAdHocRunToBackfillResult({ + adHocRunSO: getBulkCreateResponse( + 'abc', + '1', + getMockAdHocRunAttributes({ + actions: [ + { + uuid: '123abc', + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: {}, + }, + ], + }), + [{ id: '4', name: 'action_0', type: 'action' }] + ), + isSystemAction, + }) ).toEqual({ id: 'abc', createdAt: '2024-01-30T00:00:00.000Z', @@ -103,6 +176,7 @@ describe('transformAdHocRunToBackfillResult', () => { id: '1', name: 'my rule name', tags: ['foo'], + actions: [{ actionTypeId: 'test', group: 'default', id: '4', params: {}, uuid: '123abc' }], alertTypeId: 'myType', params: {}, apiKeyOwner: 'user', @@ -138,10 +212,10 @@ describe('transformAdHocRunToBackfillResult', () => { test('should return error for malformed responses when original create request is not provided', () => { expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // missing id // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', namespaces: ['default'], attributes: getMockAdHocRunAttributes(), @@ -151,8 +225,9 @@ describe('transformAdHocRunToBackfillResult', () => { updated_at: '2024-02-07T16:05:39.296Z', created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "id".', @@ -160,10 +235,10 @@ describe('transformAdHocRunToBackfillResult', () => { }, }); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // missing attributes // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: 'abc', namespaces: ['default'], @@ -173,8 +248,9 @@ describe('transformAdHocRunToBackfillResult', () => { updated_at: '2024-02-07T16:05:39.296Z', created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "attributes".', @@ -182,10 +258,10 @@ describe('transformAdHocRunToBackfillResult', () => { }, }); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // missing references // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: 'def', namespaces: ['default'], @@ -195,8 +271,9 @@ describe('transformAdHocRunToBackfillResult', () => { updated_at: '2024-02-07T16:05:39.296Z', created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "references".', @@ -204,9 +281,9 @@ describe('transformAdHocRunToBackfillResult', () => { }, }); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // empty references - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: 'ghi', namespaces: ['default'], @@ -217,8 +294,9 @@ describe('transformAdHocRunToBackfillResult', () => { updated_at: '2024-02-07T16:05:39.296Z', created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "references".', @@ -230,10 +308,10 @@ describe('transformAdHocRunToBackfillResult', () => { test('should return error for malformed responses when original create request is provided', () => { const attributes = getMockAdHocRunAttributes(); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // missing id // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', namespaces: ['default'], attributes, @@ -244,12 +322,13 @@ describe('transformAdHocRunToBackfillResult', () => { created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', }, - { + originalSO: { type: 'ad_hoc_rule_run_params', attributes, references: [{ id: '1', name: 'rule', type: 'alert' }], - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "id".', @@ -257,10 +336,10 @@ describe('transformAdHocRunToBackfillResult', () => { }, }); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // missing attributes // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: 'abc', namespaces: ['default'], @@ -271,12 +350,13 @@ describe('transformAdHocRunToBackfillResult', () => { created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', }, - { + originalSO: { type: 'ad_hoc_rule_run_params', attributes, references: [{ id: '1', name: 'rule', type: 'alert' }], - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "attributes".', @@ -284,10 +364,10 @@ describe('transformAdHocRunToBackfillResult', () => { }, }); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // missing references // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: 'def', namespaces: ['default'], @@ -298,12 +378,13 @@ describe('transformAdHocRunToBackfillResult', () => { created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', }, - { + originalSO: { type: 'ad_hoc_rule_run_params', attributes, references: [{ id: '1', name: 'rule', type: 'alert' }], - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "references".', @@ -311,9 +392,9 @@ describe('transformAdHocRunToBackfillResult', () => { }, }); expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // empty references - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: 'ghi', namespaces: ['default'], @@ -325,12 +406,13 @@ describe('transformAdHocRunToBackfillResult', () => { created_at: '2024-02-07T16:05:39.296Z', version: 'WzcsMV0=', }, - { + originalSO: { type: 'ad_hoc_rule_run_params', attributes, references: [{ id: '1', name: 'rule', type: 'alert' }], - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Malformed saved object in bulkCreate response - Missing "references".', @@ -341,9 +423,9 @@ describe('transformAdHocRunToBackfillResult', () => { test('should pass through error if saved object error when original create request is not provided', () => { expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: '788a2784-c021-484f-a53e-0c1c63c7567c', error: { @@ -351,8 +433,9 @@ describe('transformAdHocRunToBackfillResult', () => { message: 'Unable to create', statusCode: 404, }, - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Unable to create', @@ -363,9 +446,9 @@ describe('transformAdHocRunToBackfillResult', () => { test('should pass through error if saved object error when original create request is provided', () => { expect( - transformAdHocRunToBackfillResult( + transformAdHocRunToBackfillResult({ // @ts-expect-error - { + adHocRunSO: { type: 'ad_hoc_rule_run_params', id: '788a2784-c021-484f-a53e-0c1c63c7567c', error: { @@ -374,12 +457,13 @@ describe('transformAdHocRunToBackfillResult', () => { statusCode: 404, }, }, - { + originalSO: { type: 'ad_hoc_rule_run_params', attributes: getMockAdHocRunAttributes(), references: [{ id: '1', name: 'rule', type: 'alert' }], - } - ) + }, + isSystemAction, + }) ).toEqual({ error: { message: 'Unable to create', @@ -388,3 +472,122 @@ describe('transformAdHocRunToBackfillResult', () => { }); }); }); + +describe('transformAdHocRunToAdHocRunData', () => { + test('should transform bulk create response and include api key', () => { + expect( + transformAdHocRunToAdHocRunData({ + adHocRunSO: getBulkCreateResponse('abc', '1', getMockAdHocRunAttributes()), + isSystemAction, + }) + ).toEqual({ + id: 'abc', + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + id: '1', + name: 'my rule name', + tags: ['foo'], + actions: [], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }); + }); + + test('should transform bulk create response with actions and include api key', () => { + expect( + transformAdHocRunToAdHocRunData({ + adHocRunSO: getBulkCreateResponse( + 'abc', + '1', + getMockAdHocRunAttributes({ + actions: [ + { + uuid: '123abc', + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: {}, + }, + ], + }), + [{ id: '4', name: 'action_0', type: 'action' }] + ), + isSystemAction, + }) + ).toEqual({ + id: 'abc', + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + id: '1', + name: 'my rule name', + tags: ['foo'], + actions: [{ actionTypeId: 'test', group: 'default', id: '4', params: {}, uuid: '123abc' }], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts index 13257742b7005..78b228711df7d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts @@ -6,14 +6,25 @@ */ import { SavedObject, SavedObjectsBulkCreateObject } from '@kbn/core/server'; -import { AdHocRunSO } from '../../../data/ad_hoc_run/types'; +import { AdHocRun, AdHocRunSO } from '../../../data/ad_hoc_run/types'; import { createBackfillError } from '../../../backfill_client/lib'; import { ScheduleBackfillResult } from '../methods/schedule/types'; +import { transformRawActionsToDomainActions } from '../../rule/transforms'; -export const transformAdHocRunToBackfillResult = ( - { id, attributes, references, error }: SavedObject, - originalSO?: SavedObjectsBulkCreateObject -): ScheduleBackfillResult => { +interface TransformAdHocRunToBackfillResultOpts { + adHocRunSO: SavedObject; + isSystemAction: (connectorId: string) => boolean; + originalSO?: SavedObjectsBulkCreateObject; + omitGeneratedActionValues?: boolean; +} + +export const transformAdHocRunToBackfillResult = ({ + adHocRunSO, + isSystemAction, + originalSO, + omitGeneratedActionValues = true, +}: TransformAdHocRunToBackfillResultOpts): ScheduleBackfillResult => { + const { id, attributes, references, error } = adHocRunSO; const ruleId = references?.[0]?.id ?? originalSO?.references?.[0]?.id ?? 'unknown'; const ruleName = attributes?.rule?.name ?? originalSO?.attributes?.rule.name; if (error) { @@ -55,6 +66,13 @@ export const transformAdHocRunToBackfillResult = ( rule: { ...attributes.rule, id: references[0].id, + actions: transformRawActionsToDomainActions({ + ruleId: id, + actions: attributes.rule.actions, + references, + isSystemAction, + omitGeneratedValues: omitGeneratedActionValues, + }), }, spaceId: attributes.spaceId, start: attributes.start, @@ -62,3 +80,24 @@ export const transformAdHocRunToBackfillResult = ( schedule: attributes.schedule, }; }; + +// includes API key information +export const transformAdHocRunToAdHocRunData = ({ + adHocRunSO, + isSystemAction, + originalSO, + omitGeneratedActionValues = true, +}: TransformAdHocRunToBackfillResultOpts): AdHocRun => { + const result = transformAdHocRunToBackfillResult({ + adHocRunSO, + isSystemAction, + originalSO, + omitGeneratedActionValues, + }); + + return { + ...result, + apiKeyId: adHocRunSO.attributes.apiKeyId, + apiKeyToUse: adHocRunSO.attributes.apiKeyToUse, + } as AdHocRun; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts index 0dd1995e05b98..822ffed9dfce6 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts @@ -14,6 +14,7 @@ function getMockData(overwrites: Record = {}): ScheduleBackfill return { ruleId: '1', start: '2023-11-16T08:00:00.000Z', + runActions: true, ...overwrites, }; } @@ -63,7 +64,7 @@ describe('transformBackfillParamToAdHocRun', () => { }); test('should transform backfill param with start', () => { - expect(transformBackfillParamToAdHocRun(getMockData(), getMockRule(), 'default')).toEqual({ + expect(transformBackfillParamToAdHocRun(getMockData(), getMockRule(), [], 'default')).toEqual({ apiKeyId: '123', apiKeyToUse: 'MTIzOmFiYw==', createdAt: '2024-01-30T00:00:00.000Z', @@ -75,6 +76,7 @@ describe('transformBackfillParamToAdHocRun', () => { name: 'my rule name', tags: ['foo'], alertTypeId: 'myType', + actions: [], params: {}, apiKeyOwner: 'user', apiKeyCreatedByUser: false, @@ -107,6 +109,7 @@ describe('transformBackfillParamToAdHocRun', () => { transformBackfillParamToAdHocRun( getMockData({ end: '2023-11-17T08:00:00.000Z' }), getMockRule(), + [], 'default' ) ).toEqual({ @@ -120,6 +123,7 @@ describe('transformBackfillParamToAdHocRun', () => { name: 'my rule name', tags: ['foo'], alertTypeId: 'myType', + actions: [], params: {}, apiKeyOwner: 'user', apiKeyCreatedByUser: false, @@ -151,4 +155,101 @@ describe('transformBackfillParamToAdHocRun', () => { ], }); }); + + test('should transform backfill param with rule actions', () => { + const actions = [ + { uuid: '123abc', group: 'default', actionRef: 'action_0', actionTypeId: 'test', params: {} }, + ]; + expect( + transformBackfillParamToAdHocRun(getMockData(), getMockRule(), actions, 'default') + ).toEqual({ + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + // injects end parameter + end: '2023-11-16T20:00:00.000Z', + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + actions, + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }); + }); + + test('should omit rule actions when runActions=false', () => { + const actions = [ + { uuid: '123abc', group: 'default', actionRef: 'action_0', actionTypeId: 'test', params: {} }, + ]; + expect( + transformBackfillParamToAdHocRun( + getMockData({ runActions: false }), + getMockRule(), + actions, + 'default' + ) + ).toEqual({ + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + // injects end parameter + end: '2023-11-16T20:00:00.000Z', + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + actions: [], + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts index 4dc01a6c8939e..b293f7215fec4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts @@ -6,6 +6,7 @@ */ import { isString } from 'lodash'; +import { DenormalizedAction } from '../../../rules_client'; import { AdHocRunSO } from '../../../data/ad_hoc_run/types'; import { calculateSchedule } from '../../../backfill_client/lib'; import { adHocRunStatus } from '../../../../common/constants'; @@ -15,9 +16,12 @@ import { ScheduleBackfillParam } from '../methods/schedule/types'; export const transformBackfillParamToAdHocRun = ( param: ScheduleBackfillParam, rule: RuleDomain, + actions: DenormalizedAction[], spaceId: string ): AdHocRunSO => { const schedule = calculateSchedule(param.start, rule.schedule.interval, param.end); + const shouldRunActions = param.runActions !== undefined ? param.runActions : true; + return { apiKeyId: Buffer.from(rule.apiKey!, 'base64').toString().split(':')[0], apiKeyToUse: rule.apiKey!, @@ -32,6 +36,7 @@ export const transformBackfillParamToAdHocRun = ( params: rule.params, apiKeyOwner: rule.apiKeyOwner, apiKeyCreatedByUser: rule.apiKeyCreatedByUser, + actions: shouldRunActions ? actions : [], consumer: rule.consumer, enabled: rule.enabled, schedule: rule.schedule, diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts index 771f5a4db34b9..8bfd0be4dff65 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts @@ -22,6 +22,8 @@ import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { TaskRunnerFactory } from '../task_runner'; import { TaskPriority } from '@kbn/task-manager-plugin/server'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { RawRule, RawRuleAction } from '../types'; const logger = loggingSystemMock.create().get(); const taskManagerSetup = taskManagerMock.createSetup(); @@ -29,11 +31,13 @@ const taskManagerStart = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const auditLogger = auditLoggerMock.create(); +const actionsClient = actionsClientMock.create(); function getMockData(overwrites: Record = {}): ScheduleBackfillParam { return { ruleId: '1', start: '2023-11-16T08:00:00.000Z', + runActions: true, ...overwrites, }; } @@ -98,11 +102,14 @@ function getMockAdHocRunAttributes({ ruleId, overwrites, omitApiKey = false, + actions = [], }: { ruleId?: string; overwrites?: Record; omitApiKey?: boolean; + actions?: RawRule['actions']; } = {}): AdHocRunSO { + // @ts-expect-error return { ...(omitApiKey ? {} : { apiKeyId: '123', apiKeyToUse: 'MTIzOmFiYw==' }), createdAt: '2024-01-30T00:00:00.000Z', @@ -113,7 +120,7 @@ function getMockAdHocRunAttributes({ name: 'my rule name', tags: ['foo'], alertTypeId: 'myType', - // @ts-expect-error + actions, params: {}, apiKeyOwner: 'user', apiKeyCreatedByUser: false, @@ -189,6 +196,7 @@ const mockCreatePointInTimeFinderAsInternalUser = ( describe('BackfillClient', () => { let backfillClient: BackfillClient; + let isSystemAction: jest.Mock; beforeAll(() => { jest.useFakeTimers().setSystemTime(new Date('2024-01-30T00:00:00.000Z')); @@ -196,6 +204,9 @@ describe('BackfillClient', () => { beforeEach(() => { jest.resetAllMocks(); + isSystemAction = jest.fn().mockReturnValue(false); + actionsClient.isSystemAction.mockImplementation(isSystemAction); + ruleTypeRegistry.get.mockReturnValue(mockRuleType); backfillClient = new BackfillClient({ logger, @@ -271,6 +282,7 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); const result = await backfillClient.bulkQueue({ + actionsClient, auditLogger, params: mockData, rules: mockRules, @@ -332,11 +344,1163 @@ describe('BackfillClient', () => { ]); expect(result).toEqual( bulkCreateResult.saved_objects.map((so, index) => - transformAdHocRunToBackfillResult(so, bulkCreateParams?.[index]) + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) + ) + ); + }); + + test('should successfully schedule backfill for rule with actions when runActions=true', async () => { + actionsClient.getBulk.mockResolvedValue([ + { + id: '987', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isSystemAction: false, + isDeprecated: false, + }, + ]); + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + const rule1 = getMockRule({ + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + frequency: { notifyWhen: 'onActiveAlert', summary: true, throttle: null }, + }, + ], + }); + const rule2 = getMockRule({ + id: '2', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + ], + }); + const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + actions: [], + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '2', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + actionsClient, + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + const bulkCreateParams = [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes1, + rule: { + ...mockAttributes1.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + { + actionRef: 'action_1', + group: 'default', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + frequency: { notifyWhen: 'onActiveAlert', summary: true, throttle: null }, + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + { id: '987', name: 'action_1', type: 'action' }, + ], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes2, + rule: { + ...mockAttributes1.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + ], + }, + ]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(bulkCreateParams); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual( + bulkCreateResult.saved_objects.map((so, index) => + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) ) ); }); + test('should successfully schedule backfill for rule with rule-level notifyWhen field', async () => { + actionsClient.getBulk.mockResolvedValue([ + { + id: '987', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isSystemAction: false, + isDeprecated: false, + }, + ]); + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + const rule1 = getMockRule({ + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + }, + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + }, + ], + }); + const rule2 = getMockRule({ + id: '2', + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + }, + ], + }); + const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + actions: [], + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '2', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + actionsClient, + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + const bulkCreateParams = [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes1, + rule: { + ...mockAttributes1.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + { + actionRef: 'action_1', + group: 'default', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + { id: '987', name: 'action_1', type: 'action' }, + ], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes2, + rule: { + ...mockAttributes1.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + ], + }, + ]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(bulkCreateParams); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual( + bulkCreateResult.saved_objects.map((so, index) => + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) + ) + ); + }); + + test('should ignore actions for rule with actions when runActions=false', async () => { + const mockData = [ + getMockData({ runActions: false }), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z', runActions: false }), + ]; + const rule1 = getMockRule({ + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + frequency: { notifyWhen: 'onActiveAlert', summary: true, throttle: null }, + }, + ], + }); + const rule2 = getMockRule({ + id: '2', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + ], + }); + const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + actions: [], + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '2', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + actionsClient, + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + const bulkCreateParams = [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes1, + references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes2, + references: [{ id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + ]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(bulkCreateParams); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual( + bulkCreateResult.saved_objects.map((so, index) => + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) + ) + ); + }); + + test('should successfully schedule backfill for rule with system actions when runActions=true', async () => { + actionsClient.isSystemAction.mockReturnValueOnce(false); + actionsClient.isSystemAction.mockReturnValueOnce(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: '987', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isSystemAction: false, + isDeprecated: false, + }, + { + id: 'system_456', + actionTypeId: 'test.system', + name: 'System action: .cases', + config: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ]); + const mockData = [getMockData()]; + const rule = getMockRule({ + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + ], + systemActions: [ + { + id: 'system_456', + actionTypeId: 'test.system', + params: {}, + uuid: 'aaaaaa', + }, + ], + }); + const mockRules = [rule]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + actions: [], + }); + + const bulkCreateResult = { + saved_objects: [getBulkCreateParam('abc', '1', mockAttributes)], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + actionsClient, + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + const bulkCreateParams = [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes, + rule: { + ...mockAttributes.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + { + actionRef: 'system_action:system_456', + actionTypeId: 'test.system', + params: {}, + uuid: 'aaaaaa', + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + ], + }, + ]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(bulkCreateParams); + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + ]); + expect(result).toEqual( + bulkCreateResult.saved_objects.map((so, index) => + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) + ) + ); + }); + + test('should schedule backfill for rule with unsupported actions and return warning', async () => { + actionsClient.getBulk.mockResolvedValue([ + { + id: '987', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isSystemAction: false, + isDeprecated: false, + }, + ]); + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + const rule1 = getMockRule({ + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + }, + ], + }); + const rule2 = getMockRule({ + id: '2', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + }, + ], + }); + const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + actions: [], + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '2', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + actionsClient, + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + const bulkCreateParams = [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes1, + rule: { + ...mockAttributes1.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + ], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes2, + rule: { + ...mockAttributes1.rule, + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: {}, + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + } as RawRuleAction, + ], + }, + }, + references: [ + { id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }, + { id: '987', name: 'action_0', type: 'action' }, + ], + }, + ]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(bulkCreateParams); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual([ + { + ...transformAdHocRunToBackfillResult({ + adHocRunSO: bulkCreateResult.saved_objects[0], + isSystemAction, + originalSO: bulkCreateParams?.[0], + }), + warnings: [ + 'Rule has actions that are not supported for backfill. Those actions will be skipped.', + ], + }, + transformAdHocRunToBackfillResult({ + adHocRunSO: bulkCreateResult.saved_objects[1], + isSystemAction, + originalSO: bulkCreateParams?.[1], + }), + ]); + }); + + test('should schedule backfill for rule with unsupported rule-level notifyWhen field and return warning', async () => { + actionsClient.getBulk.mockResolvedValue([ + { + id: '987', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isSystemAction: false, + isDeprecated: false, + }, + ]); + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + const rule1 = getMockRule({ + notifyWhen: 'onThrottleInterval', + throttle: '12h', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: '123abc', + }, + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + uuid: 'xyz987', + }, + ], + }); + const rule2 = getMockRule({ + id: '2', + notifyWhen: 'onThrottleInterval', + throttle: '12h', + actions: [ + { + group: 'default', + id: '987', + actionTypeId: 'test', + params: {}, + }, + ], + }); + const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + actions: [], + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '2', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + actionsClient, + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + const bulkCreateParams = [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes1, + rule: { + ...mockAttributes1.rule, + actions: [], + }, + }, + references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + ...mockAttributes2, + rule: { + ...mockAttributes1.rule, + actions: [], + }, + }, + references: [{ id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + ]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(bulkCreateParams); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual([ + { + ...transformAdHocRunToBackfillResult({ + adHocRunSO: bulkCreateResult.saved_objects[0], + isSystemAction, + originalSO: bulkCreateParams?.[0], + }), + warnings: [ + 'Rule has actions that are not supported for backfill. Those actions will be skipped.', + ], + }, + { + ...transformAdHocRunToBackfillResult({ + adHocRunSO: bulkCreateResult.saved_objects[1], + isSystemAction, + originalSO: bulkCreateParams?.[1], + }), + warnings: [ + 'Rule has actions that are not supported for backfill. Those actions will be skipped.', + ], + }, + ]); + }); + test('should successfully create multiple backfill saved objects for a single rule', async () => { const mockData = [getMockData(), getMockData({ end: '2023-11-17T08:00:00.000Z' })]; const rule1 = getMockRule(); @@ -383,6 +1547,7 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); const result = await backfillClient.bulkQueue({ + actionsClient, auditLogger, params: mockData, rules: mockRules, @@ -442,7 +1607,11 @@ describe('BackfillClient', () => { ]); expect(result).toEqual( bulkCreateResult.saved_objects.map((so, index) => - transformAdHocRunToBackfillResult(so, bulkCreateParams?.[index]) + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) ) ); }); @@ -476,6 +1645,7 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); const result = await backfillClient.bulkQueue({ + actionsClient, auditLogger, params: mockData, rules: mockRules, @@ -504,7 +1674,7 @@ describe('BackfillClient', () => { message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', }); expect(logger.warn).toHaveBeenCalledWith( - `No rule found for ruleId 2 - not scheduling backfill for {\"ruleId\":\"2\",\"start\":\"2023-11-16T08:00:00.000Z\",\"end\":\"2023-11-17T08:00:00.000Z\"}` + `Error for ruleId 2 - not scheduling backfill for {\"ruleId\":\"2\",\"start\":\"2023-11-16T08:00:00.000Z\",\"runActions\":true,\"end\":\"2023-11-17T08:00:00.000Z\"}` ); expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ { @@ -516,7 +1686,11 @@ describe('BackfillClient', () => { ]); expect(result).toEqual([ ...bulkCreateResult.saved_objects.map((so, index) => - transformAdHocRunToBackfillResult(so, bulkCreateParams?.[0]) + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction, + originalSO: bulkCreateParams?.[index], + }) ), { error: { @@ -591,6 +1765,7 @@ describe('BackfillClient', () => { bulkCreateResult as SavedObjectsBulkResponse ); const result = await backfillClient.bulkQueue({ + actionsClient, auditLogger, params: mockData, rules: mockRules, @@ -739,6 +1914,7 @@ describe('BackfillClient', () => { ]; const result = await backfillClient.bulkQueue({ + actionsClient, auditLogger, params: mockData, rules: [], @@ -842,6 +2018,7 @@ describe('BackfillClient', () => { bulkCreateResult as SavedObjectsBulkResponse ); const result = await backfillClient.bulkQueue({ + actionsClient, params: mockData, rules: mockRules, ruleTypeRegistry, diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts index 48b5e49c428c0..bb4b130e29b31 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts @@ -23,9 +23,9 @@ import { TaskPriority, } from '@kbn/task-manager-plugin/server'; import { isNumber } from 'lodash'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { ScheduleBackfillError, - ScheduleBackfillParam, ScheduleBackfillParams, ScheduleBackfillResult, ScheduleBackfillResults, @@ -42,6 +42,8 @@ import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_o import { TaskRunnerFactory } from '../task_runner'; import { RuleTypeRegistry } from '../types'; import { createBackfillError } from './lib'; +import { denormalizeActions } from '../rules_client/lib/denormalize_actions'; +import { DenormalizedAction, NormalizedAlertActionWithGeneratedValues } from '../rules_client'; export const BACKFILL_TASK_TYPE = 'ad_hoc_run-backfill'; @@ -53,6 +55,7 @@ interface ConstructorOpts { } interface BulkQueueOpts { + actionsClient: ActionsClient; auditLogger?: AuditLogger; params: ScheduleBackfillParams; rules: RuleDomain[]; @@ -86,6 +89,7 @@ export class BackfillClient { } public async bulkQueue({ + actionsClient, auditLogger, params, rules, @@ -117,10 +121,16 @@ export class BackfillClient { */ const soToCreateIndexOrErrorMap: Map = new Map(); + const rulesWithUnsupportedActions = new Set(); - params.forEach((param: ScheduleBackfillParam, ndx: number) => { + for (let ndx = 0; ndx < params.length; ndx++) { + const param = params[ndx]; // For this schedule request, look up the rule or return error - const { rule, error } = getRuleOrError(param.ruleId, rules, ruleTypeRegistry); + const { rule, error } = getRuleOrError({ + ruleId: param.ruleId, + rules, + ruleTypeRegistry, + }); if (rule) { // keep track of index of this request in the adHocSOsToCreate array soToCreateIndexOrErrorMap.set(ndx, adHocSOsToCreate.length); @@ -129,22 +139,31 @@ export class BackfillClient { name: `rule`, type: RULE_SAVED_OBJECT_TYPE, }; + + const { actions, hasUnsupportedActions, references } = await extractRuleActions({ + actionsClient, + rule, + runActions: param.runActions, + }); + + if (hasUnsupportedActions) { + rulesWithUnsupportedActions.add(ndx); + } + adHocSOsToCreate.push({ type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - attributes: transformBackfillParamToAdHocRun(param, rule, spaceId), - references: [reference], + attributes: transformBackfillParamToAdHocRun(param, rule, actions, spaceId), + references: [reference, ...references], }); } else if (error) { // keep track of the error encountered for this request by index so // we can return it in order soToCreateIndexOrErrorMap.set(ndx, error); this.logger.warn( - `No rule found for ruleId ${param.ruleId} - not scheduling backfill for ${JSON.stringify( - param - )}` + `Error for ruleId ${param.ruleId} - not scheduling backfill for ${JSON.stringify(param)}` ); } - }); + } // Every request encountered an error, so short-circuit the logic here if (!adHocSOsToCreate.length) { @@ -175,7 +194,11 @@ export class BackfillClient { }) ); } - return transformAdHocRunToBackfillResult(so, adHocSOsToCreate?.[index]); + return transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction: (id: string) => actionsClient.isSystemAction(id), + originalSO: adHocSOsToCreate?.[index], + }); } ); @@ -202,7 +225,16 @@ export class BackfillClient { if (isNumber(indexOrError)) { // This number is the index of the response from the savedObjects bulkCreate function - return transformedResponse[indexOrError]; + const response = transformedResponse[indexOrError]; + if (rulesWithUnsupportedActions.has(indexOrError)) { + return { + ...response, + warnings: [ + `Rule has actions that are not supported for backfill. Those actions will be skipped.`, + ], + }; + } + return response; } else { // Return the error we encountered return indexOrError as ScheduleBackfillError; @@ -301,11 +333,16 @@ export class BackfillClient { } } -function getRuleOrError( - ruleId: string, - rules: RuleDomain[], - ruleTypeRegistry: RuleTypeRegistry -): { rule?: RuleDomain; error?: ScheduleBackfillError } { +interface GetRuleOrErrorOpts { + ruleId: string; + rules: RuleDomain[]; + ruleTypeRegistry: RuleTypeRegistry; +} + +function getRuleOrError({ ruleId, rules, ruleTypeRegistry }: GetRuleOrErrorOpts): { + rule?: RuleDomain; + error?: ScheduleBackfillError; +} { const rule = rules.find((r: RuleDomain) => r.id === ruleId); // if rule not found, return not found error @@ -345,3 +382,55 @@ function getRuleOrError( return { rule }; } + +interface ExtractRuleActions { + actionsClient: ActionsClient; + rule: RuleDomain; + runActions?: boolean; +} + +interface ExtractRuleActionsResult { + actions: DenormalizedAction[]; + hasUnsupportedActions: boolean; + references: SavedObjectReference[]; +} + +async function extractRuleActions({ + actionsClient, + rule, + runActions, +}: ExtractRuleActions): Promise { + // defauts to true if not specified + const shouldRunActions = runActions !== undefined ? runActions : true; + + if (!shouldRunActions) { + return { hasUnsupportedActions: false, actions: [], references: [] }; + } + + const ruleLevelNotifyWhen = rule.notifyWhen; + const normalizedActions = []; + for (const action of rule.actions) { + // if action level frequency is not defined and rule level notifyWhen is, set the action level frequency + if (!action.frequency && ruleLevelNotifyWhen) { + normalizedActions.push({ + ...action, + frequency: { notifyWhen: ruleLevelNotifyWhen, summary: false, throttle: null }, + }); + } else { + normalizedActions.push(action); + } + } + + const hasUnsupportedActions = normalizedActions.some( + (action) => action.frequency?.notifyWhen !== 'onActiveAlert' + ); + + const allActions = [ + ...normalizedActions.filter((action) => action.frequency?.notifyWhen === 'onActiveAlert'), + ...(rule.systemActions ?? []), + ] as NormalizedAlertActionWithGeneratedValues[]; + + const { references, actions } = await denormalizeActions(actionsClient, allActions); + + return { hasUnsupportedActions, actions, references }; +} diff --git a/x-pack/platform/plugins/shared/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts b/x-pack/platform/plugins/shared/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts index be03f45749c5d..ca4bbe91cd41f 100644 --- a/x-pack/platform/plugins/shared/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts +++ b/x-pack/platform/plugins/shared/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { RawRule } from '../../../types'; import { RuleDomain } from '../../../application/rule/types'; import { AdHocRunStatus } from '../../../../common/constants'; @@ -23,10 +24,11 @@ export interface AdHocRunSchedule extends Record { // the backfill job was scheduled. if there are updates to the rule configuration // after the backfill is scheduled, they will not be reflected during the backfill run. type AdHocRunSORule = Pick< - RuleDomain, + RawRule, | 'name' | 'tags' | 'alertTypeId' + | 'actions' | 'params' | 'apiKeyOwner' | 'apiKeyCreatedByUser' @@ -43,7 +45,7 @@ type AdHocRunSORule = Pick< // This is the rule information after loaded from persistence with the // rule ID injected from the SO references array -type AdHocRunRule = AdHocRunSORule & Pick; +type AdHocRunRule = Omit & Pick; export interface AdHocRunSO extends Record { apiKeyId: string; diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts index a7177b2489740..3c70c6fd314d4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts @@ -17,6 +17,7 @@ import { invalidateApiKeysAndDeletePendingApiKeySavedObject, runInvalidate, } from './task'; +import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server/constants/saved_objects'; let fakeTimer: sinon.SinonFakeTimers; const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); @@ -92,17 +93,24 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, page: 1, per_page: 10, aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [], - }, + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, }, }); @@ -166,13 +174,114 @@ describe('Invalidate API Keys Task', () => { }); }); - test('should get decrypted api key pending invalidation saved object when some api keys are still in use', async () => { + test('should get decrypted api key pending invalidation saved object when some api keys are still in use by AD_HOC_RUN_SAVED_OBJECT_TYPE', async () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject1 ); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '2', apiKeyId: 'xyz!==!' }], + apiKeyIdsToExclude: [{ id: '1', apiKeyId: 'abcd====!' }], + }); + }); + + test('should get decrypted api key pending invalidation saved object when some api keys are still in use by ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, @@ -299,6 +408,15 @@ describe('Invalidate API Keys Task', () => { per_page: 10, // missing aggregations }); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); const result = await getApiKeyIdsToInvalidate({ apiKeySOsPendingInvalidation: { @@ -547,19 +665,28 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, page: 1, - per_page: 0, + per_page: 10, aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [], - }, + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, }, }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ invalidated_api_keys: ['1', '2'], previously_invalidated_api_keys: [], @@ -576,7 +703,7 @@ describe('Invalidate API Keys Task', () => { }); expect(result).toEqual(2); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { type: API_KEY_PENDING_INVALIDATION_TYPE, filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, @@ -610,6 +737,20 @@ describe('Invalidate API Keys Task', () => { }, }, }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ ids: ['abcd====!', 'xyz!==!'], @@ -628,7 +769,7 @@ describe('Invalidate API Keys Task', () => { expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); }); - test('should succeed when there are API keys to invalidate and API keys to exclude', async () => { + test('should succeed when there are API keys to invalidate and API keys to exclude (AD_HOC_RUN_SAVED_OBJECT_TYPE using apiKeyId)', async () => { internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [ { @@ -656,6 +797,8 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, @@ -669,6 +812,18 @@ describe('Invalidate API Keys Task', () => { }, }, }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ invalidated_api_keys: ['1'], previously_invalidated_api_keys: [], @@ -685,7 +840,7 @@ describe('Invalidate API Keys Task', () => { }); expect(result).toEqual(1); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { type: API_KEY_PENDING_INVALIDATION_TYPE, filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, @@ -719,6 +874,152 @@ describe('Invalidate API Keys Task', () => { }, }, }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "1"`); + }); + + test('should succeed when there are API keys to invalidate and API keys to exclude (ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE using apiKeyId)', async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(1); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ ids: ['xyz!==!'], @@ -760,6 +1061,8 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, @@ -777,6 +1080,21 @@ describe('Invalidate API Keys Task', () => { }, }); + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 3 }], + }, + }, + }); + const result = await runInvalidate({ // @ts-expect-error config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, @@ -787,8 +1105,8 @@ describe('Invalidate API Keys Task', () => { }); expect(result).toEqual(0); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(2); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { type: API_KEY_PENDING_INVALIDATION_TYPE, filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, page: 1, @@ -807,7 +1125,7 @@ describe('Invalidate API Keys Task', () => { API_KEY_PENDING_INVALIDATION_TYPE, '2' ); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, perPage: 0, filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, @@ -821,6 +1139,20 @@ describe('Invalidate API Keys Task', () => { }, }, }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); }); @@ -844,19 +1176,29 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject1 ); + + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, page: 1, per_page: 0, aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [], - }, + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, }, }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ invalidated_api_keys: ['1'], previously_invalidated_api_keys: [], @@ -880,17 +1222,25 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, page: 1, per_page: 0, aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [], - }, + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, }, }); securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ @@ -909,7 +1259,7 @@ describe('Invalidate API Keys Task', () => { }); expect(result).toEqual(2); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(4); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(6); expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(2); expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); @@ -943,6 +1293,20 @@ describe('Invalidate API Keys Task', () => { }, }, }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(1, { ids: ['abcd====!'], }); @@ -954,7 +1318,7 @@ describe('Invalidate API Keys Task', () => { expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); // second iteration - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(4, { type: API_KEY_PENDING_INVALIDATION_TYPE, filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, page: 1, @@ -967,7 +1331,7 @@ describe('Invalidate API Keys Task', () => { API_KEY_PENDING_INVALIDATION_TYPE, '2' ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(4, { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(5, { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, perPage: 0, filter: `ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, @@ -981,6 +1345,20 @@ describe('Invalidate API Keys Task', () => { }, }, }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(6, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(2, { ids: ['xyz!==!'], }); @@ -1021,6 +1399,7 @@ describe('Invalidate API Keys Task', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( mockInvalidatePendingApiKeyObject2 ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId internalSavedObjectsRepository.find.mockResolvedValueOnce({ saved_objects: [], total: 2, @@ -1034,6 +1413,18 @@ describe('Invalidate API Keys Task', () => { }, }, }); + + // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ invalidated_api_keys: ['1'], previously_invalidated_api_keys: [], @@ -1057,7 +1448,7 @@ describe('Invalidate API Keys Task', () => { }); expect(result).toEqual(1); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(4); expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); @@ -1096,6 +1487,20 @@ describe('Invalidate API Keys Task', () => { }, }, }); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(1, { ids: ['xyz!==!'], }); @@ -1107,7 +1512,7 @@ describe('Invalidate API Keys Task', () => { expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); // second iteration - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(4, { type: API_KEY_PENDING_INVALIDATION_TYPE, filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:1"`, page: 1, diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts index 48eea48246c78..18364299b99a6 100644 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts +++ b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts @@ -22,6 +22,7 @@ import { AggregationsStringTermsBucketKeys, AggregationsTermsAggregateBase, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server/constants/saved_objects'; import { InvalidateAPIKeyResult } from '../rules_client'; import { AlertingConfig } from '../config'; import { timePeriodBeforeDate } from '../lib/get_cadence'; @@ -116,6 +117,7 @@ export function taskRunner( const savedObjectsClient = savedObjects.createInternalRepository([ API_KEY_PENDING_INVALIDATION_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE, + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ]); const encryptedSavedObjectsClient = encryptedSavedObjects.getClient({ includedHiddenTypes: [API_KEY_PENDING_INVALIDATION_TYPE], @@ -248,29 +250,14 @@ export async function getApiKeyIdsToInvalidate({ ); // Query saved objects index to see if any API keys are in use - const filter = `${apiKeyIds - .map(({ apiKeyId }) => `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.apiKeyId: "${apiKeyId}"`) - .join(' OR ')}`; - const { aggregations } = await savedObjectsClient.find< - AdHocRunSO, - { apiKeyId: AggregationsTermsAggregateBase } - >({ - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - filter, - perPage: 0, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.apiKeyId`, - size: PAGE_SIZE, - }, - }, - }, - }); + const apiKeyIdStrings = apiKeyIds.map(({ apiKeyId }) => apiKeyId); + let apiKeyIdsInUseBuckets: AggregationsStringTermsBucketKeys[] = []; - const apiKeyIdsInUseBuckets: AggregationsStringTermsBucketKeys[] = - (aggregations?.apiKeyId?.buckets as AggregationsStringTermsBucketKeys[]) ?? []; + for (const soType of [AD_HOC_RUN_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE]) { + apiKeyIdsInUseBuckets = apiKeyIdsInUseBuckets.concat( + await queryForApiKeysInUse(apiKeyIdStrings, soType, savedObjectsClient) + ); + } const apiKeyIdsToInvalidate: ApiKeyIdAndSOId[] = []; const apiKeyIdsToExclude: ApiKeyIdAndSOId[] = []; @@ -335,3 +322,33 @@ export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ logger.debug(`Total invalidated API keys "${totalInvalidated}"`); return totalInvalidated; } + +async function queryForApiKeysInUse( + apiKeyIds: string[], + savedObjectType: string, + savedObjectsClient: SavedObjectsClientContract +): Promise { + const filter = `${apiKeyIds + .map((apiKeyId) => `${savedObjectType}.attributes.apiKeyId: "${apiKeyId}"`) + .join(' OR ')}`; + + const { aggregations } = await savedObjectsClient.find< + AdHocRunSO, + { apiKeyId: AggregationsTermsAggregateBase } + >({ + type: savedObjectType, + filter, + perPage: 0, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `${savedObjectType}.attributes.apiKeyId`, + size: PAGE_SIZE, + }, + }, + }, + }); + + return (aggregations?.apiKeyId?.buckets as AggregationsStringTermsBucketKeys[]) ?? []; +} diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts index a8a0a210c8817..47d8e7c8f69aa 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts @@ -46,6 +46,7 @@ describe('findBackfillRoute', () => { tags: ['foo'], alertTypeId: 'myType', params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', @@ -73,6 +74,7 @@ describe('findBackfillRoute', () => { tags: ['foo'], alertTypeId: 'myType', params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts index 79ac8df1ed4b5..6b7aafbc14e86 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts @@ -35,6 +35,7 @@ describe('getBackfillRoute', () => { tags: ['foo'], alertTypeId: 'myType', params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts index c08cca6dfe683..670f86671ff8b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts @@ -44,6 +44,7 @@ describe('scheduleBackfillRoute', () => { tags: ['foo'], alertTypeId: 'myType', params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', @@ -71,6 +72,7 @@ describe('scheduleBackfillRoute', () => { tags: ['foo'], alertTypeId: 'myType', params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts index 170d85c4f862b..83e8a6e82c5ca 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts @@ -10,4 +10,9 @@ import { ScheduleBackfillRequestBodyV1 } from '../../../../../../../common/route import { ScheduleBackfillParams } from '../../../../../../application/backfill/methods/schedule/types'; export const transformRequest = (request: ScheduleBackfillRequestBodyV1): ScheduleBackfillParams => - request.map(({ rule_id, start, end }) => ({ ruleId: rule_id, start, end })); + request.map(({ rule_id, start, end, run_actions }) => ({ + ruleId: rule_id, + start, + end, + runActions: run_actions, + })); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts index 88582bea15b92..fe4a0c96e6d4f 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts @@ -19,6 +19,7 @@ describe('transformBackfillToBackfillResponse', () => { tags: ['foo'], alertTypeId: 'myType', params: {}, + actions: [], apiKeyOwner: 'user', apiKeyCreatedByUser: false, consumer: 'myApp', @@ -49,6 +50,7 @@ describe('transformBackfillToBackfillResponse', () => { name: 'my rule name', tags: ['foo'], rule_type_id: 'myType', + actions: [], params: {}, api_key_owner: 'user', api_key_created_by_user: false, diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/denormalize_actions.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/denormalize_actions.ts index 3cd1113a13628..ed27fe651ea11 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/denormalize_actions.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/denormalize_actions.ts @@ -5,25 +5,21 @@ * 2.0. */ import { SavedObjectReference } from '@kbn/core/server'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { preconfiguredConnectorActionRefPrefix, systemConnectorActionRefPrefix, } from '../common/constants'; -import { - DenormalizedAction, - NormalizedAlertActionWithGeneratedValues, - RulesClientContext, -} from '../types'; +import { DenormalizedAction, NormalizedAlertActionWithGeneratedValues } from '../types'; export async function denormalizeActions( - context: RulesClientContext, + actionsClient: ActionsClient, alertActions: NormalizedAlertActionWithGeneratedValues[] ): Promise<{ actions: DenormalizedAction[]; references: SavedObjectReference[] }> { const references: SavedObjectReference[] = []; const actions: DenormalizedAction[] = []; if (alertActions.length) { - const actionsClient = await context.getActionsClient(); const actionIds = [...new Set(alertActions.map((alertAction) => alertAction.id))]; const actionResults = await actionsClient.getBulk({ diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/extract_references.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/extract_references.ts index 9dfca0897ca08..6a131e9ee9179 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/extract_references.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/extract_references.ts @@ -26,7 +26,11 @@ export async function extractReferences< params: ExtractedParams; references: SavedObjectReference[]; }> { - const { references: actionReferences, actions } = await denormalizeActions(context, ruleActions); + const actionsClient = await context.getActionsClient(); + const { references: actionReferences, actions } = await denormalizeActions( + actionsClient, + ruleActions + ); // Extracts any references using configured reference extractor if available const extractedRefsAndParams = ruleType?.useSavedObjectReferences?.extractReferences diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts index c0eb08e6d582f..c77d7efd96864 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts @@ -18,7 +18,11 @@ import { InjectActionParamsOpts, injectActionParams } from '../inject_action_par import { RuleTypeParams, SanitizedRule, GetViewInAppRelativeUrlFnOpts } from '../../types'; import { RuleRunMetricsStore } from '../../lib/rule_run_metrics_store'; import { alertingEventLoggerMock } from '../../lib/alerting_event_logger/alerting_event_logger.mock'; -import { ConcreteTaskInstance, TaskErrorSource } from '@kbn/task-manager-plugin/server'; +import { + ConcreteTaskInstance, + TaskErrorSource, + TaskPriority, +} from '@kbn/task-manager-plugin/server'; import { RuleNotifyWhen } from '../../../common'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import sinon from 'sinon'; @@ -116,6 +120,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -125,6 +130,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -366,6 +372,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -375,6 +382,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -411,6 +419,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -420,6 +429,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My state-val goes here", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -769,6 +779,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "action-2", @@ -778,6 +789,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -1014,12 +1026,14 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "testActionTypeId", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", "params": Object { "message": "New: 1 Ongoing: 0 Recovered: 0", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -1161,12 +1175,14 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "testActionTypeId", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", "params": Object { "message": "New: 1 Ongoing: 0 Recovered: 0", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -1413,6 +1429,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -1422,6 +1439,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -1443,6 +1461,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "2", @@ -1452,6 +1471,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", @@ -1974,6 +1994,154 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "priority": undefined, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + "uuid": "111-111", + }, + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "priority": undefined, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + "uuid": "111-111", + }, + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 3 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "priority": undefined, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + "uuid": "111-111", + }, + ], + ] + `); + }); + + test('does schedule actions with priority when specified', async () => { + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, + priority: TaskPriority.Low, + rule: { + ...defaultSchedulerContext.rule, + actions: [ + { + ...defaultSchedulerContext.rule.actions[0], + frequency: { + summary: false, + notifyWhen: RuleNotifyWhen.CHANGE, + throttle: null, + }, + }, + ], + }, + }) + ); + + await actionScheduler.run({ + activeCurrentAlerts: { + ...generateAlert({ + id: 1, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + ...generateAlert({ + id: 2, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + ...generateAlert({ + id: 3, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + }, + recoveredCurrentAlerts: {}, + }); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -1983,6 +2151,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": 1, "relatedSavedObjects": Array [ Object { "id": "1", @@ -2004,6 +2173,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -2013,6 +2183,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": 1, "relatedSavedObjects": Array [ Object { "id": "1", @@ -2034,6 +2205,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": "test", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -2043,6 +2215,7 @@ describe('Action Scheduler', () => { "foo": true, "stateVal": "My goes here", }, + "priority": 1, "relatedSavedObjects": Array [ Object { "id": "1", @@ -2523,6 +2696,7 @@ describe('Action Scheduler', () => { Object { "actionTypeId": ".test-system-action", "apiKey": "MTIzOmFiYw==", + "apiKeyId": undefined, "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", @@ -2530,6 +2704,7 @@ describe('Action Scheduler', () => { "foo": "bar", "myParams": "test", }, + "priority": undefined, "relatedSavedObjects": Array [ Object { "id": "1", diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/build_rule_url.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/build_rule_url.ts index 3df27a512c7f9..3f828709ee940 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/build_rule_url.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/build_rule_url.ts @@ -6,16 +6,17 @@ */ import { Logger } from '@kbn/logging'; -import { RuleTypeParams, SanitizedRule } from '@kbn/alerting-types'; +import { RuleTypeParams } from '@kbn/alerting-types'; import { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; import { GetViewInAppRelativeUrlFn } from '../../../types'; +import { ActionSchedulerRule } from '../types'; interface BuildRuleUrlOpts { end?: number; getViewInAppRelativeUrl?: GetViewInAppRelativeUrlFn; kibanaBaseUrl: string | undefined; logger: Logger; - rule: SanitizedRule; + rule: ActionSchedulerRule; spaceId: string; start?: number; } diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts index 02ff513c5b639..8afcc95c11fff 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { TaskPriority } from '@kbn/task-manager-plugin/server'; import { RULE_SAVED_OBJECT_TYPE } from '../../..'; import { formatActionToEnqueue } from './format_action_to_enqueue'; @@ -65,6 +66,124 @@ describe('formatActionToEnqueue', () => { }); }); + test('should format a rule action with priority as expected', () => { + expect( + formatActionToEnqueue({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + apiKey: 'MTIzOmFiYw==', + executionId: '123', + ruleConsumer: 'rule-consumer', + ruleId: 'aaa', + ruleTypeId: 'security-rule', + spaceId: 'default', + priority: TaskPriority.Low, + }) + ).toEqual({ + id: '1', + uuid: '111-111', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + spaceId: 'default', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + source: { + source: { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + }, + type: 'SAVED_OBJECT', + }, + executionId: '123', + relatedSavedObjects: [ + { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + namespace: undefined, + typeId: 'security-rule', + }, + ], + actionTypeId: 'test', + priority: 1, + }); + }); + + test('should format a rule action with apiKeyId as expected', () => { + expect( + formatActionToEnqueue({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + apiKey: 'MTIzOmFiYw==', + apiKeyId: '4534623462346', + executionId: '123', + ruleConsumer: 'rule-consumer', + ruleId: 'aaa', + ruleTypeId: 'security-rule', + spaceId: 'default', + priority: TaskPriority.Low, + }) + ).toEqual({ + id: '1', + uuid: '111-111', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + spaceId: 'default', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + source: { + source: { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + }, + type: 'SAVED_OBJECT', + }, + executionId: '123', + relatedSavedObjects: [ + { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + namespace: undefined, + typeId: 'security-rule', + }, + ], + actionTypeId: 'test', + priority: 1, + apiKeyId: '4534623462346', + }); + }); + test('should format a rule action with null apiKey as expected', () => { expect( formatActionToEnqueue({ diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts index af560a19ab9be..763d653688085 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts @@ -7,12 +7,15 @@ import { RuleAction, RuleSystemAction } from '@kbn/alerting-types'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; import { RULE_SAVED_OBJECT_TYPE } from '../../..'; interface FormatActionToEnqueueOpts { action: RuleAction | RuleSystemAction; + apiKeyId?: string; apiKey: string | null; executionId: string; + priority?: TaskPriority; ruleConsumer: string; ruleId: string; ruleTypeId: string; @@ -20,7 +23,17 @@ interface FormatActionToEnqueueOpts { } export const formatActionToEnqueue = (opts: FormatActionToEnqueueOpts) => { - const { action, apiKey, executionId, ruleConsumer, ruleId, ruleTypeId, spaceId } = opts; + const { + action, + apiKey, + apiKeyId, + executionId, + priority, + ruleConsumer, + ruleId, + ruleTypeId, + spaceId, + } = opts; const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; return { @@ -29,6 +42,7 @@ export const formatActionToEnqueue = (opts: FormatActionToEnqueueOpts) => { params: action.params, spaceId, apiKey: apiKey ?? null, + apiKeyId, consumer: ruleConsumer, source: asSavedObjectExecutionSource({ id: ruleId, @@ -44,5 +58,6 @@ export const formatActionToEnqueue = (opts: FormatActionToEnqueueOpts) => { }, ], actionTypeId: action.actionTypeId, + priority, }; }; diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts index 62e501f6963af..91bfc0d4d0fa1 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts @@ -22,6 +22,7 @@ import { AlertInstanceContext, AlertInstanceState, } from '@kbn/alerting-state-types'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; const alertingEventLogger = alertingEventLoggerMock.create(); const actionsClient = actionsClientMock.create(); @@ -91,7 +92,13 @@ const getSchedulerContext = (params = {}) => { return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; }; -const getResult = (actionId: string, alertId: string, actionUuid: string) => ({ +const getResult = ( + actionId: string, + alertId: string, + actionUuid: string, + priority?: number, + apiKeyId?: string +) => ({ actionToEnqueue: { actionTypeId: 'test', apiKey: 'MTIzOmFiYw==', @@ -102,6 +109,8 @@ const getResult = (actionId: string, alertId: string, actionUuid: string) => ({ relatedSavedObjects: [{ id: 'rule-id-1', namespace: 'test1', type: 'alert', typeId: 'test' }], source: { source: { id: 'rule-id-1', type: 'alert' }, type: 'SAVED_OBJECT' }, spaceId: 'test1', + ...(priority ? { priority } : {}), + ...(apiKeyId ? { apiKeyId } : {}), }, actionToLog: { alertGroup: 'default', alertId, id: actionId, uuid: actionUuid, typeId: 'test' }, }); @@ -236,6 +245,64 @@ describe('Per-Alert Action Scheduler', () => { ]); }); + test('test should create action to schedule with priority if specified for each alert and each action', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + priority: TaskPriority.Low, + }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); + + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111', 1), + getResult('action-1', '2', '111-111', 1), + getResult('action-2', '1', '222-222', 1), + getResult('action-2', '2', '222-222', 1), + ]); + }); + + test('test should create action to schedule with apiKeyId if specified for each alert and each action', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + apiKeyId: '23534ybfsdsnsdf', + }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); + + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111', undefined, '23534ybfsdsnsdf'), + getResult('action-1', '2', '111-111', undefined, '23534ybfsdsnsdf'), + getResult('action-2', '1', '222-222', undefined, '23534ybfsdsnsdf'), + getResult('action-2', '2', '222-222', undefined, '23534ybfsdsnsdf'), + ]); + }); + test('should skip creating actions to schedule when alert has maintenance window', async () => { // 2 per-alert actions * 2 alerts = 4 actions to schedule // but alert 1 has maintenance window, so only actions for alert 2 should be scheduled diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts index 28b35d885b3d2..0ad9422b7b69d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts @@ -240,7 +240,9 @@ export class PerAlertActionScheduler< actionToEnqueue: formatActionToEnqueue({ action: actionToRun, apiKey: this.context.apiKey, + apiKeyId: this.context.apiKeyId, executionId: this.context.executionId, + priority: this.context.priority, ruleConsumer: this.context.ruleConsumer, ruleId: this.context.rule.id, ruleTypeId: this.context.ruleType.id, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts index cb19cb781ae3e..87356cdbacdaa 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts @@ -28,6 +28,7 @@ import { } from '@kbn/task-manager-plugin/server/task_running/errors'; import { CombinedSummarizedAlerts } from '../../../types'; import { ActionsCompletion } from '@kbn/alerting-state-types'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; const alertingEventLogger = alertingEventLoggerMock.create(); const actionsClient = actionsClientMock.create(); @@ -97,7 +98,13 @@ const getSchedulerContext = (params = {}) => { return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; }; -const getResult = (actionId: string, actionUuid: string, summary: CombinedSummarizedAlerts) => ({ +const getResult = ( + actionId: string, + actionUuid: string, + summary: CombinedSummarizedAlerts, + priority?: number, + apiKeyId?: string +) => ({ actionToEnqueue: { actionTypeId: 'test', apiKey: 'MTIzOmFiYw==', @@ -108,6 +115,8 @@ const getResult = (actionId: string, actionUuid: string, summary: CombinedSummar relatedSavedObjects: [{ id: 'rule-id-1', namespace: 'test1', type: 'alert', typeId: 'test' }], source: { source: { id: 'rule-id-1', type: 'alert' }, type: 'SAVED_OBJECT' }, spaceId: 'test1', + ...(priority && { priority }), + ...(apiKeyId && { apiKeyId }), }, actionToLog: { alertSummary: { @@ -261,6 +270,108 @@ describe('Summary Action Scheduler', () => { ]); }); + test('should create action to schedule with priority if specified for summary action when summary action is per rule run', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const throttledSummaryActions = {}; + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + priority: TaskPriority.Low, + }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); + + expect(throttledSummaryActions).toEqual({}); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([ + getResult('action-2', '222-222', finalSummary, 1), + getResult('action-3', '333-333', finalSummary, 1), + ]); + }); + + test('should create action to schedule with apiKeyId if specified for summary action when summary action is per rule run', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const throttledSummaryActions = {}; + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + apiKeyId: '24534wy3wydfbs', + }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); + + expect(throttledSummaryActions).toEqual({}); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([ + getResult('action-2', '222-222', finalSummary, undefined, '24534wy3wydfbs'), + getResult('action-3', '333-333', finalSummary, undefined, '24534wy3wydfbs'), + ]); + }); + test('should create actions to schedule for summary action when summary action has alertsFilter', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts index db53f15be2180..a606819f93af8 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts @@ -206,7 +206,9 @@ export class SummaryActionScheduler< actionToEnqueue: formatActionToEnqueue({ action: actionToRun, apiKey: this.context.apiKey, + apiKeyId: this.context.apiKeyId, executionId: this.context.executionId, + priority: this.context.priority, ruleConsumer: this.context.ruleConsumer, ruleId: this.context.rule.id, ruleTypeId: this.context.ruleType.id, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts index 71a7584c7280b..153af5f2051e9 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts @@ -27,6 +27,7 @@ import { } from '@kbn/task-manager-plugin/server/task_running/errors'; import { CombinedSummarizedAlerts } from '../../../types'; import { schema } from '@kbn/config-schema'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; const alertingEventLogger = alertingEventLoggerMock.create(); const actionsClient = actionsClientMock.create(); @@ -68,7 +69,13 @@ const getSchedulerContext = (params = {}) => { return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; }; -const getResult = (actionId: string, actionUuid: string, summary: CombinedSummarizedAlerts) => ({ +const getResult = ( + actionId: string, + actionUuid: string, + summary: CombinedSummarizedAlerts, + priority?: number, + apiKeyId?: string +) => ({ actionToEnqueue: { actionTypeId: '.test-system-action', apiKey: 'MTIzOmFiYw==', @@ -79,6 +86,8 @@ const getResult = (actionId: string, actionUuid: string, summary: CombinedSummar relatedSavedObjects: [{ id: 'rule-id-1', namespace: 'test1', type: 'alert', typeId: 'test' }], source: { source: { id: 'rule-id-1', type: 'alert' }, type: 'SAVED_OBJECT' }, spaceId: 'test1', + ...(priority && { priority }), + ...(apiKeyId && { apiKeyId }), }, actionToLog: { alertSummary: { @@ -183,6 +192,82 @@ describe('System Action Scheduler', () => { expect(results).toEqual([getResult('system-action-1', 'xxx-xxx', finalSummary)]); }); + test('should create actions to schedule with priority if specified for each system action', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SystemActionScheduler({ + ...getSchedulerContext(), + priority: TaskPriority.Low, + }); + const results = await scheduler.getActionsToSchedule({}); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('.test-system-action')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([getResult('system-action-1', 'xxx-xxx', finalSummary, 1)]); + }); + + test('should create actions to schedule with apiKeyId if specified for each system action', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SystemActionScheduler({ + ...getSchedulerContext(), + apiKeyId: '464tfbwer5q43h', + }); + const results = await scheduler.getActionsToSchedule({}); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('.test-system-action')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([ + getResult('system-action-1', 'xxx-xxx', finalSummary, undefined, '464tfbwer5q43h'), + ]); + }); + test('should remove new alerts from summary if suppressed by maintenance window', async () => { const newAlertWithMaintenanceWindow = generateAlert({ id: 1, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts index 0c5cceb0f0a52..01be6e4202ec8 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts @@ -156,7 +156,9 @@ export class SystemActionScheduler< actionToEnqueue: formatActionToEnqueue({ action: actionToRun, apiKey: this.context.apiKey, + apiKeyId: this.context.apiKeyId, executionId: this.context.executionId, + priority: this.context.priority, ruleConsumer: this.context.ruleConsumer, ruleId: this.context.rule.id, ruleTypeId: this.context.ruleType.id, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/types.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/types.ts index 02b9647f91866..d14c871aa4f61 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/action_scheduler/types.ts @@ -9,6 +9,7 @@ import type { Logger } from '@kbn/core/server'; import { PublicMethodsOf } from '@kbn/utility-types'; import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; import { ExecuteOptions as EnqueueExecutionOptions } from '@kbn/actions-plugin/server/create_execute_function'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; import { IAlertsClient } from '../../alerts_client/types'; import { Alert } from '../../alert'; import { @@ -31,6 +32,10 @@ import { } from '../../lib/alerting_event_logger/alerting_event_logger'; import { RuleTaskInstance, TaskRunnerContext } from '../types'; +export type ActionSchedulerRule = Omit< + SanitizedRule, + 'executionStatus' +>; export interface ActionSchedulerOptions< Params extends RuleTypeParams, ExtractedParams extends RuleTypeParams, @@ -53,10 +58,11 @@ export interface ActionSchedulerOptions< >; logger: Logger; alertingEventLogger: PublicMethodsOf; - rule: SanitizedRule; + rule: ActionSchedulerRule; taskRunnerContext: TaskRunnerContext; taskInstance: RuleTaskInstance; ruleRunMetricsStore: RuleRunMetricsStore; + apiKeyId?: string; apiKey: RawRule['apiKey']; ruleConsumer: string; executionId: string; @@ -64,6 +70,7 @@ export interface ActionSchedulerOptions< previousStartedAt: Date | null; actionsClient: PublicMethodsOf; alertsClient: IAlertsClient; + priority?: TaskPriority; } export type Executable< diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts index ae6467d0dcbf8..87bc14d496db8 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; -import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { actionsClientMock, actionsMock } from '@kbn/actions-plugin/server/mocks'; import { SavedObject } from '@kbn/core/server'; import { elasticsearchServiceMock, @@ -25,7 +25,7 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s import { IEventLogger } from '@kbn/event-log-plugin/server'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; import { SharePluginStart } from '@kbn/share-plugin/server'; -import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { ConcreteTaskInstance, TaskPriority, TaskStatus } from '@kbn/task-manager-plugin/server'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; import { AdHocTaskRunner } from './ad_hoc_task_runner'; import { TaskRunnerContext } from './types'; @@ -39,7 +39,7 @@ import { import { AdHocRunSchedule, AdHocRunSO } from '../data/ad_hoc_run/types'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; import { adHocRunStatus } from '../../common/constants'; -import { DATE_1970, ruleType } from './fixtures'; +import { DATE_1970, generateEnqueueFunctionInput, mockAAD, ruleType } from './fixtures'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; import { alertsMock } from '../mocks'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; @@ -93,6 +93,8 @@ import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { rulesSettingsServiceMock } from '../rules_settings/rules_settings_service.mock'; import { maintenanceWindowsServiceMock } from './maintenance_windows/maintenance_windows_service.mock'; +import { alertsClientMock } from '../alerts_client/alerts_client.mock'; +import { alertsServiceMock } from '../alerts_service/alerts_service.mock'; const UUID = '5f6aa57d-3e22-484e-bae8-cbed868f4d28'; @@ -121,7 +123,7 @@ type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { executionContext: ReturnType; }; const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - +const mockAlertsService = alertsServiceMock.create(); const alertingEventLogger = alertingEventLoggerMock.create(); const elasticsearchAndSOAvailability$ = of(true); const alertsService = new AlertsService({ @@ -133,6 +135,8 @@ const alertsService = new AlertsService({ elasticsearchAndSOAvailability$, isServerless: false, }); +const alertsClient = alertsClientMock.create(); +const actionsClient = actionsClientMock.create(); const backfillClient = backfillClientMock.create(); const dataPlugin = dataPluginMock.createStartContract(); const dataViewsMock = { @@ -280,7 +284,7 @@ describe('Ad Hoc Task Runner', () => { name: 'test', tags: [], alertTypeId: 'siem.queryRule', - // @ts-expect-error + actions: [], params: { author: [], description: 'test', @@ -367,10 +371,22 @@ describe('Ad Hoc Task Runner', () => { taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => fn() ); + taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( + actionsClient + ); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( + (_, __, params) => params + ); + maintenanceWindowsService.getMaintenanceWindows.mockResolvedValue({ + maintenanceWindows: [], + maintenanceWindowsWithoutScopedQueryIds: [], + }); ruleTypeRegistry.get.mockReturnValue(ruleTypeWithAlerts); ruleTypeWithAlerts.executor.mockResolvedValue({ state: {} }); mockValidateRuleTypeParams.mockReturnValue(mockedAdHocRunSO.attributes.rule.params); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(mockedAdHocRunSO); + actionsClient.bulkEnqueueExecution.mockResolvedValue({ errors: false, items: [] }); }); afterAll(() => fakeTimer.restore()); @@ -541,6 +557,127 @@ describe('Ad Hoc Task Runner', () => { expect(logger.error).not.toHaveBeenCalled(); }); + test('should schedule actions for rule with actions', async () => { + const mockedAdHocRunSOWithActions = { + ...mockedAdHocRunSO, + attributes: { + ...mockedAdHocRunSO.attributes, + rule: { + ...mockedAdHocRunSO.attributes.rule, + id: '1', + actions: [ + { + uuid: '123abc', + group: 'default', + actionRef: 'action_0', + actionTypeId: 'action', + params: { foo: true }, + frequency: { + notifyWhen: 'onActiveAlert', + summary: true, + throttle: null, + }, + }, + ], + }, + }, + references: [ + { type: RULE_SAVED_OBJECT_TYPE, name: 'rule', id: '1' }, + { id: '4', name: 'action_0', type: 'action' }, + ], + }; + alertsClient.getProcessedAlerts.mockReturnValue({}); + alertsClient.getSummarizedAlerts.mockResolvedValue({ + new: { count: 1, data: [mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }); + mockAlertsService.createAlertsClient.mockImplementation(() => alertsClient); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue( + mockedAdHocRunSOWithActions + ); + + ruleTypeWithAlerts.executor.mockImplementation( + async ({ + services: executorServices, + }: RuleExecutorOptions< + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string, + RuleAlertData + >) => { + executorServices.alertsClient?.report({ + id: '1', + actionGroup: 'default', + payload: { textField: 'foo', numericField: 27 }, + }); + return { state: {} }; + } + ); + + const taskRunner = new AdHocTaskRunner({ + context: { ...taskRunnerFactoryInitializerParams, alertsService: mockAlertsService }, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + + const runnerResult = await taskRunner.run(); + expect(runnerResult).toEqual({ state: {}, runAt: new Date('1970-01-01T00:00:00.000Z') }); + await taskRunner.cleanup(); + + // Verify all the expected calls were made before calling the rule executor + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalledWith( + mockedAdHocRunSO.attributes.rule.params, + ruleTypeWithAlerts.validate.params + ); + // @ts-ignore - accessing private variable + // should run the first entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(0); + + // Verify all the expected calls were made while calling the rule executor + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + schedule2, + schedule3, + schedule4, + schedule5, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith( + generateEnqueueFunctionInput({ + isBulk: true, + id: '4', + foo: true, + consumer: 'siem', + uuid: '123abc', + priority: TaskPriority.Low, + apiKeyId: 'apiKeyId', + }) + ); + }); + test('should run with the next pending schedule', async () => { ruleTypeWithAlerts.executor.mockImplementation( async ({ diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts index c9932820ff808..7eff959b05c2b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts @@ -20,7 +20,7 @@ import { TaskErrorSource, } from '@kbn/task-manager-plugin/server'; import { nanosToMillis } from '@kbn/event-log-plugin/common'; -import { CancellableTask, RunResult } from '@kbn/task-manager-plugin/server/task'; +import { CancellableTask, RunResult, TaskPriority } from '@kbn/task-manager-plugin/server/task'; import { AdHocRunStatus, adHocRunStatus } from '../../common/constants'; import { RuleRunnerErrorStackTraceLog, RuleTaskStateAndMetrics, TaskRunnerContext } from './types'; import { getExecutorServices } from './get_executor_services'; @@ -35,7 +35,7 @@ import { RuleTypeState, } from '../types'; import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; -import { AdHocRun, AdHocRunSchedule, AdHocRunSO } from '../data/ad_hoc_run/types'; +import { AdHocRun, AdHocRunSO, AdHocRunSchedule } from '../data/ad_hoc_run/types'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; import { RuleMonitoringService } from '../monitoring/rule_monitoring_service'; import { AdHocTaskRunningHandler } from './ad_hoc_task_running_handler'; @@ -52,6 +52,8 @@ import { import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { getEsErrorMessage } from '../lib/errors'; import { Result, isOk, asOk, asErr } from '../lib/result_type'; +import { ActionScheduler } from './action_scheduler'; +import { transformAdHocRunToAdHocRunData } from '../application/backfill/transforms/transform_ad_hoc_run_to_backfill_result'; interface ConstructorParams { context: TaskRunnerContext; @@ -173,7 +175,7 @@ export class AdHocTaskRunner implements CancellableTask { return ruleRunMetricsStore.getMetrics(); } - const { rule } = adHocRunData; + const { rule, apiKeyToUse, apiKeyId } = adHocRunData; const ruleType = this.ruleTypeRegistry.get(rule.alertTypeId); const ruleLabel = `${ruleType.id}:${rule.id}: '${rule.name}'`; @@ -254,6 +256,36 @@ export class AdHocTaskRunner implements CancellableTask { throw error; } + const actionScheduler = new ActionScheduler({ + rule: { + ...rule, + muteAll: false, + mutedInstanceIds: [], + createdAt: new Date(rule.createdAt), + updatedAt: new Date(rule.updatedAt), + }, + ruleType, + logger: this.logger, + taskRunnerContext: this.context, + taskInstance: this.taskInstance, + ruleRunMetricsStore, + apiKey: apiKeyToUse, + apiKeyId, + ruleConsumer: rule.consumer, + executionId: this.executionId, + ruleLabel, + previousStartedAt: null, + alertingEventLogger: this.alertingEventLogger, + actionsClient: await this.context.actionsPlugin.getActionsClientWithRequest(fakeRequest), + alertsClient, + priority: TaskPriority.Low, + }); + + await actionScheduler.run({ + activeCurrentAlerts: alertsClient.getProcessedAlerts('activeCurrent'), + recoveredCurrentAlerts: alertsClient.getProcessedAlerts('recoveredCurrent'), + }); + return ruleRunMetricsStore.getMetrics(); } @@ -299,14 +331,12 @@ export class AdHocTaskRunner implements CancellableTask { { namespace } ); - adHocRunData = { - id: adHocRunSO.id, - ...adHocRunSO.attributes, - rule: { - ...adHocRunSO.attributes.rule, - id: adHocRunSO.references[0].id, - }, - }; + adHocRunData = transformAdHocRunToAdHocRunData({ + adHocRunSO, + isSystemAction: (connectorId: string) => + this.context.actionsPlugin.isSystemActionConnector(connectorId), + omitGeneratedActionValues: false, + }); } catch (err) { const errorSource = SavedObjectsErrorHelpers.isNotFoundError(err) ? TaskErrorSource.USER diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/fixtures.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/fixtures.ts index d820f2690caeb..090c633b79602 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/fixtures.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/fixtures.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { TaskStatus } from '@kbn/task-manager-plugin/server'; +import { TaskPriority, TaskStatus } from '@kbn/task-manager-plugin/server'; import { SavedObject } from '@kbn/core/server'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { @@ -408,14 +408,20 @@ export const generateEnqueueFunctionInput = ({ isBulk = false, isResolved, foo, + consumer, actionTypeId, + priority, + apiKeyId, }: { uuid?: string; id: string; isBulk?: boolean; isResolved?: boolean; foo?: boolean; + consumer?: string; actionTypeId?: string; + priority?: TaskPriority; + apiKeyId?: string; }) => { const input = { actionTypeId: actionTypeId || 'action', @@ -427,7 +433,7 @@ export const generateEnqueueFunctionInput = ({ ...(isResolved !== undefined ? { isResolved } : {}), ...(foo !== undefined ? { foo } : {}), }, - consumer: 'bar', + consumer: consumer ?? 'bar', relatedSavedObjects: [ { id: '1', @@ -444,6 +450,8 @@ export const generateEnqueueFunctionInput = ({ type: 'SAVED_OBJECT', }, spaceId: 'default', + ...(priority && { priority }), + ...(apiKeyId && { apiKeyId }), }; return isBulk ? [input] : input; }; diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/transform_action_params.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/transform_action_params.ts index cb28d0aefcbd0..0b23910be3f3d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/transform_action_params.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/transform_action_params.ts @@ -14,8 +14,8 @@ import { AlertInstanceState, AlertInstanceContext, RuleTypeParams, - SanitizedRule, } from '../types'; +import { ActionSchedulerRule } from './action_scheduler/types'; export interface TransformActionParamsOptions { actionsPlugin: ActionsPluginStartContract; @@ -146,7 +146,7 @@ export function transformSummaryActionParams({ kibanaBaseUrl, }: { alerts: SummarizedAlertsWithAll; - rule: SanitizedRule; + rule: ActionSchedulerRule; ruleTypeId: string; actionsPlugin: ActionsPluginStartContract; actionId: string; diff --git a/x-pack/platform/plugins/shared/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/platform/plugins/shared/task_manager/server/queries/mark_available_tasks_as_claimed.ts index ec99c6ad5bf80..b428cb0dd889d 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -128,7 +128,9 @@ function getSortByPriority(definitions: TaskTypeDictionary): estypes.SortCombina // TODO: we could do this locally as well, but they may starve source: ` String taskType = doc['task.taskType'].value; - if (params.priority_map.containsKey(taskType)) { + if (doc['task.priority'].size() != 0) { + return doc['task.priority'].value; + } else if (params.priority_map.containsKey(taskType)) { return params.priority_map[taskType]; } else { return ${TaskPriority.Normal}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/task.ts b/x-pack/platform/plugins/shared/task_manager/server/task.ts index c52e9dfc52221..78cbea8cd0345 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -351,6 +351,11 @@ export interface TaskInstance { * Used to break up tasks so each Kibana node can claim tasks on a subset of the partitions */ partition?: number; + + /* + * Optionally override the priority defined in the task type for this specific task instance + */ + priority?: TaskPriority; } /** diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_claimers/strategy_update_by_query.test.ts b/x-pack/platform/plugins/shared/task_manager/server/task_claimers/strategy_update_by_query.test.ts index 2731dfed240a4..967fc4a9fce2e 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_claimers/strategy_update_by_query.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_claimers/strategy_update_by_query.test.ts @@ -356,7 +356,9 @@ describe('TaskClaiming', () => { }, source: ` String taskType = doc['task.taskType'].value; - if (params.priority_map.containsKey(taskType)) { + if (doc['task.priority'].size() != 0) { + return doc['task.priority'].value; + } else if (params.priority_map.containsKey(taskType)) { return params.priority_map[taskType]; } else { return 50; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts index d7fe17b4aa1f5..11b62918abe99 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts @@ -17,5 +17,6 @@ export default function backfillTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./delete_rule')); loadTestFile(require.resolve('./task_runner')); + loadTestFile(require.resolve('./task_runner_with_actions')); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts index d2fdb7f4365f4..051103e7804b9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts @@ -13,14 +13,9 @@ import { get } from 'lodash'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server/saved_objects'; import { asyncForEach } from '../../../../../../functional/services/transform/api'; import { UserAtSpaceScenarios } from '../../../../scenarios'; -import { - checkAAD, - getTestRuleData, - getUrlPrefix, - ObjectRemover, - TaskManagerDoc, -} from '../../../../../common/lib'; +import { checkAAD, getTestRuleData, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { TEST_ACTIONS_INDEX, getScheduledTask } from './test_utils'; // eslint-disable-next-line import/no-default-export export default function scheduleBackfillTests({ getService }: FtrProviderContext) { @@ -36,6 +31,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext await asyncForEach(backfillIds, async ({ id, spaceId }: { id: string; spaceId: string }) => { await supertest .delete(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/${id}`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo'); }); backfillIds = []; @@ -50,16 +46,13 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext return result._source; } - async function getScheduledTask(id: string): Promise { - const scheduledTask = await es.get({ - id: `task:${id}`, - index: '.kibana_task_manager', - }); - return scheduledTask._source!; - } - function getRule(overwrites = {}) { - return getTestRuleData({ + return { + name: 'abc', + enabled: true, + tags: ['foo'], + consumer: 'alertsFixture', + actions: [], rule_type_id: 'test.patternFiringAutoRecoverFalse', params: { pattern: { @@ -68,7 +61,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext }, schedule: { interval: '12h' }, ...overwrites, - }); + }; } function getLifecycleRule(overwrites = {}) { @@ -151,6 +144,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // schedule backfill for both rules as current user const response = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -283,7 +277,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext expect(adHocRunSO2.references).to.eql([{ id: ruleId2, name: 'rule', type: 'alert' }]); // check that the task was scheduled correctly - const taskRecord1 = await getScheduledTask(result[0].id); + const taskRecord1 = await getScheduledTask(es, result[0].id); expect(taskRecord1.type).to.eql('task'); expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord1.task.timeoutOverride).to.eql('10s'); @@ -292,7 +286,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext adHocRunParamsId: result[0].id, spaceId: space.id, }); - const taskRecord2 = await getScheduledTask(result[1].id); + const taskRecord2 = await getScheduledTask(es, result[1].id); expect(taskRecord2.type).to.eql('task'); expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord2.task.timeoutOverride).to.eql('10s'); @@ -339,6 +333,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // schedule 3 backfill jobs for rule as current user const response = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -514,7 +509,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext expect(adHocRunSO3.references).to.eql([{ id: ruleId, name: 'rule', type: 'alert' }]); // check that the task was scheduled correctly - const taskRecord1 = await getScheduledTask(result[0].id); + const taskRecord1 = await getScheduledTask(es, result[0].id); expect(taskRecord1.type).to.eql('task'); expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord1.task.timeoutOverride).to.eql('10s'); @@ -523,7 +518,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext adHocRunParamsId: result[0].id, spaceId: space.id, }); - const taskRecord2 = await getScheduledTask(result[1].id); + const taskRecord2 = await getScheduledTask(es, result[1].id); expect(taskRecord2.type).to.eql('task'); expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord2.task.timeoutOverride).to.eql('10s'); @@ -532,7 +527,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext adHocRunParamsId: result[1].id, spaceId: space.id, }); - const taskRecord3 = await getScheduledTask(result[2].id); + const taskRecord3 = await getScheduledTask(es, result[2].id); expect(taskRecord3.type).to.eql('task'); expect(taskRecord3.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord3.task.timeoutOverride).to.eql('10s'); @@ -572,6 +567,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // invalid start time const response1 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([{ rule_id: 'abc', start: 'foo' }]); @@ -579,6 +575,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // invalid end time const response2 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -593,6 +590,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext const time = moment().utc().startOf('day').subtract(7, 'days').toISOString(); const response3 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([{ rule_id: 'abc', start: time, end: time }]); @@ -600,6 +598,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // end time is before start time const response4 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -613,6 +612,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // start time is too far in the past const response5 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([{ rule_id: 'abc', start: '2023-04-30T00:00:00.000Z' }]); @@ -620,6 +620,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // start time is in the future const response6 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -629,6 +630,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // end time is in the future const response7 = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -715,6 +717,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // schedule backfill for non-existent rule const response = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -814,6 +817,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // schedule backfill as current user const response = await supertestWithoutAuth .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('x-elastic-internal-origin', 'xxx') .set('kbn-xsrf', 'foo') .auth(apiOptions.username, apiOptions.password) .send([ @@ -1019,7 +1023,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext expect(adHocRunSO3.references).to.eql([{ id: ruleId1, name: 'rule', type: 'alert' }]); // check that the task was scheduled correctly - const taskRecord1 = await getScheduledTask(result[0].id); + const taskRecord1 = await getScheduledTask(es, result[0].id); expect(taskRecord1.type).to.eql('task'); expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord1.task.timeoutOverride).to.eql('10s'); @@ -1028,7 +1032,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext adHocRunParamsId: result[0].id, spaceId: space.id, }); - const taskRecord2 = await getScheduledTask(result[1].id); + const taskRecord2 = await getScheduledTask(es, result[1].id); expect(taskRecord2.type).to.eql('task'); expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord2.task.timeoutOverride).to.eql('10s'); @@ -1037,7 +1041,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext adHocRunParamsId: result[1].id, spaceId: space.id, }); - const taskRecord3 = await getScheduledTask(result[5].id); + const taskRecord3 = await getScheduledTask(es, result[5].id); expect(taskRecord3.type).to.eql('task'); expect(taskRecord3.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord3.task.timeoutOverride).to.eql('10s'); @@ -1071,6 +1075,267 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle schedule request where rule has supported and unsupported actions', async () => { + // create a connector + const cresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'An index connector', + connector_type_id: '.index', + config: { + index: TEST_ACTIONS_INDEX, + refresh: true, + }, + secrets: {}, + }) + .expect(200); + const connectorId = cresponse.body.id; + objectRemover.add(apiOptions.spaceId, connectorId, 'connector', 'actions'); + + const start = moment().utc().startOf('day').subtract(14, 'days').toISOString(); + const end = moment().utc().startOf('day').subtract(5, 'days').toISOString(); + // create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getRule({ + actions: [ + { + group: 'default', + id: connectorId, + uuid: '111-111', + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notify_when: 'onActiveAlert', throttle: null, summary: true }, + }, + { + group: 'default', + id: connectorId, + uuid: '222-222', + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: true, + }, + }, + ], + }) + ) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getRule({ + actions: [ + { + group: 'default', + id: connectorId, + uuid: '333-333', + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notify_when: 'onActiveAlert', throttle: null, summary: false }, + }, + ], + }) + ) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // schedule backfill as current user + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .set('x-elastic-internal-origin', 'xxx') + .auth(apiOptions.username, apiOptions.password) + .send([ + { rule_id: ruleId1, start, end, run_actions: true }, + { rule_id: ruleId2, start, end, run_actions: true }, + ]); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find rules for any rule types`, + statusCode: 403, + }); + break; + // User has read privileges in this space + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body.error).to.eql('Forbidden'); + expect(response.body.message).to.match( + /Unauthorized by "alertsFixture" to scheduleBackfill "[^"]+" rule/ + ); + break; + // User doesn't have access to actions + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body.error).to.eql('Forbidden'); + expect(response.body.message).to.eql('Unauthorized to get actions'); + break; + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + const result = response.body; + + expect(result.length).to.eql(2); + + // successful schedule with warning for unsupported action + expect(typeof result[0].id).to.be('string'); + backfillIds.push({ id: result[0].id, spaceId: apiOptions.spaceId }); + expect(result[0].duration).to.eql('12h'); + expect(result[0].enabled).to.eql(true); + expect(result[0].start).to.eql(start); + expect(result[0].end).to.eql(end); + expect(result[0].status).to.eql('pending'); + expect(result[0].space_id).to.eql(space.id); + expect(typeof result[0].created_at).to.be('string'); + expect(result[0].rule.actions.length).to.eql(1); + expect(result[0].rule.actions[0]).to.eql({ + actionTypeId: '.index', + group: 'default', + id: connectorId, + uuid: '111-111', + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notifyWhen: 'onActiveAlert', throttle: null, summary: true }, + }); + expect(result[0].warnings).to.eql([ + `Rule has actions that are not supported for backfill. Those actions will be skipped.`, + ]); + + let currentStart = start; + result[0].schedule.forEach((sched: any) => { + expect(sched.interval).to.eql('12h'); + expect(sched.status).to.eql('pending'); + const runAt = moment(currentStart).add(12, 'hours').toISOString(); + expect(sched.run_at).to.eql(runAt); + currentStart = runAt; + }); + + // // successful schedule + expect(typeof result[1].id).to.be('string'); + backfillIds.push({ id: result[1].id, spaceId: apiOptions.spaceId }); + expect(result[1].duration).to.eql('12h'); + expect(result[1].enabled).to.eql(true); + expect(result[1].start).to.eql(start); + expect(result[1].end).to.eql(end); + expect(result[1].status).to.eql('pending'); + expect(result[1].space_id).to.eql(space.id); + expect(typeof result[1].created_at).to.be('string'); + expect(result[1].rule.actions.length).to.eql(1); + expect(result[1].rule.actions[0]).to.eql({ + actionTypeId: '.index', + group: 'default', + id: connectorId, + uuid: '333-333', + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notifyWhen: 'onActiveAlert', throttle: null, summary: false }, + }); + expect(result[1].warnings).to.be(undefined); + + currentStart = start; + result[1].schedule.forEach((sched: any) => { + expect(sched.interval).to.eql('12h'); + expect(sched.status).to.eql('pending'); + const runAt = moment(currentStart).add(12, 'hours').toISOString(); + expect(sched.run_at).to.eql(runAt); + currentStart = runAt; + }); + + // check that the expected ad hoc run SOs were created + const adHocRunSO1 = (await getAdHocRunSO(result[0].id)) as SavedObject; + const adHocRun1: AdHocRunSO = get(adHocRunSO1, 'ad_hoc_run_params')!; + const adHocRunSO2 = (await getAdHocRunSO(result[1].id)) as SavedObject; + const adHocRun2: AdHocRunSO = get(adHocRunSO2, 'ad_hoc_run_params')!; + + expect(typeof adHocRun1.apiKeyId).to.be('string'); + expect(typeof adHocRun1.apiKeyToUse).to.be('string'); + expect(typeof adHocRun1.createdAt).to.be('string'); + expect(adHocRun1.duration).to.eql('12h'); + expect(adHocRun1.enabled).to.eql(true); + expect(adHocRun1.start).to.eql(start); + expect(adHocRun1.end).to.eql(end); + expect(adHocRun1.status).to.eql('pending'); + expect(adHocRun1.spaceId).to.eql(space.id); + + currentStart = start; + adHocRun1.schedule.forEach((sched: any) => { + expect(sched.interval).to.eql('12h'); + expect(sched.status).to.eql('pending'); + const runAt = moment(currentStart).add(12, 'hours').toISOString(); + expect(sched.runAt).to.eql(runAt); + currentStart = runAt; + }); + + expect(typeof adHocRun2.apiKeyId).to.be('string'); + expect(typeof adHocRun2.apiKeyToUse).to.be('string'); + expect(typeof adHocRun2.createdAt).to.be('string'); + expect(adHocRun2.duration).to.eql('12h'); + expect(adHocRun2.enabled).to.eql(true); + expect(adHocRun2.start).to.eql(start); + expect(adHocRun2.end).to.eql(end); + expect(adHocRun2.status).to.eql('pending'); + expect(adHocRun2.spaceId).to.eql(space.id); + + currentStart = start; + adHocRun2.schedule.forEach((sched: any) => { + expect(sched.interval).to.eql('12h'); + expect(sched.status).to.eql('pending'); + const runAt = moment(currentStart).add(12, 'hours').toISOString(); + expect(sched.runAt).to.eql(runAt); + currentStart = runAt; + }); + + // check references are stored correctly + expect(adHocRunSO1.references).to.eql([ + { id: ruleId1, name: 'rule', type: 'alert' }, + { id: connectorId, name: 'action_0', type: 'action' }, + ]); + expect(adHocRunSO2.references).to.eql([ + { id: ruleId2, name: 'rule', type: 'alert' }, + { id: connectorId, name: 'action_0', type: 'action' }, + ]); + + // check that the task was scheduled correctly + const taskRecord1 = await getScheduledTask(es, result[0].id); + expect(taskRecord1.type).to.eql('task'); + expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord1.task.timeoutOverride).to.eql('10s'); + expect(taskRecord1.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord1.task.params)).to.eql({ + adHocRunParamsId: result[0].id, + spaceId: space.id, + }); + const taskRecord2 = await getScheduledTask(es, result[1].id); + expect(taskRecord2.type).to.eql('task'); + expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord2.task.timeoutOverride).to.eql('10s'); + expect(taskRecord2.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord2.task.params)).to.eql({ + adHocRunParamsId: result[1].id, + spaceId: space.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts index 2083a79c0d0f5..0bd46289c416f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts @@ -7,7 +7,6 @@ import expect from '@kbn/expect'; import moment from 'moment'; -import { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { SecurityAlert } from '@kbn/alerts-as-data-utils'; import { ALERT_LAST_DETECTED, @@ -33,27 +32,23 @@ import { RULE_SAVED_OBJECT_TYPE, } from '@kbn/alerting-plugin/server/saved_objects'; import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; -import { - createEsDocument, - DOCUMENT_REFERENCE, - DOCUMENT_SOURCE, -} from '../../../../../spaces_only/tests/alerting/create_test_data'; -import { asyncForEach } from '../../../../../../functional/services/transform/api'; +import { DOCUMENT_SOURCE } from '../../../../../spaces_only/tests/alerting/create_test_data'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { SuperuserAtSpace1 } from '../../../../scenarios'; +import { getTestRuleData, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; import { - getEventLog, - getTestRuleData, - getUrlPrefix, - ObjectRemover, - TaskManagerDoc, -} from '../../../../../common/lib'; + getScheduledTask, + indexTestDocs, + queryForAlertDocs, + searchScheduledTask, + testDocTimestamps, + waitForEventLogDocs, +} from './test_utils'; // eslint-disable-next-line import/no-default-export export default function createBackfillTaskRunnerTests({ getService }: FtrProviderContext) { const es = getService('es'); const retry = getService('retry'); - const log = getService('log'); const esTestIndexTool = new ESTestIndexTool(es, retry); const supertestWithoutAuth = getService('supertestWithoutAuth'); const supertest = getService('supertest'); @@ -61,31 +56,6 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide const alertsAsDataIndex = '.alerts-security.alerts-space1'; const timestampPattern = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; - const originalDocTimestamps = [ - // before first backfill run - moment().utc().subtract(14, 'days').toISOString(), - - // backfill execution set 1 - moment().utc().startOf('day').subtract(13, 'days').add(10, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(13, 'days').add(11, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(13, 'days').add(12, 'minutes').toISOString(), - - // backfill execution set 2 - moment().utc().startOf('day').subtract(12, 'days').add(20, 'minutes').toISOString(), - - // backfill execution set 3 - moment().utc().startOf('day').subtract(11, 'days').add(30, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(11, 'days').add(31, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(11, 'days').add(32, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(11, 'days').add(33, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(11, 'days').add(34, 'minutes').toISOString(), - - // backfill execution set 4 purposely left empty - - // after last backfill - moment().utc().startOf('day').subtract(9, 'days').add(40, 'minutes').toISOString(), - moment().utc().startOf('day').subtract(9, 'days').add(41, 'minutes').toISOString(), - ]; describe('ad hoc backfill task', () => { beforeEach(async () => { @@ -113,7 +83,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide const spaceId = SuperuserAtSpace1.space.id; // Index documents - await indexTestDocs(); + await indexTestDocs(es, esTestIndexTool); // Create siem.queryRule const response1 = await supertestWithoutAuth @@ -165,8 +135,8 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide const ruleId = response1.body.id; objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); - const start = moment(originalDocTimestamps[1]).utc().startOf('day').toISOString(); - const end = moment(originalDocTimestamps[11]).utc().startOf('day').toISOString(); + const start = moment(testDocTimestamps[1]).utc().startOf('day').toISOString(); + const end = moment(testDocTimestamps[11]).utc().startOf('day').toISOString(); // Schedule backfill for this rule const response2 = await supertestWithoutAuth @@ -176,9 +146,6 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide .send([{ rule_id: ruleId, start, end }]) .expect(200); - log.info(`originalDocTimestamps ${JSON.stringify(originalDocTimestamps)}`); - log.info(`scheduledBackfill ${JSON.stringify(response2.body)}`); - const scheduleResult = response2.body; expect(scheduleResult.length).to.eql(1); @@ -196,7 +163,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide const backfillId = scheduleResult[0].id; // check that the task was scheduled correctly - const taskRecord = await getScheduledTask(backfillId); + const taskRecord = await getScheduledTask(es, backfillId); expect(taskRecord.type).to.eql('task'); expect(taskRecord.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord.task.timeoutOverride).to.eql('5m'); @@ -208,6 +175,8 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide // get the execute-backfill events const events: IValidatedEvent[] = await waitForEventLogDocs( + retry, + getService, backfillId, spaceId, new Map([['execute-backfill', { equal: 4 }]]) @@ -284,7 +253,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide ); // query for alert docs - const alertDocs = await queryForAlertDocs(); + const alertDocs = await queryForAlertDocs(es, alertsAsDataIndex); expect(alertDocs.length).to.eql(9); // each alert doc should have these fields @@ -320,9 +289,9 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide ); } - expect(alertDocsBackfill1[0]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[1]); - expect(alertDocsBackfill1[1]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[2]); - expect(alertDocsBackfill1[2]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[3]); + expect(alertDocsBackfill1[0]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[1]); + expect(alertDocsBackfill1[1]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[2]); + expect(alertDocsBackfill1[2]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[3]); // backfill run 2 alerts const alertDocsBackfill2 = alertDocs.filter( @@ -342,7 +311,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide ); } - expect(alertDocsBackfill2[0]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[4]); + expect(alertDocsBackfill2[0]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[4]); // backfill run 3 alerts const alertDocsBackfill3 = alertDocs.filter( @@ -362,11 +331,11 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide ); } - expect(alertDocsBackfill3[0]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[5]); - expect(alertDocsBackfill3[1]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[6]); - expect(alertDocsBackfill3[2]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[7]); - expect(alertDocsBackfill3[3]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[8]); - expect(alertDocsBackfill3[4]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[9]); + expect(alertDocsBackfill3[0]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[5]); + expect(alertDocsBackfill3[1]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[6]); + expect(alertDocsBackfill3[2]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[7]); + expect(alertDocsBackfill3[3]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[8]); + expect(alertDocsBackfill3[4]._source![ALERT_ORIGINAL_TIME]).to.eql(testDocTimestamps[9]); // backfill run 4 alerts const alertDocsBackfill4 = alertDocs.filter( @@ -375,7 +344,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide expect(alertDocsBackfill4.length).to.eql(0); // task should have been deleted after backfill runs have finished - const numHits = await searchScheduledTask(backfillId); + const numHits = await searchScheduledTask(es, backfillId); expect(numHits).to.eql(0); }); @@ -425,7 +394,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide const backfillId = scheduleResult[0].id; // check that the task was scheduled correctly - const taskRecord = await getScheduledTask(backfillId); + const taskRecord = await getScheduledTask(es, backfillId); expect(taskRecord.type).to.eql('task'); expect(taskRecord.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord.task.timeoutOverride).to.eql('10s'); @@ -437,6 +406,8 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide // get the execute-timeout and execute-backfill events const events: IValidatedEvent[] = await waitForEventLogDocs( + retry, + getService, backfillId, spaceId, new Map([ @@ -515,7 +486,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide } // task should have been deleted after backfill runs have finished - const numHits = await searchScheduledTask(backfillId); + const numHits = await searchScheduledTask(es, backfillId); expect(numHits).to.eql(0); }); @@ -567,7 +538,7 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide const backfillId = scheduleResult[0].id; // check that the task was scheduled correctly - const taskRecord = await getScheduledTask(backfillId); + const taskRecord = await getScheduledTask(es, backfillId); expect(taskRecord.type).to.eql('task'); expect(taskRecord.task.taskType).to.eql('ad_hoc_run-backfill'); expect(taskRecord.task.timeoutOverride).to.eql('10s'); @@ -579,6 +550,8 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide // get the execute-backfill events const events: IValidatedEvent[] = await waitForEventLogDocs( + retry, + getService, backfillId, spaceId, new Map([['execute-backfill', { equal: 4 }]]) @@ -652,83 +625,8 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide ); // task should have been deleted after backfill runs have finished - const numHits = await searchScheduledTask(backfillId); + const numHits = await searchScheduledTask(es, backfillId); expect(numHits).to.eql(0); }); - - async function indexTestDocs() { - await asyncForEach(originalDocTimestamps, async (timestamp: string) => { - await createEsDocument(es, new Date(timestamp).valueOf(), 1, ES_TEST_INDEX_NAME); - }); - - await esTestIndexTool.waitForDocs( - DOCUMENT_SOURCE, - DOCUMENT_REFERENCE, - originalDocTimestamps.length - ); - } }); - - async function queryForAlertDocs(): Promise>> { - const searchResult = await es.search({ - index: alertsAsDataIndex, - body: { - sort: [{ [ALERT_ORIGINAL_TIME]: { order: 'asc' } }], - query: { match_all: {} }, - }, - }); - return searchResult.hits.hits as Array>; - } - - async function waitForEventLogDocs( - id: string, - spaceId: string, - actions: Map - ) { - return await retry.try(async () => { - return await getEventLog({ - getService, - spaceId, - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - id, - provider: 'alerting', - actions, - }); - }); - } - - async function getScheduledTask(id: string): Promise { - const scheduledTask = await es.get({ - id: `task:${id}`, - index: '.kibana_task_manager', - }); - return scheduledTask._source!; - } - - async function searchScheduledTask(id: string) { - const searchResult = await es.search({ - index: '.kibana_task_manager', - body: { - query: { - bool: { - must: [ - { - term: { - 'task.id': `task:${id}`, - }, - }, - { - terms: { - 'task.scope': ['alerting'], - }, - }, - ], - }, - }, - }, - }); - - // @ts-expect-error - return searchResult.hits.total.value; - } } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner_with_actions.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner_with_actions.ts new file mode 100644 index 0000000000000..f155f40a61f05 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner_with_actions.ts @@ -0,0 +1,267 @@ +/* + * 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 expect from '@kbn/expect'; +import moment from 'moment'; +import { ESTestIndexTool } from '@kbn/alerting-api-integration-helpers'; +import { asyncForEach } from '../../../../../../functional/services/transform/api'; +import { SuperuserAtSpace1 } from '../../../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + TEST_ACTIONS_INDEX, + indexTestDocs, + getSecurityRule, + testDocTimestamps, + waitForEventLogDocs, +} from './test_utils'; + +// eslint-disable-next-line import/no-default-export +export default function scheduleBackfillTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('ad hoc backfill with rule actions', () => { + const spaceId = SuperuserAtSpace1.space.id; + let backfillIds: string[] = []; + const objectRemover = new ObjectRemover(supertest); + let connectorId: string; + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + // Index documents + await indexTestDocs(es, esTestIndexTool); + + // create a connector + const cresponse = await supertest + .post(`${getUrlPrefix(spaceId)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send({ + name: 'An index connector', + connector_type_id: '.index', + config: { + index: TEST_ACTIONS_INDEX, + refresh: true, + }, + secrets: {}, + }) + .expect(200); + connectorId = cresponse.body.id; + objectRemover.add(spaceId, connectorId, 'connector', 'actions'); + }); + + afterEach(async () => { + await es.deleteByQuery({ + index: TEST_ACTIONS_INDEX, + query: { match_all: {} }, + conflicts: 'proceed', + }); + await asyncForEach(backfillIds, async (id: string) => { + await supertest + .delete(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/${id}`) + .set('kbn-xsrf', 'foo'); + }); + backfillIds = []; + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + }); + + it('should run summary actions for backfill jobs when run_actions=true', async () => { + // create a siem query rule with an action + const rresponse = await supertest + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send( + getSecurityRule({ + actions: [ + { + group: 'default', + id: connectorId, + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notify_when: 'onActiveAlert', throttle: null, summary: true }, + }, + ], + }) + ) + .expect(200); + + const ruleId = rresponse.body.id; + objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); + + const start = moment(testDocTimestamps[1]).utc().startOf('day').toISOString(); + const end = moment(testDocTimestamps[11]).utc().startOf('day').toISOString(); + + // schedule backfill for this rule + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([{ rule_id: ruleId, start, end, run_actions: true }]) + .expect(200); + + const scheduleResult = response.body; + expect(scheduleResult.length).to.eql(1); + expect(scheduleResult[0].schedule.length).to.eql(4); + + const backfillId = scheduleResult[0].id; + + // wait for backfills to run + await waitForEventLogDocs( + retry, + getService, + backfillId, + spaceId, + new Map([['execute-backfill', { equal: 4 }]]) + ); + + await retry.try(async () => { + // verify that the correct number of actions were executed + const actions = await es.search({ + index: TEST_ACTIONS_INDEX, + body: { query: { match_all: {} } }, + }); + + // 3 backfill executions resulted in alerts so 3 notifications should have + // been generated. + expect(actions.hits.hits.length).to.eql(3); + }); + }); + + it('should run per-alert actions for backfill jobs when run_actions=true', async () => { + // create a siem query rule with an action + const rresponse = await supertest + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send( + getSecurityRule({ + actions: [ + { + group: 'default', + id: connectorId, + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notify_when: 'onActiveAlert', throttle: null, summary: false }, + }, + ], + }) + ) + .expect(200); + + const ruleId = rresponse.body.id; + objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); + + const start = moment(testDocTimestamps[1]).utc().startOf('day').toISOString(); + const end = moment(testDocTimestamps[11]).utc().startOf('day').toISOString(); + + // schedule backfill for this rule + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([{ rule_id: ruleId, start, end, run_actions: true }]) + .expect(200); + + const scheduleResult = response.body; + expect(scheduleResult.length).to.eql(1); + expect(scheduleResult[0].schedule.length).to.eql(4); + + const backfillId = scheduleResult[0].id; + + // wait for backfills to run + await waitForEventLogDocs( + retry, + getService, + backfillId, + spaceId, + new Map([['execute-backfill', { equal: 4 }]]) + ); + + await retry.try(async () => { + // verify that the correct number of actions were executed + const actions = await es.search({ + index: TEST_ACTIONS_INDEX, + body: { query: { match_all: {} } }, + }); + + // 3 backfill executions resulted in 9 alerts so 9 notifications should have + // been generated. + expect(actions.hits.hits.length).to.eql(9); + }); + }); + + it('should not run actions for backfill jobs when run_actions=false', async () => { + // create a siem query rule with an action + const rresponse = await supertest + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send( + getSecurityRule({ + actions: [ + { + group: 'default', + id: connectorId, + params: { documents: [{ alertUuid: '{{alert.uuid}}' }] }, + frequency: { notify_when: 'onActiveAlert', throttle: null, summary: true }, + }, + ], + }) + ) + .expect(200); + + const ruleId = rresponse.body.id; + objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); + + const start = moment(testDocTimestamps[1]).utc().startOf('day').toISOString(); + const end = moment(testDocTimestamps[11]).utc().startOf('day').toISOString(); + + // schedule backfill for this rule + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([{ rule_id: ruleId, start, end, run_actions: false }]) + .expect(200); + + const scheduleResult = response.body; + expect(scheduleResult.length).to.eql(1); + expect(scheduleResult[0].schedule.length).to.eql(4); + expect(scheduleResult[0].rule.actions).to.eql([]); + + const backfillId = scheduleResult[0].id; + + // wait for backfills to run + await waitForEventLogDocs( + retry, + getService, + backfillId, + spaceId, + new Map([['execute-backfill', { equal: 4 }]]) + ); + + // since we want to check that no actions were executed and they might take a bit to run + // add a small delay + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // verify that the correct number of actions were executed + const actions = await es.search({ + index: TEST_ACTIONS_INDEX, + body: { query: { match_all: {} } }, + }); + + // no actions should be generated + expect(actions.hits.hits.length).to.eql(0); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/test_utils.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/test_utils.ts new file mode 100644 index 0000000000000..ad4fde6b39ec6 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/test_utils.ts @@ -0,0 +1,166 @@ +/* + * 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 { asyncForEach } from '@kbn/std'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; +import type { Client } from '@elastic/elasticsearch'; +import moment from 'moment'; +import { FtrProviderContext, RetryService } from '@kbn/ftr-common-functional-services'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server/saved_objects'; +import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { TaskManagerDoc, getEventLog } from '../../../../../common/lib'; +import { + DOCUMENT_REFERENCE, + DOCUMENT_SOURCE, + createEsDocument, +} from '../../../../../spaces_only/tests/alerting/create_test_data'; + +export const TEST_ACTIONS_INDEX = 'alerting-backfill-test-data'; + +export const testDocTimestamps = [ + // before first backfill run + moment().utc().subtract(14, 'days').toISOString(), + + // backfill execution set 1 + moment().utc().startOf('day').subtract(13, 'days').add(10, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(13, 'days').add(11, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(13, 'days').add(12, 'minutes').toISOString(), + + // backfill execution set 2 + moment().utc().startOf('day').subtract(12, 'days').add(20, 'minutes').toISOString(), + + // backfill execution set 3 + moment().utc().startOf('day').subtract(11, 'days').add(30, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(11, 'days').add(31, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(11, 'days').add(32, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(11, 'days').add(33, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(11, 'days').add(34, 'minutes').toISOString(), + + // backfill execution set 4 purposely left empty + + // after last backfill + moment().utc().startOf('day').subtract(9, 'days').add(40, 'minutes').toISOString(), + moment().utc().startOf('day').subtract(9, 'days').add(41, 'minutes').toISOString(), +]; + +export async function indexTestDocs(es: Client, esTestIndexTool: ESTestIndexTool) { + await asyncForEach(testDocTimestamps, async (timestamp: string) => { + await createEsDocument(es, new Date(timestamp).valueOf(), 1, ES_TEST_INDEX_NAME); + }); + + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, testDocTimestamps.length); +} + +export async function waitForEventLogDocs( + retry: RetryService, + getService: FtrProviderContext['getService'], + id: string, + spaceId: string, + actions: Map +) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + provider: 'alerting', + actions, + }); + }); +} + +export async function getScheduledTask(es: Client, id: string): Promise { + const scheduledTask = await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + return scheduledTask._source!; +} + +export async function queryForAlertDocs( + es: Client, + index: string +): Promise>> { + const searchResult = await es.search({ + index, + body: { + sort: [{ [ALERT_ORIGINAL_TIME]: { order: 'asc' } }], + query: { match_all: {} }, + }, + }); + return searchResult.hits.hits as Array>; +} + +export async function searchScheduledTask(es: Client, id: string) { + const searchResult = await es.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + term: { + 'task.id': `task:${id}`, + }, + }, + { + terms: { + 'task.scope': ['alerting'], + }, + }, + ], + }, + }, + }, + }); + + // @ts-expect-error + return searchResult.hits.total.value; +} + +export function getSecurityRule(overwrites = {}) { + return { + name: 'test siem query rule with actions', + rule_type_id: 'siem.queryRule', + consumer: 'siem', + enabled: true, + actions: [], + schedule: { interval: '24h' }, + params: { + author: [], + description: 'test', + falsePositives: [], + from: 'now-86460s', + ruleId: '31c54f10-9d3b-45a8-b064-b92e8c6fcbe7', + immutable: false, + license: '', + outputIndex: '', + meta: { from: '1m', kibana_siem_app_url: 'https://localhost:5601/app/security' }, + maxSignals: 20, + riskScore: 21, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', + type: 'query', + language: 'kuery', + index: [ES_TEST_INDEX_NAME], + query: `source:${DOCUMENT_SOURCE}`, + filters: [], + }, + ...overwrites, + }; +} From 1c7a823920c3d794dcb6ce30bbc1b075be40ac19 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Mon, 20 Jan 2025 11:11:01 -0400 Subject: [PATCH 2/6] [Discover] [ES|QL] Prevent redundant requests when loading Discover sessions and toggling chart visibility (#206699) ## Summary This PR prevents redundant Discover requests in ES|QL mode for the following scenarios: - Creating a new Discover session. - Saving the current Discover session. - Loading a saved Discover session. - Toggling the Unified Histogram chart visibility. It does so by addressing several underlying state related issues that were triggering the redundant requests: - Skipping the initial emission of `currentSuggestionContext` on Unified Histogram mount, which immediately triggered a second fetch. - Treating the Unified Histogram `table` prop the same as other props which affect Lens suggestions (data view, query, columns), and deferring updates to it until result fetching completes to avoid unnecessary suggestion updates. - Removing all auto-fetching behaviour from Unified Histogram and instead relying solely on the consumer to control when fetching should occur (including the initial fetch). Resolves #165192. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [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 - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Matthias Wilhelm --- .../layout/discover_histogram_layout.tsx | 24 +-- .../layout/use_discover_histogram.test.tsx | 31 +--- .../layout/use_discover_histogram.ts | 59 +++---- .../shared/unified_histogram/README.md | 38 +---- .../public/chart/chart.test.tsx | 4 +- .../unified_histogram/public/chart/chart.tsx | 158 +++++++++++++----- .../public/chart/histogram.test.tsx | 98 ++++------- .../public/chart/histogram.tsx | 124 ++------------ .../public/chart/hooks/use_fetch.test.ts | 35 ++++ .../public/chart/hooks/use_fetch.ts | 30 ++++ .../public/chart/hooks/use_lens_props.test.ts | 32 ++-- .../public/chart/hooks/use_lens_props.ts | 34 +++- .../public/chart/hooks/use_refetch.test.ts | 86 ---------- .../public/chart/hooks/use_refetch.ts | 145 ---------------- .../public/chart/hooks/use_total_hits.test.ts | 22 +-- .../public/chart/hooks/use_total_hits.ts | 8 +- .../public/container/container.test.tsx | 8 +- .../public/container/container.tsx | 15 +- .../public/layout/layout.tsx | 6 - .../shared/unified_histogram/public/mocks.ts | 2 +- .../shared/unified_histogram/public/types.ts | 8 +- .../apps/discover/group3/_request_counts.ts | 96 +++++------ 22 files changed, 387 insertions(+), 676 deletions(-) create mode 100644 src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.test.ts create mode 100644 src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.ts delete mode 100644 src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.test.ts delete mode 100644 src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.ts diff --git a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_histogram_layout.tsx b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_histogram_layout.tsx index 20afaf22373d5..65c92734eab92 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_histogram_layout.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_histogram_layout.tsx @@ -7,16 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public'; import { css } from '@emotion/react'; import useObservable from 'react-use/lib/useObservable'; -import { ESQL_TABLE_TYPE } from '@kbn/data-plugin/common'; -import type { Datatable } from '@kbn/expressions-plugin/common'; import { useDiscoverHistogram } from './use_discover_histogram'; import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content'; import { useAppStateSelector } from '../../state_management/discover_app_state_container'; -import { FetchStatus } from '../../../types'; import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps { @@ -44,7 +41,6 @@ export const DiscoverHistogramLayout = ({ hideChart, }); - const datatable = useObservable(dataState.data$.documents$); const renderCustomChartToggleActions = useCallback( () => React.isValidElement(panelsToggle) @@ -53,23 +49,6 @@ export const DiscoverHistogramLayout = ({ [panelsToggle] ); - const table: Datatable | undefined = useMemo(() => { - if ( - isEsqlMode && - datatable && - [FetchStatus.PARTIAL, FetchStatus.COMPLETE].includes(datatable.fetchStatus) - ) { - return { - type: 'datatable' as 'datatable', - rows: datatable.result!.map((r) => r.raw), - columns: datatable.esqlQueryColumns || [], - meta: { - type: ESQL_TABLE_TYPE, - }, - }; - } - }, [datatable, isEsqlMode]); - // Initialized when the first search has been requested or // when in ES|QL mode since search sessions are not supported if (!searchSessionId && !isEsqlMode) { @@ -81,7 +60,6 @@ export const DiscoverHistogramLayout = ({ {...unifiedHistogramProps} searchSessionId={searchSessionId} requestAdapter={dataState.inspectorAdapters.requests} - table={table} container={container} css={histogramLayoutCss} renderCustomChartToggleActions={renderCustomChartToggleActions} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.test.tsx index 863a162c63a93..72f9af5c40fa1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.test.tsx @@ -161,7 +161,6 @@ describe('useDiscoverHistogram', () => { const { hook } = await renderUseDiscoverHistogram(); const params = hook.result.current.getCreationOptions(); expect(params?.localStorageKeyPrefix).toBe('discover'); - expect(params?.disableAutoFetching).toBe(true); expect(Object.keys(params?.initialState ?? {})).toEqual([ 'chartHidden', 'timeInterval', @@ -398,8 +397,8 @@ describe('useDiscoverHistogram', () => { }); }); - describe('refetching', () => { - it('should call refetch when savedSearchFetch$ is triggered', async () => { + describe('fetching', () => { + it('should call fetch when savedSearchFetch$ is triggered', async () => { const savedSearchFetch$ = new Subject(); const stateContainer = getStateContainer(); stateContainer.dataState.fetchChart$ = savedSearchFetch$; @@ -408,33 +407,11 @@ describe('useDiscoverHistogram', () => { act(() => { hook.result.current.ref(api); }); - expect(api.refetch).toHaveBeenCalled(); + expect(api.fetch).toHaveBeenCalled(); act(() => { savedSearchFetch$.next(); }); - expect(api.refetch).toHaveBeenCalledTimes(2); - }); - - it('should skip the next refetch when hideChart changes from true to false', async () => { - const savedSearchFetch$ = new Subject(); - const stateContainer = getStateContainer(); - stateContainer.dataState.fetchChart$ = savedSearchFetch$; - const { hook, initialProps } = await renderUseDiscoverHistogram({ stateContainer }); - const api = createMockUnifiedHistogramApi(); - act(() => { - hook.result.current.ref(api); - }); - expect(api.refetch).toHaveBeenCalled(); - act(() => { - hook.rerender({ ...initialProps, hideChart: true }); - }); - act(() => { - hook.rerender({ ...initialProps, hideChart: false }); - }); - act(() => { - savedSearchFetch$.next(); - }); - expect(api.refetch).toHaveBeenCalledTimes(1); + expect(api.fetch).toHaveBeenCalledTimes(2); }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.ts b/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.ts index 8f0a0fdf33dc1..43b017337e25e 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/layout/use_discover_histogram.ts @@ -19,7 +19,7 @@ import { UnifiedHistogramVisContext, } from '@kbn/unified-histogram-plugin/public'; import { isEqual } from 'lodash'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { debounceTime, distinctUntilChanged, @@ -28,13 +28,15 @@ import { merge, Observable, pairwise, + skip, startWith, } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import type { RequestAdapter } from '@kbn/inspector-plugin/common'; -import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import type { SavedSearch } from '@kbn/saved-search-plugin/common'; -import { Filter } from '@kbn/es-query'; +import { Filter, isOfAggregateQueryType } from '@kbn/es-query'; +import { ESQL_TABLE_TYPE } from '@kbn/data-plugin/common'; import { useDiscoverCustomization } from '../../../../customizations'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { FetchStatus } from '../../../types'; @@ -240,6 +242,7 @@ export const useDiscoverHistogram = ({ dataView: esqlDataView, query: esqlQuery, columns: esqlColumns, + table, } = useObservable(esqlFetchComplete$, initialEsqlProps); useEffect(() => { @@ -249,9 +252,7 @@ export const useDiscoverHistogram = ({ } const fetchStart = stateContainer.dataState.fetchChart$.subscribe(() => { - if (!skipRefetch.current) { - setIsSuggestionLoading(true); - } + setIsSuggestionLoading(true); }); const fetchComplete = esqlFetchComplete$.subscribe(() => { setIsSuggestionLoading(false); @@ -267,18 +268,6 @@ export const useDiscoverHistogram = ({ * Data fetching */ - const skipRefetch = useRef(); - - // Skip refetching when showing the chart since Lens will - // automatically fetch when the chart is shown - useEffect(() => { - if (skipRefetch.current === undefined) { - skipRefetch.current = false; - } else { - skipRefetch.current = !hideChart; - } - }, [hideChart]); - // Handle unified histogram refetching useEffect(() => { if (!unifiedHistogram) { @@ -304,18 +293,14 @@ export const useDiscoverHistogram = ({ } const subscription = fetchChart$.subscribe((source) => { - if (!skipRefetch.current) { - if (source === 'discover') addLog('Unified Histogram - Discover refetch'); - if (source === 'lens') addLog('Unified Histogram - Lens suggestion refetch'); - unifiedHistogram.refetch(); - } - - skipRefetch.current = false; + if (source === 'discover') addLog('Unified Histogram - Discover refetch'); + if (source === 'lens') addLog('Unified Histogram - Lens suggestion refetch'); + unifiedHistogram.fetch(); }); - // triggering the initial request for total hits hook - if (!isEsqlMode && !skipRefetch.current) { - unifiedHistogram.refetch(); + // triggering the initial chart request + if (!isEsqlMode) { + unifiedHistogram.fetch(); } return () => { @@ -397,6 +382,7 @@ export const useDiscoverHistogram = ({ timeRange: timeRangeMemoized, relativeTimeRange, columns: isEsqlMode ? esqlColumns : undefined, + table: isEsqlMode ? table : undefined, onFilter: histogramCustomization?.onFilter, onBrushEnd: histogramCustomization?.onBrushEnd, withDefaultActions: histogramCustomization?.withDefaultActions, @@ -495,7 +481,10 @@ const createTotalHitsObservable = (state$?: Observable) = const createCurrentSuggestionObservable = (state$: Observable) => { return state$.pipe( map((state) => state.currentSuggestionContext), - distinctUntilChanged(isEqual) + distinctUntilChanged(isEqual), + // Skip the first emission since it's the + // initial state and doesn't need a refetch + skip(1) ); }; @@ -507,11 +496,23 @@ function getUnifiedHistogramPropsForEsql({ savedSearch: SavedSearch; }) { const columns = documentsValue?.esqlQueryColumns || EMPTY_ESQL_COLUMNS; + const query = savedSearch.searchSource.getField('query'); + const isEsqlMode = isOfAggregateQueryType(query); + const table: Datatable | undefined = + isEsqlMode && documentsValue?.result + ? { + type: 'datatable', + rows: documentsValue.result.map((r) => r.raw), + columns, + meta: { type: ESQL_TABLE_TYPE }, + } + : undefined; const nextProps = { dataView: savedSearch.searchSource.getField('index')!, query: savedSearch.searchSource.getField('query'), columns, + table, }; addLog('[UnifiedHistogram] delayed next props for ES|QL', nextProps); diff --git a/src/platform/plugins/shared/unified_histogram/README.md b/src/platform/plugins/shared/unified_histogram/README.md index 4509f28a7a61e..177cd6524d0fa 100755 --- a/src/platform/plugins/shared/unified_histogram/README.md +++ b/src/platform/plugins/shared/unified_histogram/README.md @@ -7,30 +7,15 @@ It manages its own state and data fetching, and can easily be dropped into pages ```tsx // Import the container component -import { - UnifiedHistogramContainer, -} from '@kbn/unified-histogram-plugin/public'; +import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public'; // Import modules required for your application -import { - useServices, - useResizeRef, - useRequestParams, - MyLayout, - MyButton, -} from './my-modules'; +import { useServices, useResizeRef, useRequestParams, MyLayout, MyButton } from './my-modules'; const services = useServices(); const resizeRef = useResizeRef(); -const { - dataView, - query, - filters, - timeRange, - relativeTimeRange, - searchSessionId, - requestAdapter, -} = useRequestParams(); +const { dataView, query, filters, timeRange, relativeTimeRange, searchSessionId, requestAdapter } = + useRequestParams(); return ( ({ // Optionally provide a local storage key prefix to save parts of the state, // such as the chart hidden state and top panel height, to local storage localStorageKeyPrefix: 'myApp', - // By default Unified Histogram will automatically refetch based on certain - // state changes, such as chart hidden and request params, but this can be - // disabled in favour of manual fetching if preferred. Note that an initial - // request is always triggered when first initialized, and when the chart - // changes from hidden to visible, Lens will automatically trigger a refetch - // regardless of what this property is set to - disableAutoFetching: true, // Customize the initial state in order to override the defaults initialState: { chartHidden, breakdownField }, }), [...]); -// Manually refetch if disableAutoFetching is true -useManualRefetch(() => { - unifiedHistogram?.refetch(); +// Trigger a fetch, must be called on init to render the chart +useFetch(() => { + unifiedHistogram?.fetch(); }); // Update the Unified Histogram state when our state params change diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/chart.test.tsx b/src/platform/plugins/shared/unified_histogram/public/chart/chart.test.tsx index e127e1a4ab41c..abd72dd502cfe 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/chart.test.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/chart/chart.test.tsx @@ -19,7 +19,7 @@ import type { ReactWrapper } from 'enzyme'; import { unifiedHistogramServicesMock } from '../__mocks__/services'; import { getLensVisMock } from '../__mocks__/lens_vis'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; -import { of } from 'rxjs'; +import { Subject, of } from 'rxjs'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { dataViewMock } from '../__mocks__/data_view'; import { BreakdownFieldSelector } from './breakdown_field_selector'; @@ -135,6 +135,7 @@ async function mountComponent({ withDefaultActions: undefined, isChartAvailable: checkChartAvailability({ chart, dataView, isPlainRecord }), renderCustomChartToggleActions: customToggle ? () => customToggle : undefined, + input$: new Subject(), }; let instance: ReactWrapper = {} as ReactWrapper; @@ -142,6 +143,7 @@ async function mountComponent({ instance = mountWithIntl(); // wait for initial async loading to complete await new Promise((r) => setTimeout(r, 0)); + props.input$?.next({ type: 'fetch' }); instance.update(); }); return instance; diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/chart.tsx b/src/platform/plugins/shared/unified_histogram/public/chart/chart.tsx index 164d1eb539e3c..0b3ee3a0d7395 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/chart.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/chart/chart.tsx @@ -18,11 +18,19 @@ import type { LensEmbeddableInput, LensEmbeddableOutput, } from '@kbn/lens-plugin/public'; -import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { + Datatable, + DatatableColumn, + DefaultInspectorAdapters, +} from '@kbn/expressions-plugin/common'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import type { TimeRange } from '@kbn/es-query'; +import { PublishingSubject } from '@kbn/presentation-publishing'; +import { RequestStatus } from '@kbn/inspector-plugin/public'; +import { IKibanaSearchResponse } from '@kbn/search-types'; +import { estypes } from '@elastic/elasticsearch'; import { Histogram } from './histogram'; -import type { +import { UnifiedHistogramSuggestionContext, UnifiedHistogramBreakdownContext, UnifiedHistogramChartContext, @@ -33,6 +41,7 @@ import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext, UnifiedHistogramServices, + UnifiedHistogramBucketInterval, } from '../types'; import { UnifiedHistogramSuggestionType } from '../types'; import { BreakdownFieldSelector } from './breakdown_field_selector'; @@ -41,11 +50,14 @@ import { useTotalHits } from './hooks/use_total_hits'; import { useChartStyles } from './hooks/use_chart_styles'; import { useChartActions } from './hooks/use_chart_actions'; import { ChartConfigPanel } from './chart_config_panel'; -import { useRefetch } from './hooks/use_refetch'; +import { useFetch } from './hooks/use_fetch'; import { useEditVisualization } from './hooks/use_edit_visualization'; import { LensVisService } from '../services/lens_vis_service'; import type { UseRequestParamsResult } from '../hooks/use_request_params'; import { removeTablesFromLensAttributes } from '../utils/lens_vis_from_table'; +import { useLensProps } from './hooks/use_lens_props'; +import { useStableCallback } from '../hooks/use_stable_callback'; +import { buildBucketInterval } from './utils/build_bucket_interval'; export interface ChartProps { abortController?: AbortController; @@ -64,7 +76,6 @@ export interface ChartProps { breakdown?: UnifiedHistogramBreakdownContext; renderCustomChartToggleActions?: () => ReactElement | undefined; appendHistogram?: ReactElement; - disableAutoFetching?: boolean; disableTriggers?: LensEmbeddableInput['disableTriggers']; disabledActions?: LensEmbeddableInput['disabledActions']; input$?: UnifiedHistogramInput$; @@ -99,7 +110,6 @@ export function Chart({ isPlainRecord, renderCustomChartToggleActions, appendHistogram, - disableAutoFetching, disableTriggers, disabledActions, input$: originalInput$, @@ -140,20 +150,9 @@ export function Chart({ const { filters, query, getTimeRange, updateTimeRange, relativeTimeRange } = requestParams; - const refetch$ = useRefetch({ - dataView, - request, - hits, - chart, - chartVisible, - breakdown, - filters, - query, - relativeTimeRange, - currentSuggestion, - disableAutoFetching, + const fetch$ = useFetch({ input$, - beforeRefetch: updateTimeRange, + beforeFetch: updateTimeRange, }); useTotalHits({ @@ -165,11 +164,67 @@ export function Chart({ filters, query, getTimeRange, - refetch$, + fetch$, onTotalHitsChange, isPlainRecord, }); + const [bucketInterval, setBucketInterval] = useState(); + const onLoad = useStableCallback( + ( + isLoading: boolean, + adapters: Partial | undefined, + dataLoadingSubject$?: PublishingSubject + ) => { + const lensRequest = adapters?.requests?.getRequests()[0]; + const requestFailed = lensRequest?.status === RequestStatus.ERROR; + const json = lensRequest?.response?.json as + | IKibanaSearchResponse + | undefined; + const response = json?.rawResponse; + + if (requestFailed) { + onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, undefined); + onChartLoad?.({ adapters: adapters ?? {} }); + return; + } + + const adapterTables = adapters?.tables?.tables; + const totalHits = computeTotalHits(hasLensSuggestions, adapterTables, isPlainRecord); + + if (response?._shards?.failed || response?.timed_out) { + onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, totalHits); + } else { + onTotalHitsChange?.( + isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete, + totalHits ?? hits?.total + ); + } + + if (response) { + const newBucketInterval = buildBucketInterval({ + data: services.data, + dataView, + timeInterval: chart?.timeInterval, + timeRange: getTimeRange(), + response, + }); + + setBucketInterval(newBucketInterval); + } + + onChartLoad?.({ adapters: adapters ?? {}, dataLoading$: dataLoadingSubject$ }); + } + ); + + const lensPropsContext = useLensProps({ + request, + getTimeRange, + fetch$, + visContext, + onLoad, + }); + const { chartToolbarCss, histogramCss } = useChartStyles(chartVisible); const onSuggestionContextEdit = useCallback( @@ -356,26 +411,24 @@ export function Chart({ )} - + {lensPropsContext && ( + + )} {appendHistogram} @@ -405,3 +458,30 @@ export function Chart({ ); } + +const computeTotalHits = ( + hasLensSuggestions: boolean, + adapterTables: + | { + [key: string]: Datatable; + } + | undefined, + isPlainRecord?: boolean +) => { + if (isPlainRecord && hasLensSuggestions) { + return Object.values(adapterTables ?? {})?.[0]?.rows?.length; + } else if (isPlainRecord && !hasLensSuggestions) { + // ES|QL histogram case + const rows = Object.values(adapterTables ?? {})?.[0]?.rows; + if (!rows) { + return undefined; + } + let rowsCount = 0; + rows.forEach((r) => { + rowsCount += r.results; + }); + return rowsCount; + } else { + return adapterTables?.unifiedHistogram?.meta?.statistics?.totalCount; + } +}; diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/histogram.test.tsx b/src/platform/plugins/shared/unified_histogram/public/chart/histogram.test.tsx index 7bef5d4f85554..be213b08a1ac9 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/histogram.test.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/chart/histogram.test.tsx @@ -8,23 +8,17 @@ */ import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { Histogram } from './histogram'; +import { Histogram, HistogramProps } from './histogram'; import React from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { unifiedHistogramServicesMock } from '../__mocks__/services'; import { getLensVisMock } from '../__mocks__/lens_vis'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { createDefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; -import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../types'; +import { UnifiedHistogramInput$ } from '../types'; import { act } from 'react-dom/test-utils'; -import * as buildBucketInterval from './utils/build_bucket_interval'; -import * as useTimeRange from './hooks/use_time_range'; import { RequestStatus } from '@kbn/inspector-plugin/public'; -import { getLensProps } from './hooks/use_lens_props'; - -const mockBucketInterval = { description: '1 minute', scale: undefined, scaled: false }; -jest.spyOn(buildBucketInterval, 'buildBucketInterval').mockReturnValue(mockBucketInterval); -jest.spyOn(useTimeRange, 'useTimeRange'); +import { getLensProps, useLensProps } from './hooks/use_lens_props'; const getMockLensAttributes = async () => { const query = { @@ -44,46 +38,48 @@ const getMockLensAttributes = async () => { ).visContext; }; +type CombinedProps = Omit & + Parameters[0]; + async function mountComponent(isPlainRecord = false, hasLensSuggestions = false) { const services = unifiedHistogramServicesMock; services.data.query.timefilter.timefilter.getAbsoluteTime = () => { return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; }; - const timefilterUpdateHandler = jest.fn(); - const refetch$: UnifiedHistogramInput$ = new Subject(); - const props = { + const fetch$: UnifiedHistogramInput$ = new Subject(); + const props: CombinedProps = { services: unifiedHistogramServicesMock, request: { searchSessionId: '123', }, - hasLensSuggestions, isPlainRecord, - hits: { - status: UnifiedHistogramFetchStatus.loading, - total: undefined, - }, chart: { hidden: false, timeInterval: 'auto', }, - timefilterUpdateHandler, dataView: dataViewWithTimefieldMock, getTimeRange: () => ({ from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590', }), - refetch$, + fetch$, visContext: (await getMockLensAttributes())!, - onTotalHitsChange: jest.fn(), - onChartLoad: jest.fn(), + onLoad: jest.fn(), withDefaultActions: undefined, }; - - return { - props, - component: mountWithIntl(), + const Wrapper = (wrapperProps: CombinedProps) => { + const lensPropsContext = useLensProps(wrapperProps); + return lensPropsContext ? : null; }; + + const component = mountWithIntl(); + + act(() => { + fetch$?.next({ type: 'fetch' }); + }); + + return { props, fetch$, component: component.update() }; } describe('Histogram', () => { @@ -92,13 +88,13 @@ describe('Histogram', () => { expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBe(true); }); - it('should only update lens.EmbeddableComponent props when refetch$ is triggered', async () => { - const { component, props } = await mountComponent(); + it('should only update lens.EmbeddableComponent props when fetch$ is triggered', async () => { + const { component, props, fetch$ } = await mountComponent(); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; expect(component.find(embeddable).exists()).toBe(true); let lensProps = component.find(embeddable).props(); const originalProps = getLensProps({ - searchSessionId: props.request.searchSessionId, + searchSessionId: props.request?.searchSessionId, getTimeRange: props.getTimeRange, attributes: (await getMockLensAttributes())!.attributes, onLoad: lensProps.onLoad!, @@ -108,7 +104,7 @@ describe('Histogram', () => { lensProps = component.find(embeddable).props(); expect(lensProps).toMatchObject(expect.objectContaining(originalProps)); await act(async () => { - props.refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); }); component.update(); lensProps = component.find(embeddable).props(); @@ -174,27 +170,11 @@ describe('Histogram', () => { .mockReturnValue([{ response: { json: { rawResponse } } } as any]); const dataLoading$ = new BehaviorSubject(false); onLoad(true, undefined, dataLoading$); - expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.loading, - undefined - ); - expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters: {}, dataLoading$ }); - expect(buildBucketInterval.buildBucketInterval).not.toHaveBeenCalled(); - expect(useTimeRange.useTimeRange).toHaveBeenLastCalledWith( - expect.objectContaining({ bucketInterval: undefined }) - ); + expect(props.onLoad).toHaveBeenLastCalledWith(true, undefined, dataLoading$); act(() => { onLoad?.(false, adapters, dataLoading$); }); - expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.complete, - 100 - ); - expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters, dataLoading$ }); - expect(buildBucketInterval.buildBucketInterval).toHaveBeenCalled(); - expect(useTimeRange.useTimeRange).toHaveBeenLastCalledWith( - expect.objectContaining({ bucketInterval: mockBucketInterval }) - ); + expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters, dataLoading$); }); it('should execute onLoad correctly when the request has a failure status', async () => { @@ -206,11 +186,7 @@ describe('Histogram', () => { .spyOn(adapters.requests, 'getRequests') .mockReturnValue([{ status: RequestStatus.ERROR } as any]); onLoad?.(false, adapters); - expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.error, - undefined - ); - expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters }); + expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters); }); it('should execute onLoad correctly when the response has shard failures', async () => { @@ -239,11 +215,7 @@ describe('Histogram', () => { act(() => { onLoad?.(false, adapters); }); - expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.error, - 100 - ); - expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters }); + expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters); }); it('should execute onLoad correctly for textbased language and no Lens suggestions', async () => { @@ -275,11 +247,7 @@ describe('Histogram', () => { act(() => { onLoad?.(false, adapters); }); - expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.complete, - 20 - ); - expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters }); + expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters); }); it('should execute onLoad correctly for textbased language and Lens suggestions', async () => { @@ -311,10 +279,6 @@ describe('Histogram', () => { act(() => { onLoad?.(false, adapters); }); - expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.complete, - 2 - ); - expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters }); + expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters); }); }); diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/histogram.tsx b/src/platform/plugins/shared/unified_histogram/public/chart/histogram.tsx index 19f92d8b4495a..ad35f6141b4b7 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/histogram.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/chart/histogram.tsx @@ -9,101 +9,54 @@ import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import React, { useState } from 'react'; +import React from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; -import type { DefaultInspectorAdapters, Datatable } from '@kbn/expressions-plugin/common'; -import type { IKibanaSearchResponse } from '@kbn/search-types'; -import type { estypes } from '@elastic/elasticsearch'; import type { TimeRange } from '@kbn/es-query'; import type { EmbeddableComponentProps, LensEmbeddableInput } from '@kbn/lens-plugin/public'; -import { RequestStatus } from '@kbn/inspector-plugin/public'; -import type { Observable } from 'rxjs'; -import { PublishingSubject } from '@kbn/presentation-publishing'; -import { +import type { UnifiedHistogramBucketInterval, UnifiedHistogramChartContext, - UnifiedHistogramFetchStatus, - UnifiedHistogramHitsContext, - UnifiedHistogramChartLoadEvent, - UnifiedHistogramRequestContext, UnifiedHistogramServices, - UnifiedHistogramInputMessage, UnifiedHistogramVisContext, } from '../types'; -import { buildBucketInterval } from './utils/build_bucket_interval'; import { useTimeRange } from './hooks/use_time_range'; -import { useStableCallback } from '../hooks/use_stable_callback'; -import { useLensProps } from './hooks/use_lens_props'; +import type { LensProps } from './hooks/use_lens_props'; export interface HistogramProps { abortController?: AbortController; services: UnifiedHistogramServices; dataView: DataView; - request?: UnifiedHistogramRequestContext; - hits?: UnifiedHistogramHitsContext; chart: UnifiedHistogramChartContext; + bucketInterval?: UnifiedHistogramBucketInterval; isPlainRecord?: boolean; - hasLensSuggestions: boolean; getTimeRange: () => TimeRange; - refetch$: Observable; + requestData: string; + lensProps: LensProps; visContext: UnifiedHistogramVisContext; disableTriggers?: LensEmbeddableInput['disableTriggers']; disabledActions?: LensEmbeddableInput['disabledActions']; - onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void; - onChartLoad?: (event: UnifiedHistogramChartLoadEvent) => void; onFilter?: LensEmbeddableInput['onFilter']; onBrushEnd?: LensEmbeddableInput['onBrushEnd']; withDefaultActions: EmbeddableComponentProps['withDefaultActions']; } -const computeTotalHits = ( - hasLensSuggestions: boolean, - adapterTables: - | { - [key: string]: Datatable; - } - | undefined, - isPlainRecord?: boolean -) => { - if (isPlainRecord && hasLensSuggestions) { - return Object.values(adapterTables ?? {})?.[0]?.rows?.length; - } else if (isPlainRecord && !hasLensSuggestions) { - // ES|QL histogram case - const rows = Object.values(adapterTables ?? {})?.[0]?.rows; - if (!rows) { - return undefined; - } - let rowsCount = 0; - rows.forEach((r) => { - rowsCount += r.results; - }); - return rowsCount; - } else { - return adapterTables?.unifiedHistogram?.meta?.statistics?.totalCount; - } -}; - export function Histogram({ - services: { data, lens, uiSettings }, + services: { lens, uiSettings }, dataView, - request, - hits, chart: { timeInterval }, + bucketInterval, isPlainRecord, - hasLensSuggestions, getTimeRange, - refetch$, + requestData, + lensProps, visContext, disableTriggers, disabledActions, - onTotalHitsChange, - onChartLoad, onFilter, onBrushEnd, withDefaultActions, abortController, }: HistogramProps) { - const [bucketInterval, setBucketInterval] = useState(); const { timeRangeText, timeRangeDisplay } = useTimeRange({ uiSettings, bucketInterval, @@ -113,63 +66,8 @@ export function Histogram({ timeField: dataView.timeFieldName, }); const { attributes } = visContext; - - const onLoad = useStableCallback( - ( - isLoading: boolean, - adapters: Partial | undefined, - dataLoading$?: PublishingSubject - ) => { - const lensRequest = adapters?.requests?.getRequests()[0]; - const requestFailed = lensRequest?.status === RequestStatus.ERROR; - const json = lensRequest?.response?.json as - | IKibanaSearchResponse - | undefined; - const response = json?.rawResponse; - - if (requestFailed) { - onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, undefined); - onChartLoad?.({ adapters: adapters ?? {} }); - return; - } - - const adapterTables = adapters?.tables?.tables; - const totalHits = computeTotalHits(hasLensSuggestions, adapterTables, isPlainRecord); - - if (response?._shards?.failed || response?.timed_out) { - onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, totalHits); - } else { - onTotalHitsChange?.( - isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete, - totalHits ?? hits?.total - ); - } - - if (response) { - const newBucketInterval = buildBucketInterval({ - data, - dataView, - timeInterval, - timeRange: getTimeRange(), - response, - }); - - setBucketInterval(newBucketInterval); - } - - onChartLoad?.({ adapters: adapters ?? {}, dataLoading$ }); - } - ); - - const { lensProps, requestData } = useLensProps({ - request, - getTimeRange, - refetch$, - visContext, - onLoad, - }); - const { euiTheme } = useEuiTheme(); + const boxShadow = `0 2px 2px -1px ${euiTheme.colors.mediumShade}, 0 1px 5px -2px ${euiTheme.colors.mediumShade}`; const chartCss = css` diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.test.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.test.ts new file mode 100644 index 0000000000000..d8be832908e0d --- /dev/null +++ b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.test.ts @@ -0,0 +1,35 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useFetch } from './use_fetch'; +import { renderHook } from '@testing-library/react'; +import { UnifiedHistogramInput$ } from '../../types'; +import { Subject } from 'rxjs'; + +describe('useFetch', () => { + const getDeps: () => { + input$: UnifiedHistogramInput$; + beforeFetch: () => void; + } = () => ({ + input$: new Subject(), + beforeFetch: () => {}, + }); + + it('should trigger the fetch observable when the input$ observable is triggered', () => { + const originalDeps = getDeps(); + const hook = renderHook((deps) => useFetch(deps), { + initialProps: originalDeps, + }); + const fetch = jest.fn(); + hook.result.current.subscribe(fetch); + expect(fetch).not.toHaveBeenCalled(); + originalDeps.input$.next({ type: 'fetch' }); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.ts new file mode 100644 index 0000000000000..e48d1026a9d3a --- /dev/null +++ b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_fetch.ts @@ -0,0 +1,30 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useMemo } from 'react'; +import { filter, share, tap } from 'rxjs'; +import { UnifiedHistogramInput$ } from '../../types'; + +export const useFetch = ({ + input$, + beforeFetch, +}: { + input$: UnifiedHistogramInput$; + beforeFetch: () => void; +}) => { + return useMemo( + () => + input$.pipe( + filter((message) => message.type === 'fetch'), + tap(beforeFetch), + share() + ), + [beforeFetch, input$] + ); +}; diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.test.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.test.ts index d66856bdb4f0d..8ec349b7ee85a 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.test.ts +++ b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.test.ts @@ -17,7 +17,7 @@ import { getLensProps, useLensProps } from './use_lens_props'; describe('useLensProps', () => { it('should return lens props', async () => { const getTimeRange = jest.fn(); - const refetch$ = new Subject(); + const fetch$ = new Subject(); const onLoad = jest.fn(); const query = { language: 'kuery', @@ -41,12 +41,15 @@ describe('useLensProps', () => { adapter: undefined, }, getTimeRange, - refetch$, + fetch$, visContext: attributesContext!, onLoad, }); }); - expect(lensProps.result.current.lensProps).toEqual( + act(() => { + fetch$.next({ type: 'fetch' }); + }); + expect(lensProps.result.current?.lensProps).toEqual( getLensProps({ searchSessionId: 'id', getTimeRange, @@ -58,7 +61,7 @@ describe('useLensProps', () => { it('should return lens props for text based languages', async () => { const getTimeRange = jest.fn(); - const refetch$ = new Subject(); + const fetch$ = new Subject(); const onLoad = jest.fn(); const query = { language: 'kuery', @@ -82,12 +85,15 @@ describe('useLensProps', () => { adapter: undefined, }, getTimeRange, - refetch$, + fetch$, visContext: attributesContext!, onLoad, }); }); - expect(lensProps.result.current.lensProps).toEqual( + act(() => { + fetch$.next({ type: 'fetch' }); + }); + expect(lensProps.result.current?.lensProps).toEqual( getLensProps({ searchSessionId: 'id', getTimeRange, @@ -97,9 +103,9 @@ describe('useLensProps', () => { ); }); - it('should only update lens props when refetch$ is triggered', async () => { + it('should only return lens props after fetch$ is triggered', async () => { const getTimeRange = jest.fn(); - const refetch$ = new Subject(); + const fetch$ = new Subject(); const onLoad = jest.fn(); const query = { language: 'kuery', @@ -122,7 +128,7 @@ describe('useLensProps', () => { adapter: undefined, }, getTimeRange, - refetch$, + fetch$, visContext: attributesContext!, onLoad, }; @@ -132,12 +138,12 @@ describe('useLensProps', () => { }, { initialProps: lensProps } ); - const originalProps = hook.result.current; + expect(hook.result.current).toEqual(undefined); hook.rerender({ ...lensProps, request: { searchSessionId: '456', adapter: undefined } }); - expect(hook.result.current).toEqual(originalProps); + expect(hook.result.current).toEqual(undefined); act(() => { - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); }); - expect(hook.result.current).not.toEqual(originalProps); + expect(hook.result.current).not.toEqual(undefined); }); }); diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.ts index 155a38ea9a0ab..8cf2af38b8353 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.ts +++ b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_lens_props.ts @@ -10,7 +10,7 @@ import type { TimeRange } from '@kbn/data-plugin/common'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; -import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { EmbeddableComponentProps, TypedLensByValueInput } from '@kbn/lens-plugin/public'; import { useCallback, useEffect, useState } from 'react'; import type { Observable } from 'rxjs'; import type { @@ -20,21 +20,38 @@ import type { } from '../../types'; import { useStableCallback } from '../../hooks/use_stable_callback'; +export type LensProps = Pick< + EmbeddableComponentProps, + | 'id' + | 'viewMode' + | 'timeRange' + | 'attributes' + | 'noPadding' + | 'searchSessionId' + | 'executionContext' + | 'onLoad' +>; + export const useLensProps = ({ request, getTimeRange, - refetch$, + fetch$, visContext, onLoad, }: { request?: UnifiedHistogramRequestContext; getTimeRange: () => TimeRange; - refetch$: Observable; - visContext: UnifiedHistogramVisContext; + fetch$: Observable; + visContext?: UnifiedHistogramVisContext; onLoad: (isLoading: boolean, adapters: Partial | undefined) => void; }) => { const buildLensProps = useCallback(() => { + if (!visContext) { + return; + } + const { attributes, requestData } = visContext; + return { requestData: JSON.stringify(requestData), lensProps: getLensProps({ @@ -46,13 +63,14 @@ export const useLensProps = ({ }; }, [visContext, getTimeRange, onLoad, request?.searchSessionId]); - const [lensPropsContext, setLensPropsContext] = useState(buildLensProps()); + // Initialize with undefined to avoid rendering Lens until a fetch has been triggered + const [lensPropsContext, setLensPropsContext] = useState>(); const updateLensPropsContext = useStableCallback(() => setLensPropsContext(buildLensProps())); useEffect(() => { - const subscription = refetch$.subscribe(updateLensPropsContext); + const subscription = fetch$.subscribe(updateLensPropsContext); return () => subscription.unsubscribe(); - }, [refetch$, updateLensPropsContext]); + }, [fetch$, updateLensPropsContext]); return lensPropsContext; }; @@ -67,7 +85,7 @@ export const getLensProps = ({ getTimeRange: () => TimeRange; attributes: TypedLensByValueInput['attributes']; onLoad: (isLoading: boolean, adapters: Partial | undefined) => void; -}) => ({ +}): LensProps => ({ id: 'unifiedHistogramLensComponent', viewMode: ViewMode.VIEW, timeRange: getTimeRange(), diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.test.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.test.ts deleted file mode 100644 index cf8590009062b..0000000000000 --- a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.test.ts +++ /dev/null @@ -1,86 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { useRefetch } from './use_refetch'; -import { DataView } from '@kbn/data-views-plugin/common'; -import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -import { renderHook } from '@testing-library/react'; -import { - UnifiedHistogramBreakdownContext, - UnifiedHistogramChartContext, - UnifiedHistogramHitsContext, - UnifiedHistogramInput$, - UnifiedHistogramRequestContext, -} from '../../types'; -import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; -import { Subject } from 'rxjs'; - -describe('useRefetch', () => { - const getDeps: () => { - dataView: DataView; - request: UnifiedHistogramRequestContext | undefined; - hits: UnifiedHistogramHitsContext | undefined; - chart: UnifiedHistogramChartContext | undefined; - chartVisible: boolean; - breakdown: UnifiedHistogramBreakdownContext | undefined; - filters: Filter[]; - query: Query | AggregateQuery; - relativeTimeRange: TimeRange; - input$: UnifiedHistogramInput$; - beforeRefetch: () => void; - } = () => ({ - dataView: dataViewWithTimefieldMock, - request: undefined, - hits: undefined, - chart: undefined, - chartVisible: true, - breakdown: undefined, - filters: [], - query: { language: 'kuery', query: '' }, - relativeTimeRange: { from: 'now-15m', to: 'now' }, - input$: new Subject(), - beforeRefetch: () => {}, - }); - - it('should trigger the refetch observable when any of the arguments change', () => { - const originalDeps = getDeps(); - const hook = renderHook((deps) => useRefetch(deps), { - initialProps: originalDeps, - }); - const refetch = jest.fn(); - hook.result.current.subscribe(refetch); - hook.rerender({ ...originalDeps }); - expect(refetch).not.toHaveBeenCalled(); - hook.rerender({ ...originalDeps, chartVisible: false }); - expect(refetch).toHaveBeenCalledTimes(1); - }); - - it('should not trigger the refetch observable when disableAutoFetching is true', () => { - const originalDeps = { ...getDeps(), disableAutoFetching: true }; - const hook = renderHook((deps) => useRefetch(deps), { - initialProps: originalDeps, - }); - const refetch = jest.fn(); - hook.result.current.subscribe(refetch); - hook.rerender({ ...originalDeps, chartVisible: false }); - expect(refetch).not.toHaveBeenCalled(); - }); - - it('should trigger the refetch observable when the input$ observable is triggered', () => { - const originalDeps = { ...getDeps(), disableAutoFetching: true }; - const hook = renderHook((deps) => useRefetch(deps), { - initialProps: originalDeps, - }); - const refetch = jest.fn(); - hook.result.current.subscribe(refetch); - expect(refetch).not.toHaveBeenCalled(); - originalDeps.input$.next({ type: 'refetch' }); - expect(refetch).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.ts deleted file mode 100644 index 07abcae6c3c45..0000000000000 --- a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_refetch.ts +++ /dev/null @@ -1,145 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { DataView } from '@kbn/data-views-plugin/common'; -import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -import type { Suggestion } from '@kbn/lens-plugin/public'; -import { cloneDeep, isEqual } from 'lodash'; -import { useEffect, useMemo, useRef } from 'react'; -import { filter, share, tap } from 'rxjs'; -import { - UnifiedHistogramBreakdownContext, - UnifiedHistogramChartContext, - UnifiedHistogramHitsContext, - UnifiedHistogramInput$, - UnifiedHistogramRequestContext, -} from '../../types'; - -export const useRefetch = ({ - dataView, - request, - hits, - chart, - chartVisible, - breakdown, - filters, - query, - relativeTimeRange, - currentSuggestion, - disableAutoFetching, - input$, - beforeRefetch, -}: { - dataView: DataView; - request: UnifiedHistogramRequestContext | undefined; - hits: UnifiedHistogramHitsContext | undefined; - chart: UnifiedHistogramChartContext | undefined; - chartVisible: boolean; - breakdown: UnifiedHistogramBreakdownContext | undefined; - filters: Filter[]; - query: Query | AggregateQuery; - relativeTimeRange: TimeRange; - currentSuggestion?: Suggestion; - disableAutoFetching?: boolean; - input$: UnifiedHistogramInput$; - beforeRefetch: () => void; -}) => { - const refetchDeps = useRef>(); - - // When the Unified Histogram props change, we must compare the current subset - // that should trigger a histogram refetch against the previous subset. If they - // are different, we must refetch the histogram to ensure it's up to date. - useEffect(() => { - // Skip if auto fetching if disabled - if (disableAutoFetching) { - return; - } - - const newRefetchDeps = getRefetchDeps({ - dataView, - request, - hits, - chart, - chartVisible, - breakdown, - filters, - query, - relativeTimeRange, - currentSuggestion, - }); - - if (!isEqual(refetchDeps.current, newRefetchDeps)) { - if (refetchDeps.current) { - input$.next({ type: 'refetch' }); - } - - refetchDeps.current = newRefetchDeps; - } - }, [ - breakdown, - chart, - chartVisible, - currentSuggestion, - dataView, - disableAutoFetching, - filters, - hits, - input$, - query, - relativeTimeRange, - request, - ]); - - return useMemo( - () => - input$.pipe( - filter((message) => message.type === 'refetch'), - tap(beforeRefetch), - share() - ), - [beforeRefetch, input$] - ); -}; - -const getRefetchDeps = ({ - dataView, - request, - hits, - chart, - chartVisible, - breakdown, - filters, - query, - relativeTimeRange, - currentSuggestion, -}: { - dataView: DataView; - request: UnifiedHistogramRequestContext | undefined; - hits: UnifiedHistogramHitsContext | undefined; - chart: UnifiedHistogramChartContext | undefined; - chartVisible: boolean; - breakdown: UnifiedHistogramBreakdownContext | undefined; - filters: Filter[]; - query: Query | AggregateQuery; - relativeTimeRange: TimeRange; - currentSuggestion?: Suggestion; -}) => - cloneDeep([ - dataView.id, - request?.searchSessionId, - Boolean(hits), - chartVisible, - chart?.timeInterval, - Boolean(breakdown), - breakdown?.field, - filters, - query, - relativeTimeRange, - currentSuggestion?.visualizationId, - ]); diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.test.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.test.ts index 4ba57d490db6f..9f7c1ef4c118d 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.test.ts +++ b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.test.ts @@ -28,7 +28,7 @@ jest.mock('react-use/lib/useDebounce', () => { describe('useTotalHits', () => { const timeRange = { from: 'now-15m', to: 'now' }; - const refetch$: UnifiedHistogramInput$ = new Subject(); + const fetch$: UnifiedHistogramInput$ = new Subject(); const getDeps = () => ({ services: { data: dataPluginMock.createStartContract(), @@ -54,7 +54,7 @@ describe('useTotalHits', () => { filters: [], query: { query: '', language: 'kuery' }, getTimeRange: () => timeRange, - refetch$, + fetch$, onTotalHitsChange: jest.fn(), }); @@ -95,11 +95,11 @@ describe('useTotalHits', () => { }, query, filters, - refetch$, + fetch$, onTotalHitsChange, }) ); - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); rerender(); expect(onTotalHitsChange).toBeCalledTimes(1); expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.loading, undefined); @@ -128,7 +128,7 @@ describe('useTotalHits', () => { query: { esql: 'from test' }, }; const { rerender } = renderHook(() => useTotalHits(deps)); - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); rerender(); expect(onTotalHitsChange).not.toHaveBeenCalled(); }); @@ -153,7 +153,7 @@ describe('useTotalHits', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('should not fetch if refetch$ is not triggered', async () => { + it('should not fetch if fetch$ is not triggered', async () => { const onTotalHitsChange = jest.fn(); const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear(); const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear(); @@ -165,14 +165,14 @@ describe('useTotalHits', () => { expect(fetchSpy).toHaveBeenCalledTimes(0); }); - it('should fetch a second time if refetch$ is triggered', async () => { + it('should fetch a second time if fetch$ is triggered', async () => { const abortSpy = jest.spyOn(AbortController.prototype, 'abort').mockClear(); const onTotalHitsChange = jest.fn(); const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear(); const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear(); const options = { ...getDeps(), onTotalHitsChange }; const { rerender } = renderHook(() => useTotalHits(options)); - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); rerender(); expect(onTotalHitsChange).toBeCalledTimes(1); expect(setFieldSpy).toHaveBeenCalled(); @@ -180,7 +180,7 @@ describe('useTotalHits', () => { await waitFor(() => { expect(onTotalHitsChange).toBeCalledTimes(2); }); - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); rerender(); expect(abortSpy).toHaveBeenCalled(); expect(onTotalHitsChange).toBeCalledTimes(3); @@ -199,7 +199,7 @@ describe('useTotalHits', () => { .mockClear() .mockReturnValue(throwError(() => error)); const { rerender } = renderHook(() => useTotalHits({ ...getDeps(), onTotalHitsChange })); - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); rerender(); await waitFor(() => { expect(onTotalHitsChange).toBeCalledTimes(2); @@ -228,7 +228,7 @@ describe('useTotalHits', () => { filters, }) ); - refetch$.next({ type: 'refetch' }); + fetch$.next({ type: 'fetch' }); rerender(); expect(setOverwriteDataViewTypeSpy).toHaveBeenCalledWith(undefined); expect(setFieldSpy).toHaveBeenCalledWith('filter', filters); diff --git a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.ts b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.ts index 1399074816903..5f338c8500b3f 100644 --- a/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.ts +++ b/src/platform/plugins/shared/unified_histogram/public/chart/hooks/use_total_hits.ts @@ -31,7 +31,7 @@ export const useTotalHits = ({ filters, query, getTimeRange, - refetch$, + fetch$, onTotalHitsChange, isPlainRecord, }: { @@ -43,7 +43,7 @@ export const useTotalHits = ({ filters: Filter[]; query: Query | AggregateQuery; getTimeRange: () => TimeRange; - refetch$: Observable; + fetch$: Observable; onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void; isPlainRecord?: boolean; }) => { @@ -65,9 +65,9 @@ export const useTotalHits = ({ }); useEffect(() => { - const subscription = refetch$.subscribe(fetch); + const subscription = fetch$.subscribe(fetch); return () => subscription.unsubscribe(); - }, [fetch, refetch$]); + }, [fetch, fetch$]); }; const fetchTotalHits = async ({ diff --git a/src/platform/plugins/shared/unified_histogram/public/container/container.test.tsx b/src/platform/plugins/shared/unified_histogram/public/container/container.test.tsx index 4f499cfbb58fa..d67d4fc4fd815 100644 --- a/src/platform/plugins/shared/unified_histogram/public/container/container.test.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/container/container.test.tsx @@ -46,17 +46,15 @@ describe('UnifiedHistogramContainer', () => { expect(api).toBeDefined(); }); - it('should trigger input$ when refetch is called', async () => { + it('should trigger input$ when fetch is called', async () => { let api: UnifiedHistogramApi | undefined; const setApi = (ref: UnifiedHistogramApi) => { api = ref; }; - const getCreationOptions = jest.fn(() => ({ disableAutoFetching: true })); const component = mountWithIntl( { const inputSpy = jest.fn(); input$?.subscribe(inputSpy); act(() => { - api?.refetch(); + api?.fetch(); }); expect(inputSpy).toHaveBeenCalledTimes(1); - expect(inputSpy).toHaveBeenCalledWith({ type: 'refetch' }); + expect(inputSpy).toHaveBeenCalledWith({ type: 'fetch' }); }); }); diff --git a/src/platform/plugins/shared/unified_histogram/public/container/container.tsx b/src/platform/plugins/shared/unified_histogram/public/container/container.tsx index ce55d0773344e..5d83f8e84101a 100644 --- a/src/platform/plugins/shared/unified_histogram/public/container/container.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/container/container.tsx @@ -30,10 +30,7 @@ import { topPanelHeightSelector } from './utils/state_selectors'; import { exportVisContext } from '../utils/external_vis_context'; import { getBreakdownField } from './utils/local_storage_utils'; -type LayoutProps = Pick< - UnifiedHistogramLayoutProps, - 'disableAutoFetching' | 'disableTriggers' | 'disabledActions' ->; +type LayoutProps = Pick; /** * The options used to initialize the container @@ -84,9 +81,9 @@ export type UnifiedHistogramContainerProps = { */ export type UnifiedHistogramApi = { /** - * Manually trigger a refetch of the data + * Trigger a fetch of the data */ - refetch: () => void; + fetch: () => void; } & Pick< UnifiedHistogramStateService, 'state$' | 'setChartHidden' | 'setTopPanelHeight' | 'setTimeInterval' | 'setTotalHits' @@ -112,7 +109,7 @@ export const UnifiedHistogramContainer = forwardRef< const options = await getCreationOptions?.(); const apiHelper = await services.lens.stateHelperApi(); - setLayoutProps(pick(options, 'disableAutoFetching', 'disableTriggers', 'disabledActions')); + setLayoutProps(pick(options, 'disableTriggers', 'disabledActions')); setLocalStorageKeyPrefix(options?.localStorageKeyPrefix); setStateService(createStateService({ services, ...options })); setLensSuggestionsApi(() => apiHelper.suggestions); @@ -125,8 +122,8 @@ export const UnifiedHistogramContainer = forwardRef< } setApi({ - refetch: () => { - input$.next({ type: 'refetch' }); + fetch: () => { + input$.next({ type: 'fetch' }); }, ...pick( stateService, diff --git a/src/platform/plugins/shared/unified_histogram/public/layout/layout.tsx b/src/platform/plugins/shared/unified_histogram/public/layout/layout.tsx index b9d9f6fbc446f..c384102ed1691 100644 --- a/src/platform/plugins/shared/unified_histogram/public/layout/layout.tsx +++ b/src/platform/plugins/shared/unified_histogram/public/layout/layout.tsx @@ -119,10 +119,6 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren * This element would replace the default chart toggle buttons */ renderCustomChartToggleActions?: () => ReactElement | undefined; - /** - * Disable automatic refetching based on props changes, and instead wait for a `refetch` message - */ - disableAutoFetching?: boolean; /** * Disable triggers for the Lens embeddable */ @@ -219,7 +215,6 @@ export const UnifiedHistogramLayout = ({ container, topPanelHeight, renderCustomChartToggleActions, - disableAutoFetching, disableTriggers, disabledActions, lensSuggestionsApi, @@ -359,7 +354,6 @@ export const UnifiedHistogramLayout = ({ breakdown={breakdown} renderCustomChartToggleActions={renderCustomChartToggleActions} appendHistogram={chartSpacer} - disableAutoFetching={disableAutoFetching} disableTriggers={disableTriggers} disabledActions={disabledActions} input$={input$} diff --git a/src/platform/plugins/shared/unified_histogram/public/mocks.ts b/src/platform/plugins/shared/unified_histogram/public/mocks.ts index 11ebd50239257..73d7d2bd93015 100644 --- a/src/platform/plugins/shared/unified_histogram/public/mocks.ts +++ b/src/platform/plugins/shared/unified_histogram/public/mocks.ts @@ -17,7 +17,7 @@ export const createMockUnifiedHistogramApi = () => { setTopPanelHeight: jest.fn(), setTimeInterval: jest.fn(), setTotalHits: jest.fn(), - refetch: jest.fn(), + fetch: jest.fn(), }; return api; }; diff --git a/src/platform/plugins/shared/unified_histogram/public/types.ts b/src/platform/plugins/shared/unified_histogram/public/types.ts index a64000da11df0..47881249089ec 100644 --- a/src/platform/plugins/shared/unified_histogram/public/types.ts +++ b/src/platform/plugins/shared/unified_histogram/public/types.ts @@ -126,16 +126,16 @@ export interface UnifiedHistogramBreakdownContext { } /** - * Message to refetch the chart and total hits + * Message to fetch the chart and total hits */ -export interface UnifiedHistogramRefetchMessage { - type: 'refetch'; +export interface UnifiedHistogramFetchMessage { + type: 'fetch'; } /** * Unified histogram input message */ -export type UnifiedHistogramInputMessage = UnifiedHistogramRefetchMessage; +export type UnifiedHistogramInputMessage = UnifiedHistogramFetchMessage; /** * Unified histogram input observable diff --git a/test/functional/apps/discover/group3/_request_counts.ts b/test/functional/apps/discover/group3/_request_counts.ts index b10590c3002d2..b025f23797228 100644 --- a/test/functional/apps/discover/group3/_request_counts.ts +++ b/test/functional/apps/discover/group3/_request_counts.ts @@ -65,11 +65,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { .getEntries() .filter((entry: any) => ['fetch', 'xmlhttprequest'].includes(entry.initiatorType)) ); - const result = requests.filter((entry) => entry.name.endsWith(`/internal/search/${endpoint}`) ); - const count = result.length; if (count !== searchCount) { log.warning('Request count differs:', result); @@ -80,18 +78,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }; - const waitForLoadingToFinish = async () => { - await header.waitUntilLoadingHasFinished(); - await discover.waitForDocTableLoadingComplete(); - await elasticChart.canvasExists(); - }; - const expectSearches = async (type: 'ese' | 'esql', expected: number, cb: Function) => { await expectSearchCount(type, 0); await cb(); await expectSearchCount(type, expected); }; + const waitForLoadingToFinish = async () => { + await header.waitUntilLoadingHasFinished(); + await discover.waitForDocTableLoadingComplete(); + await elasticChart.canvasExists(); + }; + const getSharedTests = ({ type, savedSearch, @@ -99,7 +97,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { query2, savedSearchesRequests, setQuery, - expectedRequests = 2, }: { type: 'ese' | 'esql'; savedSearch: string; @@ -107,10 +104,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { query2: string; savedSearchesRequests?: number; setQuery: (query: string) => Promise; - expectedRequests?: number; - expectedRefreshRequest?: number; }) => { - it(`should send no more than ${expectedRequests} search requests (documents + chart) on page load`, async () => { + it(`should send 2 search requests (documents + chart) on page load`, async () => { if (type === 'ese') { await browser.refresh(); } @@ -118,29 +113,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { performance.setResourceTimingBufferSize(Number.MAX_SAFE_INTEGER); }); if (type === 'esql') { - await expectSearches(type, expectedRequests, async () => { + await expectSearches(type, 2, async () => { await queryBar.clickQuerySubmitButton(); }); } else { - await expectSearchCount(type, expectedRequests); + await expectSearchCount(type, 2); } }); - it(`should send no more than ${expectedRequests} requests (documents + chart) when refreshing`, async () => { - await expectSearches(type, expectedRequests, async () => { + it(`should send 2 requests (documents + chart) when refreshing`, async () => { + await expectSearches(type, 2, async () => { await queryBar.clickQuerySubmitButton(); }); }); - it(`should send no more than ${expectedRequests} requests (documents + chart) when changing the query`, async () => { - await expectSearches(type, expectedRequests, async () => { + it(`should send 2 requests (documents + chart) when changing the query`, async () => { + await expectSearches(type, 2, async () => { await setQuery(query1); await queryBar.clickQuerySubmitButton(); }); }); - it(`should send no more than ${expectedRequests} requests (documents + chart) when changing the time range`, async () => { - await expectSearches(type, expectedRequests, async () => { + it(`should send 2 requests (documents + chart) when changing the time range`, async () => { + await expectSearches(type, 2, async () => { await timePicker.setAbsoluteRange( 'Sep 21, 2015 @ 06:31:44.000', 'Sep 23, 2015 @ 00:00:00.000' @@ -156,6 +151,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.toggleChartVisibility(); }); }); + it(`should send a request for chart data when toggling the chart visibility after a time range change`, async () => { // hide chart await discover.toggleChartVisibility(); @@ -170,7 +166,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it(`should send ${savedSearchesRequests} requests for saved search changes`, async () => { + const actualSavedSearchRequests = savedSearchesRequests ?? 2; + + it(`should send no more than ${actualSavedSearchRequests} requests for saved search changes`, async () => { await setQuery(query1); await queryBar.clickQuerySubmitButton(); await timePicker.setAbsoluteRange( @@ -178,42 +176,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 23, 2015 @ 00:00:00.000' ); await waitForLoadingToFinish(); - const actualExpectedRequests = savedSearchesRequests ?? expectedRequests; log.debug('Creating saved search'); - await expectSearches( - type, - type === 'esql' ? actualExpectedRequests + 2 : actualExpectedRequests, - async () => { - await discover.saveSearch(savedSearch); - } - ); + await expectSearches(type, actualSavedSearchRequests, async () => { + await discover.saveSearch(savedSearch); + }); log.debug('Resetting saved search'); await setQuery(query2); await queryBar.clickQuerySubmitButton(); await waitForLoadingToFinish(); - await expectSearches(type, actualExpectedRequests, async () => { + await expectSearches(type, 2, async () => { await discover.revertUnsavedChanges(); }); log.debug('Clearing saved search'); - await expectSearches( - type, - type === 'esql' ? actualExpectedRequests + 1 : actualExpectedRequests, - async () => { - await testSubjects.click('discoverNewButton'); - if (type === 'esql') { - await queryBar.clickQuerySubmitButton(); - } - await waitForLoadingToFinish(); + await expectSearches(type, actualSavedSearchRequests, async () => { + await testSubjects.click('discoverNewButton'); + if (type === 'esql') { + await queryBar.clickQuerySubmitButton(); } - ); + await waitForLoadingToFinish(); + }); log.debug('Loading saved search'); - await expectSearches( - type, - type === 'esql' ? actualExpectedRequests + 2 : actualExpectedRequests, - async () => { - await discover.loadSavedSearch(savedSearch); - } - ); + await expectSearches(type, actualSavedSearchRequests, async () => { + await discover.loadSavedSearch(savedSearch); + }); }); }; @@ -233,7 +218,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { setQuery: (query) => queryBar.setQuery(query), }); - it('should send no more than 2 requests (documents + chart) when adding a filter', async () => { + it('should send 2 requests (documents + chart) when adding a filter', async () => { await expectSearches(type, 2, async () => { await filterBar.addFilter({ field: 'extension', @@ -243,39 +228,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it('should send no more than 2 requests (documents + chart) when sorting', async () => { + it('should send 2 requests (documents + chart) when sorting', async () => { await expectSearches(type, 2, async () => { await discover.clickFieldSort('@timestamp', 'Sort Old-New'); }); }); - it('should send no more than 2 requests (documents + chart) when changing to a breakdown field without an other bucket', async () => { + it('should send 2 requests (documents + chart) when changing to a breakdown field without an other bucket', async () => { await expectSearches(type, 2, async () => { await discover.chooseBreakdownField('type'); }); }); - it('should send no more than 3 requests (documents + chart + other bucket) when changing to a breakdown field with an other bucket', async () => { + it('should send 3 requests (documents + chart + other bucket) when changing to a breakdown field with an other bucket', async () => { await testSubjects.click('discoverNewButton'); await expectSearches(type, 3, async () => { await discover.chooseBreakdownField('extension.raw'); }); }); - it('should send no more than 2 requests (documents + chart) when changing the chart interval', async () => { + it('should send 2 requests (documents + chart) when changing the chart interval', async () => { await expectSearches(type, 2, async () => { await discover.setChartInterval('Day'); }); }); - it('should send no more than 2 requests (documents + chart) when changing the data view', async () => { + it('should send 2 requests (documents + chart) when changing the data view', async () => { await expectSearches(type, 2, async () => { await discover.selectIndexPattern('long-window-logstash-*'); }); }); }); + describe('ES|QL mode', () => { const type = 'esql'; + before(async () => { await kibanaServer.uiSettings.update({ 'discover:searchOnPageLoad': false, @@ -293,9 +280,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { savedSearch: 'esql test', query1: 'from logstash-* | where bytes > 1000 ', query2: 'from logstash-* | where bytes < 2000 ', - savedSearchesRequests: 2, + savedSearchesRequests: 3, setQuery: (query) => monacoEditor.setCodeEditorValue(query), - expectedRequests: 2, }); }); }); From bf15ee72e9adb7a8894c9ede9bbea3ef65dccb16 Mon Sep 17 00:00:00 2001 From: Robert Jaszczurek <92210485+rbrtj@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:24:52 +0100 Subject: [PATCH 3/6] [ML] Notifications: Fix table reponsiveness (#206956) Fix for: https://github.com/elastic/kibana/issues/205874 After: https://github.com/user-attachments/assets/7d0a12be-053e-4e8d-8b26-216b2e279cad --- .../notifications/components/notifications_list.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/plugins/shared/ml/public/application/notifications/components/notifications_list.tsx b/x-pack/platform/plugins/shared/ml/public/application/notifications/components/notifications_list.tsx index 399b880c6a331..624954fe05ce6 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/notifications/components/notifications_list.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/notifications/components/notifications_list.tsx @@ -201,9 +201,9 @@ export const NotificationsList: FC = () => { ), sortable: true, - truncateText: false, + truncateText: true, 'data-test-subj': 'mlNotificationEntity', - width: '200px', + width: '15%', }, { field: 'message', @@ -211,7 +211,9 @@ export const NotificationsList: FC = () => { ), sortable: false, - truncateText: false, + truncateText: { + lines: 8, + }, 'data-test-subj': 'mlNotificationMessage', }, ]; From 49d1cea3ba58746d44d05a23cb132df46c0ccccb Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 20 Jan 2025 16:25:15 +0100 Subject: [PATCH 4/6] [Rules migration] Improvements & fixes (#207177) ## Summary [Internal link](https://github.com/elastic/security-team/issues/10820) to the feature details This PR includes next improvements and fixes ### Improvements 1. Add information tooltip for `Status`, `Severity`, `Author`, `Integrations` and `Actions` column headers. [Figma link](https://www.figma.com/design/BD9GZZz6y8pfSbubAt5H2W?node-id=2579-182863#1094946220) https://github.com/user-attachments/assets/8de91149-8b47-4dc1-8a6c-853c9e428522 ### Fixes 1. Migration rules page flickering/reloading on filter updates. Make sure that we show loading indicator for the table only when rules data is being fetched: https://github.com/user-attachments/assets/ff24fd50-c286-46a6-a850-9d12d3a01993 2. Make sure that we split translation tab equally between original and translated query code block components. https://github.com/user-attachments/assets/c1214f2c-e0a9-4add-82e6-4296458ce7f9 To reproduce this issue you need to add a splunk rule with the long one line query. For example > tag=watchlist NOT sourcetype=stash | eval risk_object=case(isnotnull(user),user,isnotnull(src_user),src_user,isnotnull(dest),dest,isnotnull(src),src,1=1,host) | eval risk_object_type=case(isnotnull(user),"user",isnotnull(src_user),"user",isnotnull(dest),"system",isnotnull(src),"system",1=1,"system") | eval risk_score=if(eventtype="website_watchlist",50,null()) | eval suppression_value=sourcetype."|".risk_object | `get_event_id` | table _raw,event_id,host,source,sourcetype,src,dest,dvc,src_user,user > [!NOTE] > This feature needs `siemMigrationsEnabled` experimental flag enabled to work. --- .../tabs/translation/index.tsx | 2 +- .../tabs/translation/migration_rule_query.tsx | 7 +++- .../rules/components/rules_table/index.tsx | 18 ++++----- .../rules_table_columns/actions.tsx | 33 ++++++++++++++- .../components/rules_table_columns/author.tsx | 31 +++++++++++++- .../rules_table_columns/header/index.tsx | 28 +++++++++++++ .../rules_table_columns/integrations.tsx | 26 +++++++++++- .../rules_table_columns/severity.tsx | 25 +++++++++++- .../components/rules_table_columns/status.tsx | 40 ++++++++++++++++++- .../rules_table_columns/translations.ts | 14 +++++++ 10 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/header/index.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/index.tsx index 8ad5c9af9a62e..182fb3c9fc048 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/index.tsx @@ -106,7 +106,7 @@ export const TranslationTab: React.FC = React.memo( {}} - onClickAriaLabel={'Click to update translation status'} + onClickAriaLabel={'Translation status badge'} > {isInstalled ? i18n.INSTALLED_LABEL diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/migration_rule_query.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/migration_rule_query.tsx index 9b41d43d1c0ec..0aeb4042d1b86 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/migration_rule_query.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/migration_rule_query.tsx @@ -116,7 +116,12 @@ export const MigrationRuleQuery: React.FC = React.memo(

{ruleName}

- + {query} diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx index 59052599dfc98..30c2a8e0b538a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx @@ -212,13 +212,13 @@ export const MigrationRulesTable: React.FC = React.mem startMigration(migrationId, SiemMigrationRetryFilter.FAILED); }, [migrationId, startMigration]); - const isLoading = - isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading || isRetryLoading; + const isRulesLoading = + isPrebuiltRulesLoading || isDataLoading || isTableLoading || isRetryLoading; const ruleActionsFactory = useCallback( (ruleMigration: RuleMigration, closeRulePreview: () => void) => { const canMigrationRuleBeInstalled = - !isLoading && + !isRulesLoading && !ruleMigration.elastic_rule?.id && ruleMigration.translation_result === RuleTranslationResult.FULL; return ( @@ -253,12 +253,12 @@ export const MigrationRulesTable: React.FC = React.mem ); }, - [installSingleRule, isLoading] + [installSingleRule, isRulesLoading] ); const getMigrationRuleData = useCallback( (ruleId: string) => { - if (!isLoading && ruleMigrations.length) { + if (!isRulesLoading && ruleMigrations.length) { const ruleMigration = ruleMigrations.find((item) => item.id === ruleId); let matchedPrebuiltRule: RuleResponse | undefined; let relatedIntegrations: RelatedIntegration[] = []; @@ -278,14 +278,14 @@ export const MigrationRulesTable: React.FC = React.mem return { ruleMigration, matchedPrebuiltRule, relatedIntegrations, isIntegrationsLoading }; } }, - [integrations, isIntegrationsLoading, isLoading, prebuiltRules, ruleMigrations] + [integrations, isIntegrationsLoading, isRulesLoading, prebuiltRules, ruleMigrations] ); const { migrationRuleDetailsFlyout: rulePreviewFlyout, openMigrationRuleDetails: openRulePreview, } = useMigrationRuleDetailsFlyout({ - isLoading, + isLoading: isRulesLoading, getMigrationRuleData, ruleActionsFactory, }); @@ -300,7 +300,7 @@ export const MigrationRulesTable: React.FC = React.mem return ( <> @@ -324,7 +324,7 @@ export const MigrationRulesTable: React.FC = React.mem { return { field: 'elastic_rule', - name: i18n.COLUMN_ACTIONS, + name: ( + , + title: ( + +

+ {i18n.COLUMN_ACTIONS} + +

+
+ ), + view: {i18n.ACTIONS_VIEW_LABEL}, + install: {i18n.ACTIONS_INSTALL_LABEL}, + edit: {i18n.ACTIONS_EDIT_LABEL}, + }} + /> + } + /> + ), render: (_, rule: RuleMigration) => { return ( { return ( @@ -30,7 +32,32 @@ const Author = ({ isPrebuiltRule }: { isPrebuiltRule: boolean }) => { export const createAuthorColumn = (): TableColumn => { return { field: 'elastic_rule.prebuilt_rule_id', - name: i18n.COLUMN_AUTHOR, + name: ( + , + title: ( + +

+ {i18n.COLUMN_AUTHOR} + +

+
+ ), + elastic: {i18n.ELASTIC_AUTHOR_TITLE}, + custom: {i18n.CUSTOM_AUTHOR_TITLE}, + }} + /> + } + /> + ), render: (_, rule: RuleMigration) => { return rule.status === SiemMigrationStatus.FAILED ? ( <>{COLUMN_EMPTY_VALUE} diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/header/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/header/index.tsx new file mode 100644 index 0000000000000..c23e8f7d3db2f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/header/index.tsx @@ -0,0 +1,28 @@ +/* + * 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 React from 'react'; + +import { EuiToolTip, EuiIcon } from '@elastic/eui'; + +interface TableHeaderProps { + title: string; + tooltipContent?: React.ReactNode; +} + +export const TableHeader: React.FC = React.memo(({ title, tooltipContent }) => { + return ( + + <> + {title} +   + + + + ); +}); +TableHeader.displayName = 'TableHeader'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/integrations.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/integrations.tsx index cef0131cb22e9..2f8dcb4b7a38e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/integrations.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/integrations.tsx @@ -6,12 +6,14 @@ */ import React from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; +import { EuiHorizontalRule, EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import type { RelatedIntegration } from '../../../../../common/api/detection_engine'; import { IntegrationsPopover } from '../../../../detections/components/rules/related_integrations/integrations_popover'; import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import * as i18n from './translations'; import type { TableColumn } from './constants'; +import { TableHeader } from './header'; export const createIntegrationsColumn = ({ getMigrationRuleData, @@ -22,7 +24,27 @@ export const createIntegrationsColumn = ({ }): TableColumn => { return { field: 'elastic_rule.integration_ids', - name: i18n.COLUMN_INTEGRATIONS, + name: ( + +

+ {i18n.COLUMN_INTEGRATIONS} + +

+ + ), + }} + /> + } + /> + ), render: (_, rule: RuleMigration) => { const migrationRuleData = getMigrationRuleData(rule.id); if (migrationRuleData?.isIntegrationsLoading) { diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/severity.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/severity.tsx index 2b01ec83c1eda..6911e23a421fb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/severity.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/severity.tsx @@ -7,16 +7,39 @@ import React from 'react'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { EuiHorizontalRule, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import { type RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import { SeverityBadge } from '../../../../common/components/severity_badge'; import { COLUMN_EMPTY_VALUE, type TableColumn } from './constants'; import * as i18n from './translations'; +import { TableHeader } from './header'; export const createSeverityColumn = (): TableColumn => { return { field: 'elastic_rule.severity', - name: i18n.COLUMN_SEVERITY, + name: ( + +

+ {i18n.COLUMN_SEVERITY} + +

+ + ), + }} + /> + } + /> + ), render: (value: Severity, rule: RuleMigration) => rule.status === SiemMigrationStatus.FAILED ? ( <>{COLUMN_EMPTY_VALUE} diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/status.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/status.tsx index 2936878c93b8b..0a8d78f3aa812 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/status.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/status.tsx @@ -6,15 +6,53 @@ */ import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiHorizontalRule, EuiText } from '@elastic/eui'; +import { RuleTranslationResult } from '../../../../../common/siem_migrations/constants'; import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import * as i18n from './translations'; import type { TableColumn } from './constants'; import { StatusBadge } from '../status_badge'; +import { TableHeader } from './header'; +import { convertTranslationResultIntoText } from '../../utils/translation_results'; export const createStatusColumn = (): TableColumn => { return { field: 'translation_result', - name: i18n.COLUMN_STATUS, + name: ( + , + title: ( + +

+ {i18n.STATUS_TOOLTIP_TITLE} + +

+
+ ), + installed: {i18n.INSTALLED_STATUS_TITLE}, + translated: {convertTranslationResultIntoText(RuleTranslationResult.FULL)}, + partiallyTranslated: ( + {convertTranslationResultIntoText(RuleTranslationResult.PARTIAL)} + ), + notTranslated: ( + {convertTranslationResultIntoText(RuleTranslationResult.UNTRANSLATABLE)} + ), + }} + /> + } + /> + ), render: (_, rule: RuleMigration) => , sortable: true, truncateText: true, diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/translations.ts index e16b4b334eb75..a38a765d36f54 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table_columns/translations.ts @@ -97,3 +97,17 @@ export const COLUMN_INTEGRATIONS = i18n.translate( defaultMessage: 'Integrations', } ); + +export const STATUS_TOOLTIP_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.tableColumn.statusTooltipTitle', + { + defaultMessage: 'Translation Status legend', + } +); + +export const INSTALLED_STATUS_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.tableColumn.installedStatusTitle', + { + defaultMessage: 'Installed', + } +); From 354385213fa4936eea904e8da1954d886cf5a555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Mon, 20 Jan 2025 16:36:24 +0100 Subject: [PATCH 5/6] [Synonyms UI] Search synonyms set list (#206931) ## Summary Adds Synonyms set table and endpoint to the synonyms. Actions are just a placeholder and will be working in next PR following this with Delete modal. Screenshot 2025-01-16 at 13 43 44 Screenshot 2025-01-16 at 13 43 55 ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../search_synonyms/common/api_routes.ts | 10 +++ .../search_synonyms/common/pagination.ts | 43 +++++++++++++ .../search_synonyms/public/application.tsx | 18 ++++-- .../public/components/overview/overview.tsx | 15 ++++- .../synonym_sets/synonym_sets.test.tsx | 54 ++++++++++++++++ .../components/synonym_sets/synonym_sets.tsx | 64 +++++++++++++++++++ .../hooks/use_fetch_synonyms_sets.test.ts | 56 ++++++++++++++++ .../public/hooks/use_fetch_synonyms_sets.ts | 29 +++++++++ .../server/lib/fetch_synonym_sets.test.ts | 60 +++++++++++++++++ .../server/lib/fetch_synonym_sets.ts | 22 +++++++ .../plugins/search_synonyms/server/routes.ts | 57 ++++++++++++++++- .../server/utils/error_handler.ts | 28 ++++++++ .../plugins/search_synonyms/tsconfig.json | 3 + 13 files changed, 450 insertions(+), 9 deletions(-) create mode 100644 x-pack/solutions/search/plugins/search_synonyms/common/api_routes.ts create mode 100644 x-pack/solutions/search/plugins/search_synonyms/common/pagination.ts create mode 100644 x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.test.tsx create mode 100644 x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.tsx create mode 100644 x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.test.ts create mode 100644 x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.ts create mode 100644 x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.test.ts create mode 100644 x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.ts create mode 100644 x-pack/solutions/search/plugins/search_synonyms/server/utils/error_handler.ts diff --git a/x-pack/solutions/search/plugins/search_synonyms/common/api_routes.ts b/x-pack/solutions/search/plugins/search_synonyms/common/api_routes.ts new file mode 100644 index 0000000000000..00f459da50ac9 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/common/api_routes.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export enum APIRoutes { + SYNONYM_SETS = '/internal/search_synonyms/synonyms', +} diff --git a/x-pack/solutions/search/plugins/search_synonyms/common/pagination.ts b/x-pack/solutions/search/plugins/search_synonyms/common/pagination.ts new file mode 100644 index 0000000000000..5acaaf4654b81 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/common/pagination.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +export const DEFAULT_PAGE_VALUE: Page = { + from: 0, + size: 10, +}; + +export interface Pagination { + pageIndex: number; + pageSize: number; + totalItemCount: number; +} + +export interface Page { + from: number; // current page index, 0-based + size: number; +} + +export interface Paginate { + _meta: Pagination; + data: T[]; +} + +export function paginationToPage(pagination: Pagination): Page { + return { + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, + }; +} +export function pageToPagination(page: { from: number; size: number; total: number }) { + // Prevent divide-by-zero-error + const pageIndex = page.size ? Math.trunc(page.from / page.size) : 0; + return { + pageIndex, + pageSize: page.size, + totalItemCount: page.total, + }; +} diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/application.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/application.tsx index fb724a91a22ab..55bc184b647ff 100644 --- a/x-pack/solutions/search/plugins/search_synonyms/public/application.tsx +++ b/x-pack/solutions/search/plugins/search_synonyms/public/application.tsx @@ -12,9 +12,11 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { Route, Router, Routes } from '@kbn/shared-ux-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AppPluginStartDependencies } from './types'; import { SearchSynonymsOverview } from './components/overview/overview'; +const queryClient = new QueryClient({}); export const renderApp = async ( core: CoreStart, services: AppPluginStartDependencies, @@ -24,13 +26,15 @@ export const renderApp = async ( - - - - - - - + + + + + + + + + , diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx index 54d20246e7f92..e36ae0314a189 100644 --- a/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx +++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx @@ -8,13 +8,17 @@ import React, { useMemo } from 'react'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { useKibana } from '../../hooks/use_kibana'; +import { SynonymSets } from '../synonym_sets/synonym_sets'; +import { useFetchSynonymsSets } from '../../hooks/use_fetch_synonyms_sets'; import { EmptyPrompt } from '../empty_prompt/empty_prompt'; export const SearchSynonymsOverview = () => { const { services: { console: consolePlugin, history, searchNavigation }, } = useKibana(); + const { data: synonymsData, isInitialLoading } = useFetchSynonymsSets(); const embeddableConsole = useMemo( () => (consolePlugin?.EmbeddableConsole ? : null), @@ -28,7 +32,16 @@ export const SearchSynonymsOverview = () => { data-test-subj="searchSynonymsOverviewPage" solutionNav={searchNavigation?.useClassicNavigation(history)} > - + + {isInitialLoading && } + + {!isInitialLoading && synonymsData && synonymsData._meta.totalItemCount > 0 && ( + + )} + {!isInitialLoading && synonymsData && synonymsData._meta.totalItemCount === 0 && ( + + )} + {embeddableConsole} ); diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.test.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.test.tsx new file mode 100644 index 0000000000000..a06d4bad66edc --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 React from 'react'; + +import { render, screen } from '@testing-library/react'; +import { SynonymSets } from './synonym_sets'; + +jest.mock('../../hooks/use_fetch_synonyms_sets', () => ({ + useFetchSynonymsSets: () => ({ + data: { + data: [ + { + synonyms_set: 'Synonyms Set 1', + count: 2, + }, + { + synonyms_set: 'Synonyms Set 2', + count: 3, + }, + ], + _meta: { pageIndex: 0, pageSize: 10, totalItemCount: 2 }, + }, + isLoading: false, + isError: false, + }), +})); + +describe('Search Synonym Sets list', () => { + it('should render the list with synonym sets', () => { + render(); + const synonymSetTable = screen.getByTestId('synonyms-set-table'); + expect(synonymSetTable).toBeInTheDocument(); + + const synonymSetItemNames = screen.getAllByTestId('synonyms-set-item-name'); + expect(synonymSetItemNames).toHaveLength(2); + expect(synonymSetItemNames[0].textContent).toBe('Synonyms Set 1'); + expect(synonymSetItemNames[1].textContent).toBe('Synonyms Set 2'); + + const synonymSetItemRuleCounts = screen.getAllByTestId('synonyms-set-item-rule-count'); + expect(synonymSetItemRuleCounts).toHaveLength(2); + expect(synonymSetItemRuleCounts[0].textContent).toBe('2'); + expect(synonymSetItemRuleCounts[1].textContent).toBe('3'); + + const synonymSetItemPageSize = screen.getByTestId('tablePaginationPopoverButton'); + const synonymSetPageButton = screen.getByTestId('pagination-button-0'); + expect(synonymSetItemPageSize).toBeInTheDocument(); + expect(synonymSetPageButton).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.tsx new file mode 100644 index 0000000000000..66e33f81f9da5 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonym_sets/synonym_sets.tsx @@ -0,0 +1,64 @@ +/* + * 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 { SynonymsGetSynonymsSetsSynonymsSetItem } from '@elastic/elasticsearch/lib/api/types'; +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { DEFAULT_PAGE_VALUE, paginationToPage } from '../../../common/pagination'; +import { useFetchSynonymsSets } from '../../hooks/use_fetch_synonyms_sets'; + +export const SynonymSets = () => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_VALUE.size); + const { from } = paginationToPage({ pageIndex, pageSize, totalItemCount: 0 }); + const { data: synonyms } = useFetchSynonymsSets({ from, size: pageSize }); + + if (!synonyms) { + return null; + } + + const pagination = { + initialPageSize: 10, + pageSizeOptions: [10, 25, 50], + ...synonyms._meta, + pageSize, + pageIndex, + }; + const columns: Array> = [ + { + field: 'synonyms_set', + name: i18n.translate('xpack.searchSynonyms.synonymsSetTable.nameColumn', { + defaultMessage: 'Synonyms Set', + }), + render: (name: string) =>
{name}
, + }, + { + field: 'count', + name: i18n.translate('xpack.searchSynonyms.synonymsSetTable.ruleCount', { + defaultMessage: 'Rule Count', + }), + render: (ruleCount: number) => ( +
{ruleCount}
+ ), + }, + ]; + return ( +
+ { + setPageIndex(changedPage.index); + setPageSize(changedPage.size); + }} + /> +
+ ); +}; diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.test.ts b/x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.test.ts new file mode 100644 index 0000000000000..61b24ac3f69cd --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; + +const mockHttpGet = jest.fn(); + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn().mockImplementation(async ({ queryKey, queryFn, opts }) => { + try { + const res = await queryFn(); + return Promise.resolve(res); + } catch (e) { + // opts.onError(e); + } + }), +})); + +jest.mock('./use_kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + http: { + get: mockHttpGet, + }, + notifications: { + toasts: { + addError: jest.fn(), + }, + }, + }, + }), +})); + +describe('useFetchSynonymsSet Hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return synonyms set', async () => { + const synonyms = [ + { + id: '1', + synonyms: ['foo', 'bar'], + }, + ]; + mockHttpGet.mockReturnValue(synonyms); + const { useFetchSynonymsSets } = jest.requireActual('./use_fetch_synonyms_sets'); + + const { result } = renderHook(() => useFetchSynonymsSets()); + await waitFor(() => expect(result.current).resolves.toStrictEqual(synonyms)); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.ts b/x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.ts new file mode 100644 index 0000000000000..b2bd6dbd71788 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/public/hooks/use_fetch_synonyms_sets.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import type { SynonymsGetSynonymsSetsSynonymsSetItem } from '@elastic/elasticsearch/lib/api/types'; +import { DEFAULT_PAGE_VALUE, Page, Paginate } from '../../common/pagination'; +import { APIRoutes } from '../../common/api_routes'; +import { useKibana } from './use_kibana'; + +export const useFetchSynonymsSets = (page: Page = DEFAULT_PAGE_VALUE) => { + const { + services: { http }, + } = useKibana(); + return useQuery({ + queryKey: ['synonyms-sets-fetch', page.from, page.size], + queryFn: async () => { + return await http.get>( + APIRoutes.SYNONYM_SETS, + { + query: { from: page.from, size: page.size }, + } + ); + }, + }); +}; diff --git a/x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.test.ts b/x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.test.ts new file mode 100644 index 0000000000000..e96c2bc5489ae --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.test.ts @@ -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 { ElasticsearchClient } from '@kbn/core/server'; +import { fetchSynonymSets } from './fetch_synonym_sets'; + +describe('fetch synonym sets lib function', () => { + const mockClient = { + security: { + hasPrivileges: jest.fn(), + }, + synonyms: { + getSynonymsSets: jest.fn(), + }, + }; + + const client = () => mockClient as unknown as ElasticsearchClient; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return synonym sets', async () => { + mockClient.synonyms.getSynonymsSets.mockResolvedValue({ + count: 2, + results: [ + { + synonyms_set: 'my_synonyms_set', + count: 2, + }, + { + synonyms_set: 'my_synonyms_set_2', + count: 3, + }, + ], + }); + + const result = await fetchSynonymSets(client(), { from: 0, size: 10 }); + expect(result).toEqual({ + _meta: { + pageIndex: 0, + pageSize: 10, + totalItemCount: 2, + }, + data: [ + { + synonyms_set: 'my_synonyms_set', + count: 2, + }, + { + synonyms_set: 'my_synonyms_set_2', + count: 3, + }, + ], + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.ts b/x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.ts new file mode 100644 index 0000000000000..82e25fdb80170 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/server/lib/fetch_synonym_sets.ts @@ -0,0 +1,22 @@ +/* + * 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 { SynonymsGetSynonymsSetsSynonymsSetItem } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { Page, Paginate, pageToPagination } from '../../common/pagination'; + +export const fetchSynonymSets = async ( + client: ElasticsearchClient, + { from, size }: Page +): Promise> => { + const result = await client.synonyms.getSynonymsSets({ + from, + size, + }); + const _meta = pageToPagination({ from, size, total: result.count }); + return { _meta, data: result.results }; +}; diff --git a/x-pack/solutions/search/plugins/search_synonyms/server/routes.ts b/x-pack/solutions/search/plugins/search_synonyms/server/routes.ts index 9411887ffb311..4bcbb90f76b0a 100644 --- a/x-pack/solutions/search/plugins/search_synonyms/server/routes.ts +++ b/x-pack/solutions/search/plugins/search_synonyms/server/routes.ts @@ -6,5 +6,60 @@ */ import { IRouter, Logger } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { APIRoutes } from '../common/api_routes'; -export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) {} +import { errorHandler } from './utils/error_handler'; +import { fetchSynonymSets } from './lib/fetch_synonym_sets'; +import { DEFAULT_PAGE_VALUE } from '../common/pagination'; + +export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) { + router.get( + { + path: APIRoutes.SYNONYM_SETS, + options: { + access: 'internal', + tags: ['synonyms:read'], + }, + security: { + authz: { + requiredPrivileges: ['synonyms:read'], + }, + }, + validate: { + query: schema.object({ + from: schema.number({ defaultValue: DEFAULT_PAGE_VALUE.from }), + size: schema.number({ defaultValue: DEFAULT_PAGE_VALUE.size }), + }), + }, + }, + errorHandler(logger)(async (context, request, response) => { + const core = await context.core; + const { + client: { asCurrentUser }, + } = core.elasticsearch; + const user = core.security.authc.getCurrentUser(); + if (!user) { + return response.customError({ + statusCode: 502, + body: 'Could not retrieve current user, security plugin is not ready', + }); + } + const hasSearchSynonymsPrivilege = await asCurrentUser.security.hasPrivileges({ + cluster: ['manage_search_synonyms'], + }); + if (!hasSearchSynonymsPrivilege.has_all_requested) { + return response.forbidden({ + body: "Your user doesn't have manage_search_synonyms privileges", + }); + } + const result = await fetchSynonymSets(asCurrentUser, { + from: request.query.from, + size: request.query.size, + }); + return response.ok({ + body: result, + }); + }) + ); +} diff --git a/x-pack/solutions/search/plugins/search_synonyms/server/utils/error_handler.ts b/x-pack/solutions/search/plugins/search_synonyms/server/utils/error_handler.ts new file mode 100644 index 0000000000000..b4b3894125bdb --- /dev/null +++ b/x-pack/solutions/search/plugins/search_synonyms/server/utils/error_handler.ts @@ -0,0 +1,28 @@ +/* + * 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 { RequestHandlerWrapper } from '@kbn/core-http-server'; +import { KibanaServerError } from '@kbn/kibana-utils-plugin/common'; +import type { Logger } from '@kbn/logging'; + +function isKibanaServerError(error: any): error is KibanaServerError { + return error.statusCode && error.message; +} + +export const errorHandler: (logger: Logger) => RequestHandlerWrapper = (logger) => (handler) => { + return async (context, request, response) => { + try { + return await handler(context, request, response); + } catch (e) { + logger.error(e); + if (isKibanaServerError(e)) { + return response.customError({ statusCode: e.statusCode, body: e.message }); + } + throw e; + } + }; +}; diff --git a/x-pack/solutions/search/plugins/search_synonyms/tsconfig.json b/x-pack/solutions/search/plugins/search_synonyms/tsconfig.json index 027f552896e68..d1f54f9f43f0c 100644 --- a/x-pack/solutions/search/plugins/search_synonyms/tsconfig.json +++ b/x-pack/solutions/search/plugins/search_synonyms/tsconfig.json @@ -22,6 +22,9 @@ "@kbn/search-navigation", "@kbn/doc-links", "@kbn/shared-ux-page-kibana-template", + "@kbn/core-http-server", + "@kbn/kibana-utils-plugin", + "@kbn/logging", ], "exclude": [ "target/**/*", From 8d2a43a0ce235a9e1e9380ca6dec15f0f37ff616 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Mon, 20 Jan 2025 16:38:50 +0100 Subject: [PATCH 6/6] Replace Borealis `isServerless` conditional with YML config (#206690) Resolves https://github.com/elastic/eui-private/issues/171 Resolves https://github.com/elastic/eui-private/issues/177 ## Summary This PR addresses a prior PR review [comment](https://github.com/elastic/kibana/pull/203840/files#diff-bb850523655bac7adb30995553acabae9705435fa51e5b8bf13c483152db694a) by removing `isServerless` from the logic determining what theme should be used at runtime with a simple YML configuration setting instead. I added a non-public `uiSettings.experimental.defaultTheme` config property that defaults to `borealis` and is set to `amsterdam` in `serverless.yml`. Since the default theme is now (and should be) set to Borealis, I also updated `DEFAULT_THEME_NAME` and `FALLBACK_THEME_NAME` to reflect that. This doesn't have any impact on Serverless; it will keep using Amsterdam. Additionally, while making these changes, I wanted to simultaneously improve types and address earlier PR [comment](https://github.com/elastic/kibana/pull/199748#discussion_r1840402343). Now `SUPPORTED_THEME_NAMES` array is declared as `const` making the `ThemeName` type strict instead of resolving a generic `string` type. Usages were updated to use `ThemeName` instead of `string`, too. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine --- config/serverless.yml | 3 ++ .../src/theme_service.mock.ts | 2 +- .../common-internal/src/types.ts | 4 +- .../__snapshots__/modal_service.test.tsx.snap | 38 +++++++++---------- .../src/bootstrap/bootstrap_renderer.test.ts | 34 +++++++++-------- .../src/bootstrap/bootstrap_renderer.ts | 12 +++--- .../server-internal/src/rendering_service.tsx | 3 +- .../packages/ui-settings/common/src/theme.ts | 6 +-- .../src/settings/index.test.ts | 1 - .../server-internal/src/settings/index.ts | 4 +- .../src/settings/theme.test.ts | 35 ++++++++--------- .../server-internal/src/settings/theme.ts | 36 ++++++------------ .../server-internal/src/ui_settings_config.ts | 11 ++++++ .../src/ui_settings_service.ts | 5 +-- .../ui-settings/server-internal/tsconfig.json | 1 + .../public/services/theme/theme.test.tsx | 6 +-- 16 files changed, 102 insertions(+), 99 deletions(-) diff --git a/config/serverless.yml b/config/serverless.yml index e7629c5053fa4..cbaf654db3867 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -234,3 +234,6 @@ discover.enableUiSettingsValidations: true xpack.dataUsage.enabled: true # This feature is disabled in Serverless until fully tested within a Serverless environment xpack.dataUsage.enableExperimental: ['dataUsageDisabled'] + +# Ensure Serverless is using the Amsterdam theme +uiSettings.experimental.defaultTheme: "amsterdam" diff --git a/packages/core/theme/core-theme-browser-mocks/src/theme_service.mock.ts b/packages/core/theme/core-theme-browser-mocks/src/theme_service.mock.ts index e3d2b66645794..8c27cc8561cf7 100644 --- a/packages/core/theme/core-theme-browser-mocks/src/theme_service.mock.ts +++ b/packages/core/theme/core-theme-browser-mocks/src/theme_service.mock.ts @@ -14,7 +14,7 @@ import type { ThemeService } from '@kbn/core-theme-browser-internal'; const mockTheme: CoreTheme = { darkMode: false, - name: 'amsterdam', + name: 'borealis', }; const createThemeMock = (): CoreTheme => { diff --git a/src/core/packages/injected-metadata/common-internal/src/types.ts b/src/core/packages/injected-metadata/common-internal/src/types.ts index e988420720900..ce88073ca7bac 100644 --- a/src/core/packages/injected-metadata/common-internal/src/types.ts +++ b/src/core/packages/injected-metadata/common-internal/src/types.ts @@ -11,7 +11,7 @@ import type { PluginName, DiscoveredPlugin } from '@kbn/core-base-common'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import type { EnvironmentMode, PackageInfo } from '@kbn/config'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; -import type { DarkModeValue } from '@kbn/core-ui-settings-common'; +import type { DarkModeValue, ThemeName } from '@kbn/core-ui-settings-common'; import type { BrowserLoggingConfig } from '@kbn/core-logging-common-internal'; /** @internal */ @@ -41,7 +41,7 @@ export interface InjectedMetadataExternalUrlPolicy { /** @internal */ export interface InjectedMetadataTheme { darkMode: DarkModeValue; - name: string; + name: ThemeName; version: ThemeVersion; stylesheetPaths: { default: string[]; diff --git a/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap index d3e0381a65383..9717c0fea96c0 100644 --- a/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap @@ -111,7 +111,7 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -423,14 +423,14 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -812,21 +812,21 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -1126,21 +1126,21 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -1445,21 +1445,21 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -1753,21 +1753,21 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, Object { "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -2002,7 +2002,7 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -2142,7 +2142,7 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -2287,7 +2287,7 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], @@ -2421,7 +2421,7 @@ Array [ "type": "return", "value": Object { "darkMode": false, - "name": "amsterdam", + "name": "borealis", }, }, ], diff --git a/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.test.ts b/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.test.ts index 7c1bbfc85c29f..8576d075c1fbc 100644 --- a/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.test.ts +++ b/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.test.ts @@ -35,13 +35,13 @@ const createPackageInfo = (parts: Partial = {}): PackageInfo => ({ }); const getClientGetMockImplementation = - ({ darkMode, name }: { darkMode?: boolean; name?: string } = {}) => + ({ darkMode, name }: { darkMode?: boolean | string; name?: string } = {}) => (key: string) => { switch (key) { case 'theme:darkMode': return Promise.resolve(darkMode ?? false); case 'theme:name': - return Promise.resolve(name ?? 'amsterdam'); + return Promise.resolve(name ?? 'borealis'); } return Promise.resolve(); }; @@ -67,7 +67,7 @@ describe('bootstrapRenderer', () => { packageInfo = createPackageInfo(); userSettingsService = userSettingsServiceMock.createSetupContract(); - getThemeTagMock.mockReturnValue('v8light'); + getThemeTagMock.mockReturnValue('borealislight'); getPluginsBundlePathsMock.mockReturnValue(new Map()); renderTemplateMock.mockReturnValue('__rendered__'); getJsDependencyPathsMock.mockReturnValue([]); @@ -124,7 +124,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: true, }); }); @@ -141,7 +141,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: false, }); }); @@ -167,7 +167,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: true, }); }); @@ -192,7 +192,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: false, }); }); @@ -217,7 +217,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: false, }); }); @@ -247,7 +247,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: true, }); }); @@ -290,13 +290,17 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: true, }); }); it('calls getThemeTag with the correct parameters when darkMode is `system`', async () => { - uiSettingsClient.get.mockResolvedValue('system'); + uiSettingsClient.get.mockImplementation( + getClientGetMockImplementation({ + darkMode: 'system', + }) + ); const request = httpServerMock.createKibanaRequest(); @@ -307,7 +311,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'system', + name: 'borealis', darkMode: false, }); }); @@ -321,7 +325,7 @@ describe('bootstrapRenderer', () => { }); }); - it('does not call uiSettingsClient.get', async () => { + it('does not call uiSettingsClient.get with `theme:darkMode`', async () => { const request = httpServerMock.createKibanaRequest(); await renderer({ @@ -329,7 +333,7 @@ describe('bootstrapRenderer', () => { uiSettingsClient, }); - expect(uiSettingsClient.get).not.toHaveBeenCalled(); + expect(uiSettingsClient.get).not.toHaveBeenCalledWith('theme:darkMode'); }); it('calls getThemeTag with the default parameters', async () => { @@ -367,7 +371,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - name: 'v8', + name: 'borealis', darkMode: false, }); }); diff --git a/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.ts b/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.ts index f861f97922204..5b1cfc7c0c368 100644 --- a/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.ts +++ b/src/core/packages/rendering/server-internal/src/bootstrap/bootstrap_renderer.ts @@ -12,8 +12,10 @@ import { PackageInfo } from '@kbn/config'; import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server'; import { type DarkModeValue, + type ThemeName, DEFAULT_THEME_NAME, parseDarkModeValue, + parseThemeNameValue, } from '@kbn/core-ui-settings-common'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; @@ -62,13 +64,11 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({ return async function bootstrapRenderer({ uiSettingsClient, request, isAnonymousPage = false }) { let darkMode: DarkModeValue = false; - let themeName: string = DEFAULT_THEME_NAME; - - if (packageInfo.buildFlavor !== 'serverless') { - themeName = 'borealis'; - } + let themeName: ThemeName = DEFAULT_THEME_NAME; try { + themeName = parseThemeNameValue(await uiSettingsClient.get('theme:name')); + const authenticated = isAuthenticated(request); if (authenticated) { @@ -79,8 +79,6 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({ } else { darkMode = parseDarkModeValue(await uiSettingsClient.get('theme:darkMode')); } - - themeName = await uiSettingsClient.get('theme:name'); } } catch (e) { // just use the default values in case of connectivity issues with ES diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index a92c3dac485b5..babf533e51dba 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -21,6 +21,7 @@ import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; import { type DarkModeValue, + type ThemeName, parseDarkModeValue, parseThemeNameValue, type UiSettingsParams, @@ -212,7 +213,7 @@ export class RenderingService { darkMode = getSettingValue('theme:darkMode', settings, parseDarkModeValue); } - const themeName = getSettingValue('theme:name', settings, parseThemeNameValue); + const themeName = getSettingValue('theme:name', settings, parseThemeNameValue); const themeStylesheetPaths = (mode: boolean) => getThemeStylesheetPaths({ diff --git a/src/core/packages/ui-settings/common/src/theme.ts b/src/core/packages/ui-settings/common/src/theme.ts index 6c95531cddc38..33d613e19ad56 100644 --- a/src/core/packages/ui-settings/common/src/theme.ts +++ b/src/core/packages/ui-settings/common/src/theme.ts @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export const DEFAULT_THEME_NAME = 'amsterdam'; -export const SUPPORTED_THEME_NAMES = ['amsterdam', 'borealis']; +export const DEFAULT_THEME_NAME = 'borealis'; +export const SUPPORTED_THEME_NAMES = ['amsterdam', 'borealis'] as const; export type ThemeName = (typeof SUPPORTED_THEME_NAMES)[number]; @@ -37,7 +37,7 @@ export type ThemeTags = readonly ThemeTag[]; */ export const DEFAULT_THEME_TAGS: ThemeTags = SUPPORTED_THEME_TAGS; -export const FALLBACK_THEME_TAG: ThemeTag = 'v8light'; +export const FALLBACK_THEME_TAG: ThemeTag = 'borealislight'; const isValidTag = (tag: unknown) => SUPPORTED_THEME_TAGS.includes(tag as (typeof SUPPORTED_THEME_TAGS)[number]); diff --git a/src/core/packages/ui-settings/server-internal/src/settings/index.test.ts b/src/core/packages/ui-settings/server-internal/src/settings/index.test.ts index b4e30e7c3599d..bc0c1a2a47710 100644 --- a/src/core/packages/ui-settings/server-internal/src/settings/index.test.ts +++ b/src/core/packages/ui-settings/server-internal/src/settings/index.test.ts @@ -17,7 +17,6 @@ import { getStateSettings } from './state'; import { getAnnouncementsSettings } from './announcements'; const defaultOptions: GetCoreSettingsOptions = { - isServerless: false, isDist: true, isThemeSwitcherEnabled: undefined, }; diff --git a/src/core/packages/ui-settings/server-internal/src/settings/index.ts b/src/core/packages/ui-settings/server-internal/src/settings/index.ts index a9ea940605bd1..5e348d2aec097 100644 --- a/src/core/packages/ui-settings/server-internal/src/settings/index.ts +++ b/src/core/packages/ui-settings/server-internal/src/settings/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { UiSettingsParams } from '@kbn/core-ui-settings-common'; +import type { ThemeName, UiSettingsParams } from '@kbn/core-ui-settings-common'; import { getAccessibilitySettings } from './accessibility'; import { getDateFormatSettings } from './date_formats'; import { getMiscUiSettings } from './misc'; @@ -17,9 +17,9 @@ import { getStateSettings } from './state'; import { getAnnouncementsSettings } from './announcements'; export interface GetCoreSettingsOptions { - isServerless: boolean; isDist: boolean; isThemeSwitcherEnabled: boolean | undefined; + defaultTheme?: ThemeName; } export const getCoreSettings = ( diff --git a/src/core/packages/ui-settings/server-internal/src/settings/theme.test.ts b/src/core/packages/ui-settings/server-internal/src/settings/theme.test.ts index 45d6f48f9d535..c18a11fda10db 100644 --- a/src/core/packages/ui-settings/server-internal/src/settings/theme.test.ts +++ b/src/core/packages/ui-settings/server-internal/src/settings/theme.test.ts @@ -11,7 +11,6 @@ import type { UiSettingsParams } from '@kbn/core-ui-settings-common'; import { getThemeSettings, type GetThemeSettingsOptions } from './theme'; const defaultOptions: GetThemeSettingsOptions = { - isServerless: false, isDist: true, isThemeSwitcherEnabled: undefined, }; @@ -42,6 +41,8 @@ describe('theme settings', () => { const validate = getValidationFn(themeSettings['theme:name']); it('should only accept expected values', () => { + // TODO: Remove amsterdam theme + // https://github.com/elastic/eui-private/issues/170 expect(() => validate('amsterdam')).not.toThrow(); expect(() => validate('borealis')).not.toThrow(); @@ -50,25 +51,24 @@ describe('theme settings', () => { }); describe('readonly', () => { - it('should be readonly when `isServerless = true`', () => { + it('should not be editable when `isThemeSwitcherEnabled` is falsy', () => { + expect(getThemeSettings(defaultOptions)['theme:name'].readonly).toBe(true); expect( - getThemeSettings({ ...defaultOptions, isServerless: true })['theme:name'].readonly + getThemeSettings({ + ...defaultOptions, + isThemeSwitcherEnabled: false, + })['theme:name'].readonly ).toBe(true); - expect( - getThemeSettings({ ...defaultOptions, isServerless: false })['theme:name'].readonly - ).toBe(false); }); it('should be editable when `isThemeSwitcherEnabled = true`', () => { expect( - getThemeSettings({ ...defaultOptions, isServerless: true, isThemeSwitcherEnabled: true })[ - 'theme:name' - ].readonly + getThemeSettings({ ...defaultOptions, isThemeSwitcherEnabled: true })['theme:name'] + .readonly ).toBe(false); expect( getThemeSettings({ ...defaultOptions, - isServerless: false, isThemeSwitcherEnabled: true, })['theme:name'].readonly ).toBe(false); @@ -76,16 +76,17 @@ describe('theme settings', () => { }); describe('value', () => { - it('should default to `amsterdam` when `isServerless = true`', () => { - expect( - getThemeSettings({ ...defaultOptions, isServerless: true })['theme:name'].value - ).toBe('amsterdam'); + it('should default to `borealis`', () => { + expect(getThemeSettings(defaultOptions)['theme:name'].value).toBe('borealis'); }); - it('should default to `borealis` when `isServerless = false`', () => { + it('should use the `defaultTheme` value when defined', () => { expect( - getThemeSettings({ ...defaultOptions, isServerless: false })['theme:name'].value - ).toBe('borealis'); + getThemeSettings({ + ...defaultOptions, + defaultTheme: 'amsterdam', + })['theme:name'].value + ).toBe('amsterdam'); }); }); }); diff --git a/src/core/packages/ui-settings/server-internal/src/settings/theme.ts b/src/core/packages/ui-settings/server-internal/src/settings/theme.ts index ef8b3df843ea9..87551111bf91c 100644 --- a/src/core/packages/ui-settings/server-internal/src/settings/theme.ts +++ b/src/core/packages/ui-settings/server-internal/src/settings/theme.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; +import type { Writable } from '@kbn/utility-types'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import { type UiSettingsParams, @@ -17,18 +18,17 @@ import { SUPPORTED_THEME_NAMES, DEFAULT_THEME_NAME, } from '@kbn/core-ui-settings-common'; +import { defaultThemeSchema } from '../ui_settings_config'; interface ThemeInfo { defaultDarkMode: boolean; - defaultThemeName: ThemeName; } -const getThemeInfo = ({ isDist, isServerless }: GetThemeSettingsOptions): ThemeInfo => { +const getThemeInfo = ({ isDist }: GetThemeSettingsOptions): ThemeInfo => { const themeTags = parseThemeTags(process.env.KBN_OPTIMIZER_THEMES); const themeInfo: ThemeInfo = { defaultDarkMode: false, - defaultThemeName: DEFAULT_THEME_NAME, }; if (!isDist) { @@ -36,30 +36,20 @@ const getThemeInfo = ({ isDist, isServerless }: GetThemeSettingsOptions): ThemeI themeInfo.defaultDarkMode = themeTags[0]?.endsWith('dark') || false; } - if (!isServerless) { - // Default to Borealis theme in non-serverless - themeInfo.defaultThemeName = 'borealis'; - } - return themeInfo; }; export interface GetThemeSettingsOptions { - isServerless: boolean; isDist: boolean; isThemeSwitcherEnabled: boolean | undefined; + defaultTheme?: ThemeName; } export const getThemeSettings = ( options: GetThemeSettingsOptions ): Record => { - const { defaultDarkMode, defaultThemeName } = getThemeInfo(options); - - // Make `theme:name` readonly in serverless unless the theme switcher is enabled - let isThemeNameReadonly = options.isServerless; - if (options.isThemeSwitcherEnabled !== undefined) { - isThemeNameReadonly = !options.isThemeSwitcherEnabled; - } + const { defaultDarkMode } = getThemeInfo(options); + const defaultTheme = options.defaultTheme ?? DEFAULT_THEME_NAME; return { 'theme:darkMode': { @@ -122,7 +112,8 @@ export const getThemeSettings = ( defaultMessage: 'Theme', }), type: 'select', - options: SUPPORTED_THEME_NAMES, + // Cast to a mutable array to satisfy the `UiSettingsParams.options` type + options: SUPPORTED_THEME_NAMES as Writable, optionLabels: { amsterdam: i18n.translate('core.ui_settings.params.themeName.options.amsterdam', { defaultMessage: 'Amsterdam', @@ -131,15 +122,10 @@ export const getThemeSettings = ( defaultMessage: 'Borealis', }), }, - value: defaultThemeName, - readonly: isThemeNameReadonly, + value: defaultTheme, + readonly: !options.isThemeSwitcherEnabled, requiresPageReload: true, - schema: schema.oneOf([ - schema.literal('amsterdam'), - schema.literal('borealis'), - // Allow experimental themes - schema.string(), - ]), + schema: defaultThemeSchema, }, }; }; diff --git a/src/core/packages/ui-settings/server-internal/src/ui_settings_config.ts b/src/core/packages/ui-settings/server-internal/src/ui_settings_config.ts index 04b7ff6b0f558..1dd1cd8273892 100644 --- a/src/core/packages/ui-settings/server-internal/src/ui_settings_config.ts +++ b/src/core/packages/ui-settings/server-internal/src/ui_settings_config.ts @@ -9,6 +9,7 @@ import { schema, TypeOf, offeringBasedSchema } from '@kbn/config-schema'; import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import { DEFAULT_THEME_NAME } from '@kbn/core-ui-settings-common'; import { ConfigDeprecationProvider } from '@kbn/config'; const deprecations: ConfigDeprecationProvider = ({ unused, renameFromRoot }) => [ @@ -16,12 +17,22 @@ const deprecations: ConfigDeprecationProvider = ({ unused, renameFromRoot }) => renameFromRoot('server.defaultRoute', 'uiSettings.overrides.defaultRoute', { level: 'warning' }), ]; +export const defaultThemeSchema = schema.oneOf([ + // TODO: Remove amsterdam theme + // https://github.com/elastic/eui-private/issues/170 + schema.literal('amsterdam'), + schema.literal('borealis'), + // Allow experimental themes + schema.string(), +]); + const configSchema = schema.object({ overrides: schema.object({}, { unknowns: 'allow' }), publicApiEnabled: offeringBasedSchema({ serverless: schema.boolean({ defaultValue: false }) }), experimental: schema.maybe( schema.object({ themeSwitcherEnabled: schema.maybe(schema.boolean({ defaultValue: false })), + defaultTheme: schema.maybe(schema.string({ defaultValue: DEFAULT_THEME_NAME })), }) ), }); diff --git a/src/core/packages/ui-settings/server-internal/src/ui_settings_service.ts b/src/core/packages/ui-settings/server-internal/src/ui_settings_service.ts index 83e968761776f..ba2ccdab5e626 100644 --- a/src/core/packages/ui-settings/server-internal/src/ui_settings_service.ts +++ b/src/core/packages/ui-settings/server-internal/src/ui_settings_service.ts @@ -18,6 +18,7 @@ import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-ser import type { InternalSavedObjectsServiceSetup } from '@kbn/core-saved-objects-server-internal'; import type { ReadonlyModeType, + ThemeName, UiSettingsParams, UiSettingsScope, } from '@kbn/core-ui-settings-common'; @@ -53,7 +54,6 @@ export class UiSettingsService private readonly config$: Observable; private readonly isDist: boolean; private readonly isDev: boolean; - private readonly isServerless: boolean; private readonly uiSettingsDefaults = new Map(); private readonly uiSettingsGlobalDefaults = new Map(); private overrides: Record = {}; @@ -64,7 +64,6 @@ export class UiSettingsService this.isDist = coreContext.env.packageInfo.dist; this.config$ = coreContext.configService.atPath(uiConfigDefinition.path); this.isDev = coreContext.env.mode.dev; - this.isServerless = coreContext.env.packageInfo.buildFlavor === 'serverless'; } public async preboot(): Promise { @@ -76,8 +75,8 @@ export class UiSettingsService this.register( getCoreSettings({ isDist: this.isDist, - isServerless: this.isServerless, isThemeSwitcherEnabled: experimental?.themeSwitcherEnabled, + defaultTheme: experimental?.defaultTheme as ThemeName, }) ); diff --git a/src/core/packages/ui-settings/server-internal/tsconfig.json b/src/core/packages/ui-settings/server-internal/tsconfig.json index 09887438dda56..f0e81686a2c78 100644 --- a/src/core/packages/ui-settings/server-internal/tsconfig.json +++ b/src/core/packages/ui-settings/server-internal/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/core-logging-server-mocks", "@kbn/core-saved-objects-api-server-internal", "@kbn/core-saved-objects-common", + "@kbn/utility-types", ], "exclude": [ "target/**/*", diff --git a/src/platform/plugins/shared/charts/public/services/theme/theme.test.tsx b/src/platform/plugins/shared/charts/public/services/theme/theme.test.tsx index b478dc284d0d6..5c45da9472401 100644 --- a/src/platform/plugins/shared/charts/public/services/theme/theme.test.tsx +++ b/src/platform/plugins/shared/charts/public/services/theme/theme.test.tsx @@ -18,7 +18,7 @@ import { ThemeService } from './theme'; import { coreMock } from '@kbn/core/public/mocks'; const createTheme$Mock = (mode: boolean) => { - return from([{ darkMode: mode, name: 'amsterdam' }]); + return from([{ darkMode: mode, name: 'borealis' }]); }; const { theme: setUpMockTheme } = coreMock.createSetup(); @@ -36,7 +36,7 @@ describe('ThemeService', () => { expect(await themeService.darkModeEnabled$.pipe(take(1)).toPromise()).toStrictEqual({ darkMode: false, - name: 'amsterdam', + name: 'borealis', }); }); @@ -47,7 +47,7 @@ describe('ThemeService', () => { expect(await themeService.darkModeEnabled$.pipe(take(1)).toPromise()).toStrictEqual({ darkMode: true, - name: 'amsterdam', + name: 'borealis', }); }); });