From 1f673dc9f12e90a6aa41a903fee8b0adafcdcaf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Mon, 16 Sep 2024 10:10:36 -0400 Subject: [PATCH 01/16] Consistent scheduling when tasks run within the poll interval of their original time (#190093) Resolves https://github.com/elastic/kibana/issues/189114 In this PR, I'm changing the logic to calculate the task's next run at. Whenever the gap between the task's runAt and when it was picked up is less than the poll interval, we'll use the `runAt` to schedule the next. This way we don't continuously add time to the task's next run (ex: running every 1m turns into every 1m 3s). I've had to modify a few tests to have a more increased interval because this made tasks run more frequently (on time), which introduced flakiness. ## To verify 1. Create an alerting rule that runs every 10s 2. Apply the following diff to your code ``` diff --git a/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts b/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts index 55d5f85e5d3..4342dcdd845 100644 --- a/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts +++ b/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts @@ -31,5 +31,7 @@ export function getNextRunAt( Date.now() ); + console.log(`*** Next run at: ${new Date(nextCalculatedRunAt).toISOString()}, interval=${newSchedule?.interval ?? schedule.interval}, originalRunAt=${originalRunAt.toISOString()}, startedAt=${startedAt.toISOString()}`); + return new Date(nextCalculatedRunAt); } ``` 3. Observe the logs, the gap between runAt and startedAt should be less than the poll interval, so the next run at is based on `runAt` instead of `startedAt`. 4. Stop Kibana for 15 seconds then start it again 5. Observe the first logs when the rule runs again and notice now that the gap between runAt and startedAt is larger than the poll interval, the next run at is based on `startedAt` instead of `runAt` to spread the tasks out evenly. --------- Co-authored-by: Elastic Machine --- .../task_manager/server/config.mock.ts | 17 ++++++ .../server/lib/get_next_run_at.test.ts | 58 +++++++++++++++++++ .../server/lib/get_next_run_at.ts | 25 ++++++++ .../task_manager/server/polling_lifecycle.ts | 5 +- .../server/task_running/task_runner.test.ts | 26 +++++++-- .../server/task_running/task_runner.ts | 38 ++++++++---- .../alerting/group1/get_action_error_log.ts | 2 +- .../builtin_alert_types/es_query/common.ts | 2 +- .../builtin_alert_types/es_query/esql_only.ts | 12 ++-- .../builtin_alert_types/es_query/rule.ts | 24 ++++---- 10 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/config.mock.ts create mode 100644 x-pack/plugins/task_manager/server/lib/get_next_run_at.test.ts create mode 100644 x-pack/plugins/task_manager/server/lib/get_next_run_at.ts diff --git a/x-pack/plugins/task_manager/server/config.mock.ts b/x-pack/plugins/task_manager/server/config.mock.ts new file mode 100644 index 0000000000000..513dea71d39dd --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type TaskManagerConfig, configSchema } from './config'; + +const createConfigMock = (overwrites: Partial = {}) => { + const mocked: TaskManagerConfig = configSchema.validate(overwrites); + return mocked; +}; + +export const configMock = { + create: createConfigMock, +}; diff --git a/x-pack/plugins/task_manager/server/lib/get_next_run_at.test.ts b/x-pack/plugins/task_manager/server/lib/get_next_run_at.test.ts new file mode 100644 index 0000000000000..efa7cf90ae15f --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/get_next_run_at.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { taskManagerMock } from '../mocks'; + +import { getNextRunAt } from './get_next_run_at'; + +describe('getNextRunAt', () => { + test('should use startedAt when the task delay is greater than the threshold', () => { + const now = new Date(); + // Use time in the past to ensure the task delay calculation isn't relative to "now" + const fiveSecondsAgo = new Date(now.getTime() - 5000); + const fourSecondsAgo = new Date(now.getTime() - 4000); + const nextRunAt = getNextRunAt( + taskManagerMock.createTask({ + schedule: { interval: '1m' }, + runAt: fiveSecondsAgo, + startedAt: fourSecondsAgo, + }), + 500 + ); + expect(nextRunAt).toEqual(new Date(fourSecondsAgo.getTime() + 60000)); + }); + + test('should use runAt when the task delay is greater than the threshold', () => { + const now = new Date(); + // Use time in the past to ensure the task delay calculation isn't relative to "now" + const fiveSecondsAgo = new Date(now.getTime() - 5000); + const aBitLessThanFiveSecondsAgo = new Date(now.getTime() - 4995); + const nextRunAt = getNextRunAt( + taskManagerMock.createTask({ + schedule: { interval: '1m' }, + runAt: fiveSecondsAgo, + startedAt: aBitLessThanFiveSecondsAgo, + }), + 500 + ); + expect(nextRunAt).toEqual(new Date(fiveSecondsAgo.getTime() + 60000)); + }); + + test('should not schedule in the past', () => { + const testStart = new Date(); + const fiveMinsAgo = new Date(Date.now() - 300000); + const nextRunAt = getNextRunAt( + taskManagerMock.createTask({ + schedule: { interval: '1m' }, + runAt: fiveMinsAgo, + startedAt: fiveMinsAgo, + }), + 0 + ); + expect(nextRunAt.getTime()).toBeGreaterThanOrEqual(testStart.getTime()); + }); +}); diff --git a/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts b/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts new file mode 100644 index 0000000000000..a25960e61ee29 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/get_next_run_at.ts @@ -0,0 +1,25 @@ +/* + * 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 { intervalFromDate } from './intervals'; +import type { ConcreteTaskInstance } from '../task'; + +export function getNextRunAt( + { runAt, startedAt, schedule }: Pick, + taskDelayThresholdForPreciseScheduling: number = 0 +): Date { + const taskDelay = startedAt!.getTime() - runAt.getTime(); + const scheduleFromDate = taskDelay < taskDelayThresholdForPreciseScheduling ? runAt : startedAt!; + + // Ensure we also don't schedule in the past by performing the Math.max with Date.now() + const nextCalculatedRunAt = Math.max( + intervalFromDate(scheduleFromDate, schedule!.interval)!.getTime(), + Date.now() + ); + + return new Date(nextCalculatedRunAt); +} diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index b8d41391f1411..81a65009391f6 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -84,6 +84,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter; private logger: Logger; public pool: TaskPool; @@ -122,6 +123,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter this.events$.next(event); @@ -220,9 +222,10 @@ export class TaskPollingLifecycle implements ITaskEventEmitter secondsFromNow(mins * 60); +const getNextRunAtSpy = jest.spyOn(nextRunAtUtils, 'getNextRunAt'); let fakeTimer: sinon.SinonFakeTimers; @@ -977,6 +981,8 @@ describe('TaskManagerRunner', () => { expect(instance.params).toEqual({ a: 'b' }); expect(instance.state).toEqual({ hey: 'there' }); expect(instance.enabled).not.toBeDefined(); + + expect(getNextRunAtSpy).not.toHaveBeenCalled(); }); test('reschedules tasks that have an schedule', async () => { @@ -1007,6 +1013,8 @@ describe('TaskManagerRunner', () => { expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); expect(instance.enabled).not.toBeDefined(); + + expect(getNextRunAtSpy).toHaveBeenCalled(); }); test('expiration returns time after which timeout will have elapsed from start', async () => { @@ -1084,6 +1092,8 @@ describe('TaskManagerRunner', () => { expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt }), { validate: true, }); + + expect(getNextRunAtSpy).not.toHaveBeenCalled(); }); test('reschedules tasks that return a schedule', async () => { @@ -1114,6 +1124,11 @@ describe('TaskManagerRunner', () => { expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt }), { validate: true, }); + + expect(getNextRunAtSpy).toHaveBeenCalledWith( + expect.objectContaining({ schedule }), + expect.any(Number) + ); }); test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { @@ -2479,12 +2494,15 @@ describe('TaskManagerRunner', () => { onTaskEvent: opts.onTaskEvent, executionContext, usageCounter, - eventLoopDelayConfig: { - monitor: true, - warn_threshold: 5000, - }, + config: configMock.create({ + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, + }), allowReadingInvalidState: opts.allowReadingInvalidState || false, strategy: opts.strategy ?? CLAIM_STRATEGY_UPDATE_BY_QUERY, + pollIntervalConfiguration$: new BehaviorSubject(500), }); if (stage === TaskRunningStage.READY_TO_RUN) { diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index b92c363a7972c..32b48c5caf58b 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -11,6 +11,7 @@ * rescheduling, middleware application, etc. */ +import { Observable } from 'rxjs'; import apm from 'elastic-apm-node'; import { v4 as uuidv4 } from 'uuid'; import { withSpan } from '@kbn/apm-utils'; @@ -55,9 +56,10 @@ import { } from '../task'; import { TaskTypeDictionary } from '../task_type_dictionary'; import { isUnrecoverableError } from './errors'; -import { CLAIM_STRATEGY_MGET, type EventLoopDelayConfig } from '../config'; +import { CLAIM_STRATEGY_MGET, type TaskManagerConfig } from '../config'; import { TaskValidator } from '../task_validator'; import { getRetryAt, getRetryDate, getTimeout } from '../lib/get_retry_at'; +import { getNextRunAt } from '../lib/get_next_run_at'; export const EMPTY_RUN_RESULT: SuccessfulRunResult = { state: {} }; @@ -108,9 +110,10 @@ type Opts = { defaultMaxAttempts: number; executionContext: ExecutionContextStart; usageCounter?: UsageCounter; - eventLoopDelayConfig: EventLoopDelayConfig; + config: TaskManagerConfig; allowReadingInvalidState: boolean; strategy: string; + pollIntervalConfiguration$: Observable; } & Pick; export enum TaskRunResult { @@ -160,9 +163,10 @@ export class TaskManagerRunner implements TaskRunner { private uuid: string; private readonly executionContext: ExecutionContextStart; private usageCounter?: UsageCounter; - private eventLoopDelayConfig: EventLoopDelayConfig; + private config: TaskManagerConfig; private readonly taskValidator: TaskValidator; private readonly claimStrategy: string; + private currentPollInterval: number; /** * Creates an instance of TaskManagerRunner. @@ -185,9 +189,10 @@ export class TaskManagerRunner implements TaskRunner { onTaskEvent = identity, executionContext, usageCounter, - eventLoopDelayConfig, + config, allowReadingInvalidState, strategy, + pollIntervalConfiguration$, }: Opts) { this.instance = asPending(sanitizeInstance(instance)); this.definitions = definitions; @@ -200,13 +205,17 @@ export class TaskManagerRunner implements TaskRunner { this.executionContext = executionContext; this.usageCounter = usageCounter; this.uuid = uuidv4(); - this.eventLoopDelayConfig = eventLoopDelayConfig; + this.config = config; this.taskValidator = new TaskValidator({ logger: this.logger, definitions: this.definitions, allowReadingInvalidState, }); this.claimStrategy = strategy; + this.currentPollInterval = config.poll_interval; + pollIntervalConfiguration$.subscribe((pollInterval) => { + this.currentPollInterval = pollInterval; + }); } /** @@ -335,7 +344,7 @@ export class TaskManagerRunner implements TaskRunner { const apmTrans = apm.startTransaction(this.taskType, TASK_MANAGER_RUN_TRANSACTION_TYPE, { childOf: this.instance.task.traceparent, }); - const stopTaskTimer = startTaskTimerWithEventLoopMonitoring(this.eventLoopDelayConfig); + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring(this.config.event_loop_delay); // Validate state const stateValidationResult = this.validateTaskState(this.instance.task); @@ -637,13 +646,20 @@ export class TaskManagerRunner implements TaskRunner { return asOk({ status: TaskStatus.ShouldDelete }); } - const { startedAt, schedule } = this.instance.task; - + const updatedTaskSchedule = reschedule ?? this.instance.task.schedule; return asOk({ runAt: - runAt || intervalFromDate(startedAt!, reschedule?.interval ?? schedule?.interval)!, + runAt || + getNextRunAt( + { + runAt: this.instance.task.runAt, + startedAt: this.instance.task.startedAt, + schedule: updatedTaskSchedule, + }, + this.currentPollInterval + ), state, - schedule: reschedule ?? schedule, + schedule: updatedTaskSchedule, attempts, status: TaskStatus.Idle, }); @@ -791,7 +807,7 @@ export class TaskManagerRunner implements TaskRunner { const { eventLoopBlockMs = 0 } = taskTiming; const taskLabel = `${this.taskType} ${this.instance.task.id}`; - if (eventLoopBlockMs > this.eventLoopDelayConfig.warn_threshold) { + if (eventLoopBlockMs > this.config.event_loop_delay.warn_threshold) { this.logger.warn( `event loop blocked for at least ${eventLoopBlockMs} ms while running task ${taskLabel}`, { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/get_action_error_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/get_action_error_log.ts index dbb8cee8673b8..2a25cf481407e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/get_action_error_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/get_action_error_log.ts @@ -146,7 +146,7 @@ export default function createGetActionErrorLogTests({ getService }: FtrProvider .send( getTestRuleData({ rule_type_id: 'test.cumulative-firing', - schedule: { interval: '5s' }, + schedule: { interval: '6s' }, actions: [ { id: createdConnector1.id, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts index 26d8c64a30296..a08dba15f77ba 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts @@ -20,7 +20,7 @@ export const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; export const ES_TEST_DATA_STREAM_NAME = 'test-data-stream'; export const RULE_INTERVALS_TO_WRITE = 5; -export const RULE_INTERVAL_SECONDS = 4; +export const RULE_INTERVAL_SECONDS = 6; export const RULE_INTERVAL_MILLIS = RULE_INTERVAL_SECONDS * 1000; export const ES_GROUPS_TO_WRITE = 3; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts index 10aaf21a28a07..e748b56bd64cb 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/esql_only.ts @@ -82,7 +82,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { }); const docs = await waitForDocs(2); - const messagePattern = /Document count is \d+ in the last 20s. Alert when greater than 0./; + const messagePattern = /Document count is \d+ in the last 30s. Alert when greater than 0./; for (let i = 0; i < docs.length; i++) { const doc = docs[i]; @@ -136,7 +136,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); expect(title).to.be(`rule 'always fire' matched query`); - const messagePattern = /Document count is \d+ in the last 20s. Alert when greater than 0./; + const messagePattern = /Document count is \d+ in the last 30s. Alert when greater than 0./; expect(message).to.match(messagePattern); expect(hits).not.to.be.empty(); } @@ -156,7 +156,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); expect(title).to.be(`rule 'always fire' matched query`); - const messagePattern = /Document count is \d+ in the last 20s. Alert when greater than 0./; + const messagePattern = /Document count is \d+ in the last 30s. Alert when greater than 0./; expect(message).to.match(messagePattern); expect(hits).not.to.be.empty(); } @@ -186,7 +186,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(activeTitle).to.be(`rule 'fire then recovers' matched query`); expect(activeValue).to.be('1'); expect(activeMessage).to.match( - /Document count is \d+ in the last 4s. Alert when greater than 0./ + /Document count is \d+ in the last 6s. Alert when greater than 0./ ); await createEsDocumentsInGroups(1, endDate); docs = await waitForDocs(2); @@ -200,7 +200,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(recoveredName).to.be('fire then recovers'); expect(recoveredTitle).to.be(`rule 'fire then recovers' recovered`); expect(recoveredMessage).to.match( - /Document count is \d+ in the last 4s. Alert when greater than 0./ + /Document count is \d+ in the last 6s. Alert when greater than 0./ ); }); @@ -223,7 +223,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { 'from test-data-stream | stats c = count(@timestamp) by host.hostname, host.name, host.id | where c > -1', }); - const messagePattern = /Document count is \d+ in the last 20s. Alert when greater than 0./; + const messagePattern = /Document count is \d+ in the last 30s. Alert when greater than 0./; const docs = await waitForDocs(2); for (let i = 0; i < docs.length; i++) { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts index d53c985f616f3..5ad588a6924de 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts @@ -151,7 +151,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); const messagePattern = - /Document count is \d+.?\d* in the last 20s in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; + /Document count is \d+.?\d* in the last 30s in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; for (let i = 0; i < docs.length; i++) { const doc = docs[i]; @@ -269,7 +269,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { await initData(); const messagePattern = - /Document count is \d+.?\d* in the last 20s in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; + /Document count is \d+.?\d* in the last 30s in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; const docs = await waitForDocs(2); for (let i = 0; i < docs.length; i++) { @@ -391,7 +391,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { await initData(); const messagePattern = - /Document count is \d+.?\d* in the last 20s for group-\d+ in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; + /Document count is \d+.?\d* in the last 30s for group-\d+ in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; const titlePattern = /rule 'always fire' matched query for group group-\d/; const conditionPattern = /Number of matching documents for group "group-\d" is greater than -1/; @@ -478,7 +478,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { await initData(); const messagePattern = - /Document count is \d+.?\d* in the last 20s for group-\d+,\d+ in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; + /Document count is \d+.?\d* in the last 30s for group-\d+,\d+ in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; const titlePattern = /rule 'always fire' matched query for group group-\d+,\d+/; const conditionPattern = /Number of matching documents for group "group-\d+,\d+" is greater than -1/; @@ -608,7 +608,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { const titlePattern = /rule 'always fire' matched query for group group-\d/; expect(title).to.match(titlePattern); const messagePattern = - /Document count is \d+.?\d* in the last 20s for group-\d+ in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; + /Document count is \d+.?\d* in the last 30s for group-\d+ in .kibana-alerting-test-data (?:index|data view). Alert when greater than -1./; expect(message).to.match(messagePattern); expect(hits).not.to.be.empty(); @@ -696,7 +696,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); expect(title).to.be(`rule 'always fire' matched query`); const messagePattern = - /Document count is \d+.?\d* in the last 20s in .kibana-alerting-test-data (?:index|data view). ./; + /Document count is \d+.?\d* in the last 30s in .kibana-alerting-test-data (?:index|data view). ./; expect(message).to.match(messagePattern); expect(hits).not.to.be.empty(); @@ -806,7 +806,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(name).to.be('fires once'); expect(title).to.be(`rule 'fires once' matched query`); const messagePattern = - /Document count is \d+.?\d* in the last 20s in .kibana-alerting-test-data (?:index|data view). Alert when greater than or equal to 0./; + /Document count is \d+.?\d* in the last 30s in .kibana-alerting-test-data (?:index|data view). Alert when greater than or equal to 0./; expect(message).to.match(messagePattern); expect(hits).not.to.be.empty(); expect(previousTimestamp).to.be.empty(); @@ -866,7 +866,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); expect(title).to.be(`rule 'always fire' matched query`); const messagePattern = - /Document count is \d+.?\d* in the last 20s in .kibana-alerting-test-data (?:index|data view). Alert when less than 1./; + /Document count is \d+.?\d* in the last 30s in .kibana-alerting-test-data (?:index|data view). Alert when less than 1./; expect(message).to.match(messagePattern); expect(hits).to.be.empty(); @@ -944,7 +944,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(activeTitle).to.be(`rule 'fire then recovers' matched query`); expect(activeValue).to.be('0'); expect(activeMessage).to.match( - /Document count is \d+.?\d* in the last 4s in .kibana-alerting-test-data (?:index|data view). Alert when less than 1./ + /Document count is \d+.?\d* in the last 6s in .kibana-alerting-test-data (?:index|data view). Alert when less than 1./ ); await createEsDocumentsInGroups(1, endDate); @@ -959,7 +959,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(recoveredName).to.be('fire then recovers'); expect(recoveredTitle).to.be(`rule 'fire then recovers' recovered`); expect(recoveredMessage).to.match( - /Document count is \d+.?\d* in the last 4s in .kibana-alerting-test-data (?:index|data view). Alert when less than 1./ + /Document count is \d+.?\d* in the last 6s in .kibana-alerting-test-data (?:index|data view). Alert when less than 1./ ); }) ); @@ -1044,7 +1044,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { await initData(); const messagePattern = - /Document count is \d+.?\d* in the last 20s in test-data-stream (?:index|data view). Alert when greater than -1./; + /Document count is \d+.?\d* in the last 30s in test-data-stream (?:index|data view). Alert when greater than -1./; const docs = await waitForDocs(2); for (let i = 0; i < docs.length; i++) { @@ -1179,7 +1179,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(docs[0]._source.hits.length).greaterThan(0); const messagePattern = - /Document count is \d+.?\d* in the last 20s in .kibana-alerting-test-data (?:index|data view). Alert when greater than 0./; + /Document count is \d+.?\d* in the last 30s in .kibana-alerting-test-data (?:index|data view). Alert when greater than 0./; expect(docs[0]._source.params.message).to.match(messagePattern); expect(docs[1]._source.hits.length).to.be(0); From fd67f2d92ef299e11274c89a7195ed2cf6de1808 Mon Sep 17 00:00:00 2001 From: Bharat Pasupula <123897612+bhapas@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:33:04 +0200 Subject: [PATCH 02/16] [Automatic Import] Change the integrations owner type to community (#193002) ## Summary Currently the integrations created by `Automatic Import` are set to `elastic`. But `community` fits better. Once the custom integrations generated by Automatic Import are moved into upstream `elastic/integrations` repository appropriate owner type can be defined in the contribution PR. Screenshot 2024-09-16 at 14 12 17 - Closes https://github.com/elastic/kibana/issues/192917 ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../server/integration_builder/build_integration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts b/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts index 73113f6bf7b04..0598ee3ba2cca 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts @@ -174,7 +174,7 @@ function createPackageManifestDict( ], owner: { github: package_owner, - type: 'elastic', + type: 'community', }, }; From 8e94a4ba57508f36cf7ec38d52dc141565757a5a Mon Sep 17 00:00:00 2001 From: Tre Date: Mon, 16 Sep 2024 15:40:45 +0100 Subject: [PATCH 03/16] [FTR][ML] Cleanup ML Job Table and JobDetails Services (#191720) ## Summary Services such as `MachineLearningJobExpandedDetailsProvider` and `MachineLearningJobTableProvider` share some responsibility. We need to clarify what goes where, such that the design of the services makes the most sense with respect to where the methods live. - Dropped "class" syntax of job table service, for simple object literal (not super important, but "I was in the neighborhood") - Mv `assertJobRowDetailsCounts` jobTable -> jobExpandedDetails - Mv `clickJobRowCalendarWithAssertion` jobTable -> jobExpandedDetails - Mv `assertJobRowCalendars` jobTable -> jobExpandedDetails - Mv `openAnnotationsTab` jobTable -> jobExpandedDetails - Mv `assertJobListMultiSelectionText` jobExpandedDetails -> jobTable --------- Co-authored-by: Elastic Machine --- .../ml/anomaly_detection_jobs/advanced_job.ts | 4 +- .../categorization_job.ts | 4 +- .../anomaly_detection_jobs/date_nanos_job.ts | 2 +- .../apps/ml/anomaly_detection_jobs/geo_job.ts | 4 +- .../job_expanded_details.ts | 4 +- .../multi_metric_job.ts | 4 +- .../anomaly_detection_jobs/population_job.ts | 4 +- .../saved_search_job.ts | 2 +- .../single_metric_job.ts | 4 +- ...ingle_metric_job_without_datafeed_start.ts | 2 +- .../annotations.ts | 10 +- .../short_tests/settings/calendar_creation.ts | 11 +- .../services/ml/job_expanded_details.ts | 59 ++- .../test/functional/services/ml/job_table.ts | 365 +++++++----------- 14 files changed, 223 insertions(+), 256 deletions(-) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/advanced_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/advanced_job.ts index ea5d70fcbe069..370580fe604dc 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/advanced_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/advanced_job.ts @@ -423,7 +423,7 @@ export default function ({ getService }: FtrProviderContext) { ...testData.expected.row, }); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( testData.jobId, { job_id: testData.jobId, @@ -638,7 +638,7 @@ export default function ({ getService }: FtrProviderContext) { ...testData.expected.row, }); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( testData.jobIdClone, { job_id: testData.jobIdClone, diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/categorization_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/categorization_job.ts index 07929a8f9b6f9..eb3708c129205 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/categorization_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/categorization_job.ts @@ -228,7 +228,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobId, getExpectedRow(jobId, jobGroups)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobId, getExpectedCounts(jobId), getExpectedModelSizeStats(jobId) @@ -343,7 +343,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobIdClone, getExpectedRow(jobIdClone, jobGroupsClone)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobIdClone, getExpectedCounts(jobIdClone), getExpectedModelSizeStats(jobIdClone) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/date_nanos_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/date_nanos_job.ts index e5ffd4c193949..c513e1ee10bdb 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/date_nanos_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/date_nanos_job.ts @@ -298,7 +298,7 @@ export default function ({ getService }: FtrProviderContext) { ...testData.expected.row, }); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( testData.jobId, { job_id: testData.jobId, diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/geo_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/geo_job.ts index a95ba4782c413..f5ed246f939d2 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/geo_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/geo_job.ts @@ -219,7 +219,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobId, getExpectedRow(jobId, jobGroups)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobId, getExpectedCounts(jobId), getExpectedModelSizeStats(jobId) @@ -339,7 +339,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobIdClone, getExpectedRow(jobIdClone, jobGroupsClone)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobIdClone, getExpectedCounts(jobIdClone), getExpectedModelSizeStats(jobIdClone) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/job_expanded_details.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/job_expanded_details.ts index e48ca875bb1f2..bddcd564bdd18 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/job_expanded_details.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/job_expanded_details.ts @@ -113,10 +113,10 @@ export default function ({ getService }: FtrProviderContext) { it('multi-selection with one opened job should only present the opened job when job list is filtered by the Opened button', async () => { await ml.jobTable.selectAllJobs(); - await ml.jobExpandedDetails.assertJobListMultiSelectionText('2 jobs selected'); + await ml.jobTable.assertJobListMultiSelectionText('2 jobs selected'); await ml.jobTable.filterByState(QuickFilterButtonTypes.Opened); await ml.jobTable.assertJobsInTable([jobId]); - await ml.jobExpandedDetails.assertJobListMultiSelectionText('1 job selected'); + await ml.jobTable.assertJobListMultiSelectionText('1 job selected'); }); it('multi-selection with one closed job should only present the closed job when job list is filtered by the Closed button', async () => { diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/multi_metric_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/multi_metric_job.ts index 24f385704bd71..c60c4d21bc92b 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/multi_metric_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/multi_metric_job.ts @@ -244,7 +244,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobId, getExpectedRow(jobId, jobGroups)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobId, getExpectedCounts(jobId), getExpectedModelSizeStats(jobId) @@ -376,7 +376,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobIdClone, getExpectedRow(jobIdClone, jobGroupsClone)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobIdClone, getExpectedCounts(jobIdClone), getExpectedModelSizeStats(jobIdClone) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/population_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/population_job.ts index 1dd7801fa334c..9ef7aea22bb6b 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/population_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/population_job.ts @@ -259,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobId, getExpectedRow(jobId, jobGroups)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobId, getExpectedCounts(jobId), getExpectedModelSizeStats(jobId) @@ -402,7 +402,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobIdClone, getExpectedRow(jobIdClone, jobGroupsClone)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobIdClone, getExpectedCounts(jobIdClone), getExpectedModelSizeStats(jobIdClone) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/saved_search_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/saved_search_job.ts index 414230b0b73a1..342a8a13eebbe 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/saved_search_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/saved_search_job.ts @@ -424,7 +424,7 @@ export default function ({ getService }: FtrProviderContext) { ...testData.expected.row, }); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( testData.jobId, { job_id: testData.jobId, diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts index 957ac090e1ade..411b013deb64c 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts @@ -219,7 +219,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobId, getExpectedRow(jobId, jobGroups)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobId, getExpectedCounts(jobId), getExpectedModelSizeStats(jobId) @@ -357,7 +357,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobIdClone, getExpectedRow(jobIdClone, jobGroupsClone)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobIdClone, getExpectedCounts(jobIdClone), getExpectedModelSizeStats(jobIdClone) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job_without_datafeed_start.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job_without_datafeed_start.ts index e137f366628e7..89fbd1213e6e8 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job_without_datafeed_start.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job_without_datafeed_start.ts @@ -143,7 +143,7 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobTable.assertJobRowFields(jobId, getExpectedRow(jobId)); - await ml.jobTable.assertJobRowDetailsCounts( + await ml.jobExpandedDetails.assertJobRowDetailsCounts( jobId, getExpectedCounts(jobId), getExpectedModelSizeStats(jobId) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/annotations.ts b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/annotations.ts index ab1177d2dbc84..acae757510aa4 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/annotations.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/annotations.ts @@ -90,7 +90,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display created annotation in job list'); await ml.navigation.navigateToJobManagement(); await ml.jobTable.filterWithSearchString(jobId, 1); - await ml.jobTable.openAnnotationsTab(jobId); + await ml.jobExpandedDetails.openAnnotationsTab(jobId); await ml.jobAnnotations.assertAnnotationExists({ annotation: newText, event: 'user', @@ -124,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.navigation.navigateToJobManagement(); await ml.jobTable.filterWithSearchString(jobId, 1); - await ml.jobTable.openAnnotationsTab(jobId); + await ml.jobExpandedDetails.openAnnotationsTab(jobId); await ml.jobAnnotations.assertAnnotationContentById( annotationId, expectedOriginalAnnotation @@ -177,7 +177,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display edited annotation in job list'); await ml.navigation.navigateToJobManagement(); await ml.jobTable.filterWithSearchString(jobId, 1); - await ml.jobTable.openAnnotationsTab(jobId); + await ml.jobExpandedDetails.openAnnotationsTab(jobId); await ml.jobAnnotations.assertAnnotationContentById(annotationId, expectedEditedAnnotation); }); }); @@ -197,7 +197,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.navigation.navigateToMl(); await ml.navigation.navigateToJobManagement(); await ml.jobTable.filterWithSearchString(jobId, 1); - await ml.jobTable.openAnnotationsTab(jobId); + await ml.jobExpandedDetails.openAnnotationsTab(jobId); await ml.jobAnnotations.openDatafeedChartFlyout(annotationId, jobId); await ml.jobAnnotations.assertDelayedDataChartExists(); @@ -252,7 +252,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('does not show the deleted annotation in job list'); await ml.navigation.navigateToJobManagement(); await ml.jobTable.filterWithSearchString(jobId, 1); - await ml.jobTable.openAnnotationsTab(jobId); + await ml.jobExpandedDetails.openAnnotationsTab(jobId); await ml.jobAnnotations.assertAnnotationsRowMissing(annotationId); }); }); diff --git a/x-pack/test/functional/apps/ml/short_tests/settings/calendar_creation.ts b/x-pack/test/functional/apps/ml/short_tests/settings/calendar_creation.ts index 15eac59357928..78a15a64ce0bd 100644 --- a/x-pack/test/functional/apps/ml/short_tests/settings/calendar_creation.ts +++ b/x-pack/test/functional/apps/ml/short_tests/settings/calendar_creation.ts @@ -146,8 +146,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.navigation.navigateToAnomalyDetection(); - await ml.jobTable.assertJobRowCalendars('test_calendar_ad_1', [calendarId]); - await ml.jobTable.clickJobRowCalendarWithAssertion('test_calendar_ad_1', calendarId); + await ml.jobExpandedDetails.assertJobRowCalendars('test_calendar_ad_1', [calendarId]); + await ml.jobExpandedDetails.clickJobRowCalendarWithAssertion( + 'test_calendar_ad_1', + calendarId + ); await ml.testExecution.logTestStep( 'created calendars can be connected to job groups after creation' @@ -161,8 +164,8 @@ export default function ({ getService }: FtrProviderContext) { 'multi-metric', ]); await ml.navigation.navigateToAnomalyDetection(); - await ml.jobTable.assertJobRowCalendars('test_calendar_ad_4', [calendarId]); - await ml.jobTable.assertJobRowCalendars('test_calendar_ad_3', [calendarId], false); + await ml.jobExpandedDetails.assertJobRowCalendars('test_calendar_ad_4', [calendarId]); + await ml.jobExpandedDetails.assertJobRowCalendars('test_calendar_ad_3', [calendarId], false); }); async function assignJobToCalendar( diff --git a/x-pack/test/functional/services/ml/job_expanded_details.ts b/x-pack/test/functional/services/ml/job_expanded_details.ts index d9c82d72eabc4..bf5c7b2c87b5b 100644 --- a/x-pack/test/functional/services/ml/job_expanded_details.ts +++ b/x-pack/test/functional/services/ml/job_expanded_details.ts @@ -21,6 +21,14 @@ export function MachineLearningJobExpandedDetailsProvider( const headerPage = getPageObject('header'); return { + async openAnnotationsTab(jobId: string) { + await retry.tryForTime(10000, async () => { + await jobTable.ensureDetailsOpen(jobId); + await testSubjects.click(jobTable.detailsSelector(jobId, 'mlJobListTab-annotations')); + await testSubjects.existOrFail('mlAnnotationsTable'); + }); + }, + async clickEditAnnotationAction(jobId: string, annotationId: string) { await jobAnnotationsTable.ensureAnnotationsActionsMenuOpen(annotationId); await testSubjects.click('mlAnnotationsActionEdit'); @@ -77,7 +85,7 @@ export function MachineLearningJobExpandedDetailsProvider( const { _id: annotationId }: { _id: string } = annotationsFromApi[0]; await jobTable.ensureDetailsOpen(jobId); - await jobTable.openAnnotationsTab(jobId); + await this.openAnnotationsTab(jobId); await this.clearSearchButton(); await jobAnnotationsTable.ensureAnnotationsActionsMenuOpen(annotationId); await testSubjects.click('mlAnnotationsActionOpenInSingleMetricViewer'); @@ -92,7 +100,7 @@ export function MachineLearningJobExpandedDetailsProvider( await this.assertAnnotationsFromApi(annotationsFromApi); await jobTable.ensureDetailsOpen(jobId); - await jobTable.openAnnotationsTab(jobId); + await this.openAnnotationsTab(jobId); await this.clearSearchButton(); const { _id: annotationId }: { _id: string } = annotationsFromApi[0]; @@ -107,7 +115,7 @@ export function MachineLearningJobExpandedDetailsProvider( await jobTable.ensureDetailsClosed(jobId); await jobTable.withDetailsOpen(jobId, async () => { - await jobTable.openAnnotationsTab(jobId); + await this.openAnnotationsTab(jobId); await this.clearSearchButton(); const visibleText = await testSubjects.getVisibleText( jobTable.detailsSelector(jobId, 'mlAnnotationsColumnAnnotation') @@ -118,7 +126,7 @@ export function MachineLearningJobExpandedDetailsProvider( async assertDataFeedFlyout(jobId: string): Promise { await jobTable.withDetailsOpen(jobId, async () => { - await jobTable.openAnnotationsTab(jobId); + await this.openAnnotationsTab(jobId); await this.clearSearchButton(); await testSubjects.click(jobTable.detailsSelector(jobId, 'euiCollapsedItemActionsButton')); await testSubjects.click('mlAnnotationsActionViewDatafeed'); @@ -162,9 +170,46 @@ export function MachineLearningJobExpandedDetailsProvider( }); }, - async assertJobListMultiSelectionText(expectedMsg: string): Promise { - const visibleText = await testSubjects.getVisibleText('~mlADJobListMultiSelectActionsArea'); - expect(visibleText).to.be(expectedMsg); + async clickJobRowCalendarWithAssertion(jobId: string, calendarId: string): Promise { + await jobTable.ensureDetailsOpen(jobId); + const calendarSelector = `mlJobDetailsCalendar-${calendarId}`; + await testSubjects.existOrFail(calendarSelector, { + timeout: 3_000, + }); + await testSubjects.click(calendarSelector, 3_000); + await testSubjects.existOrFail('mlPageCalendarEdit > mlCalendarFormEdit', { + timeout: 3_000, + }); + const calendarTitleVisibleText = await testSubjects.getVisibleText('mlCalendarTitle'); + expect(calendarTitleVisibleText).to.contain( + calendarId, + `Calendar page title should contain [${calendarId}], got [${calendarTitleVisibleText}]` + ); + }, + + async assertJobRowDetailsCounts( + jobId: string, + expectedCounts: object, + expectedModelSizeStats: object + ) { + const { counts, modelSizeStats } = await jobTable.parseJobCounts(jobId); + + // Only check for expected keys / values, ignore additional properties + // This way the tests stay stable when new properties are added on the ES side + for (const [key, value] of Object.entries(expectedCounts)) { + expect(counts) + .to.have.property(key) + .eql(value, `Expected counts property '${key}' to exist with value '${value}'`); + } + + for (const [key, value] of Object.entries(expectedModelSizeStats)) { + expect(modelSizeStats) + .to.have.property(key) + .eql( + value, + `Expected model size stats property '${key}' to exist with value '${value}')` + ); + } }, }; } diff --git a/x-pack/test/functional/services/ml/job_table.ts b/x-pack/test/functional/services/ml/job_table.ts index 97ce1858bc2f1..bd19a31f62b54 100644 --- a/x-pack/test/functional/services/ml/job_table.ts +++ b/x-pack/test/functional/services/ml/job_table.ts @@ -55,21 +55,21 @@ export function MachineLearningJobTableProvider( const testSubjects = getService('testSubjects'); const retry = getService('retry'); - return new (class MlJobTable { - public async selectAllJobs(): Promise { + return { + async selectAllJobs(): Promise { await testSubjects.click('checkboxSelectAll'); - } + }, - public async assertJobsInTable(expectedJobIds: string[]) { + async assertJobsInTable(expectedJobIds: string[]) { const sortedExpectedIds = expectedJobIds.sort(); const sortedActualJobIds = (await this.parseJobTable()).map((row) => row.id).sort(); expect(sortedActualJobIds).to.eql( sortedExpectedIds, `Expected jobs in table to be [${sortedExpectedIds}], got [${sortedActualJobIds}]` ); - } + }, - public async filterByState(quickFilterButton: QuickFilterButtonTypes): Promise { + async filterByState(quickFilterButton: QuickFilterButtonTypes): Promise { const searchBar: WebElementWrapper = await testSubjects.find('mlJobListSearchBar'); const quickFilter: WebElementWrapper = await searchBar.findByCssSelector( `span[data-text="${quickFilterButton}"]` @@ -86,46 +86,9 @@ export function MachineLearningJobTableProvider( quickFilterButton, `Expected visible text of pressed quick filter button to equal [${quickFilterButton}], but got [${pressedBttnText}]` ); - } + }, - public async clickJobRowCalendarWithAssertion( - jobId: string, - calendarId: string - ): Promise { - await this.ensureDetailsOpen(jobId); - const calendarSelector = `mlJobDetailsCalendar-${calendarId}`; - await testSubjects.existOrFail(calendarSelector, { - timeout: 3_000, - }); - await testSubjects.click(calendarSelector, 3_000); - await testSubjects.existOrFail('mlPageCalendarEdit > mlCalendarFormEdit', { - timeout: 3_000, - }); - const calendarTitleVisibleText = await testSubjects.getVisibleText('mlCalendarTitle'); - expect(calendarTitleVisibleText).to.contain( - calendarId, - `Calendar page title should contain [${calendarId}], got [${calendarTitleVisibleText}]` - ); - } - - public async assertJobRowCalendars( - jobId: string, - expectedCalendars: string[], - checkForExists: boolean = true - ): Promise { - await this.withDetailsOpen(jobId, async function verifyJobRowCalendars(): Promise { - for await (const expectedCalendar of expectedCalendars) { - const calendarSelector = `mlJobDetailsCalendar-${expectedCalendar}`; - await testSubjects[checkForExists ? 'existOrFail' : 'missingOrFail'](calendarSelector, { - timeout: 3_000, - }); - if (checkForExists) - expect(await testSubjects.getVisibleText(calendarSelector)).to.be(expectedCalendar); - } - }); - } - - public async parseJobTable( + async parseJobTable( tableEnvironment: 'mlAnomalyDetection' | 'stackMgmtJobList' = 'mlAnomalyDetection' ) { const table = await testSubjects.find('~mlJobListTable'); @@ -215,9 +178,10 @@ export function MachineLearningJobTableProvider( } return rows; - } + }, - public async parseJobCounts(jobId: string) { + // TODO: Mv this fn over too + async parseJobCounts(jobId: string) { return await this.withDetailsOpen(jobId, async () => { // click counts tab await testSubjects.click(this.detailsSelector(jobId, 'mlJobListTab-counts')); @@ -248,59 +212,51 @@ export function MachineLearningJobTableProvider( modelSizeStats: await parseTable(modelSizeStatsTable), }; }); - } + }, - public rowSelector(jobId: string, subSelector?: string) { + rowSelector(jobId: string, subSelector?: string) { const row = `~mlJobListTable > ~row-${jobId}`; return !subSelector ? row : `${row} > ${subSelector}`; - } + }, - public detailsSelector(jobId: string, subSelector?: string) { + detailsSelector(jobId: string, subSelector?: string) { const row = `~mlJobListTable > ~details-${jobId}`; return !subSelector ? row : `${row} > ${subSelector}`; - } + }, - public async withDetailsOpen(jobId: string, block: () => Promise): Promise { + async withDetailsOpen(jobId: string, block: () => Promise): Promise { await this.ensureDetailsOpen(jobId); try { return await block(); } finally { await this.ensureDetailsClosed(jobId); } - } + }, - public async ensureDetailsOpen(jobId: string) { + async ensureDetailsOpen(jobId: string) { await retry.tryForTime(10000, async () => { if (!(await testSubjects.exists(this.detailsSelector(jobId)))) { await testSubjects.click(this.rowSelector(jobId, 'mlJobListRowDetailsToggle')); await testSubjects.existOrFail(this.detailsSelector(jobId), { timeout: 1000 }); } }); - } + }, - public async ensureDetailsClosed(jobId: string) { + async ensureDetailsClosed(jobId: string) { await retry.tryForTime(10000, async () => { if (await testSubjects.exists(this.detailsSelector(jobId))) { await testSubjects.click(this.rowSelector(jobId, 'mlJobListRowDetailsToggle')); await testSubjects.missingOrFail(this.detailsSelector(jobId), { timeout: 1000 }); } }); - } - - public async openAnnotationsTab(jobId: string) { - await retry.tryForTime(10000, async () => { - await this.ensureDetailsOpen(jobId); - await testSubjects.click(this.detailsSelector(jobId, 'mlJobListTab-annotations')); - await testSubjects.existOrFail('mlAnnotationsTable'); - }); - } + }, - public async waitForRefreshButtonLoaded(buttonTestSubj: string) { + async waitForRefreshButtonLoaded(buttonTestSubj: string) { await testSubjects.existOrFail(`~${buttonTestSubj}`, { timeout: 10 * 1000 }); await testSubjects.existOrFail(`${buttonTestSubj} loaded`, { timeout: 30 * 1000 }); - } + }, - public async refreshJobList( + async refreshJobList( tableEnvironment: 'mlAnomalyDetection' | 'stackMgmtJobList' = 'mlAnomalyDetection' ) { const testSubjStr = @@ -312,14 +268,14 @@ export function MachineLearningJobTableProvider( await testSubjects.click(`~${testSubjStr}`); await this.waitForRefreshButtonLoaded(testSubjStr); await this.waitForJobsToLoad(); - } + }, - public async waitForJobsToLoad() { + async waitForJobsToLoad() { await testSubjects.existOrFail('~mlJobListTable', { timeout: 60 * 1000 }); await testSubjects.existOrFail('mlJobListTable loaded', { timeout: 30 * 1000 }); - } + }, - public async filterWithSearchString( + async filterWithSearchString( filter: string, expectedRowCount: number = 1, tableEnvironment: 'mlAnomalyDetection' | 'stackMgmtJobList' = 'mlAnomalyDetection' @@ -339,9 +295,9 @@ export function MachineLearningJobTableProvider( filteredRows )}')` ); - } + }, - public async assertJobRowFields(jobId: string, expectedRow: object) { + async assertJobRowFields(jobId: string, expectedRow: object) { await retry.tryForTime(5000, async () => { await this.refreshJobList(); const rows = await this.parseJobTable(); @@ -353,46 +309,18 @@ export function MachineLearningJobTableProvider( )}')` ); }); - } + }, - public async assertJobRowJobId(jobId: string) { + async assertJobRowJobId(jobId: string) { await retry.tryForTime(5000, async () => { await this.refreshJobList(); const rows = await this.parseJobTable(); const jobRowMatch = rows.find((row) => row.id === jobId); expect(jobRowMatch).to.not.eql(undefined, `Expected row with job ID ${jobId} to exist`); }); - } + }, - public async assertJobRowDetailsCounts( - jobId: string, - expectedCounts: object, - expectedModelSizeStats: object - ) { - const { counts, modelSizeStats } = await this.parseJobCounts(jobId); - - // Only check for expected keys / values, ignore additional properties - // This way the tests stay stable when new properties are added on the ES side - for (const [key, value] of Object.entries(expectedCounts)) { - expect(counts) - .to.have.property(key) - .eql(value, `Expected counts property '${key}' to exist with value '${value}'`); - } - - for (const [key, value] of Object.entries(expectedModelSizeStats)) { - expect(modelSizeStats) - .to.have.property(key) - .eql( - value, - `Expected model size stats property '${key}' to exist with value '${value}')` - ); - } - } - - public async assertJobActionSingleMetricViewerButtonEnabled( - jobId: string, - expectedValue: boolean - ) { + async assertJobActionSingleMetricViewerButtonEnabled(jobId: string, expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( this.rowSelector(jobId, 'mlOpenJobsInSingleMetricViewerButton') ); @@ -402,12 +330,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionAnomalyExplorerButtonEnabled( - jobId: string, - expectedValue: boolean - ) { + async assertJobActionAnomalyExplorerButtonEnabled(jobId: string, expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( this.rowSelector(jobId, 'mlOpenJobsInAnomalyExplorerButton') ); @@ -417,9 +342,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionsMenuButtonEnabled(jobId: string, expectedValue: boolean) { + async assertJobActionsMenuButtonEnabled(jobId: string, expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( this.rowSelector(jobId, 'euiCollapsedItemActionsButton') ); @@ -429,9 +354,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionStartDatafeedButtonEnabled(jobId: string, expectedValue: boolean) { + async assertJobActionStartDatafeedButtonEnabled(jobId: string, expectedValue: boolean) { await this.ensureJobActionsMenuOpen(jobId); const isEnabled = await testSubjects.isEnabled('mlActionButtonStartDatafeed'); expect(isEnabled).to.eql( @@ -440,9 +365,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionResetJobButtonEnabled(jobId: string, expectedValue: boolean) { + async assertJobActionResetJobButtonEnabled(jobId: string, expectedValue: boolean) { await this.ensureJobActionsMenuOpen(jobId); const isEnabled = await testSubjects.isEnabled('mlActionButtonResetJob'); expect(isEnabled).to.eql( @@ -451,9 +376,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionCloneJobButtonEnabled(jobId: string, expectedValue: boolean) { + async assertJobActionCloneJobButtonEnabled(jobId: string, expectedValue: boolean) { await this.ensureJobActionsMenuOpen(jobId); const isEnabled = await testSubjects.isEnabled('mlActionButtonCloneJob'); expect(isEnabled).to.eql( @@ -462,12 +387,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionViewDatafeedCountsButtonEnabled( - jobId: string, - expectedValue: boolean - ) { + async assertJobActionViewDatafeedCountsButtonEnabled(jobId: string, expectedValue: boolean) { await this.ensureJobActionsMenuOpen(jobId); const isEnabled = await testSubjects.isEnabled('mlActionButtonViewDatafeedChart'); expect(isEnabled).to.eql( @@ -476,9 +398,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionEditJobButtonEnabled(jobId: string, expectedValue: boolean) { + async assertJobActionEditJobButtonEnabled(jobId: string, expectedValue: boolean) { await this.ensureJobActionsMenuOpen(jobId); const isEnabled = await testSubjects.isEnabled('mlActionButtonEditJob'); expect(isEnabled).to.eql( @@ -487,9 +409,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertJobActionDeleteJobButtonEnabled(jobId: string, expectedValue: boolean) { + async assertJobActionDeleteJobButtonEnabled(jobId: string, expectedValue: boolean) { await this.ensureJobActionsMenuOpen(jobId); const isEnabled = await testSubjects.isEnabled('mlActionButtonDeleteJob'); expect(isEnabled).to.eql( @@ -498,51 +420,51 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async ensureJobActionsMenuOpen(jobId: string) { + async ensureJobActionsMenuOpen(jobId: string) { await retry.tryForTime(30 * 1000, async () => { if (!(await testSubjects.exists('mlActionButtonDeleteJob'))) { await testSubjects.click(this.rowSelector(jobId, 'euiCollapsedItemActionsButton')); await testSubjects.existOrFail('mlActionButtonDeleteJob', { timeout: 5000 }); } }); - } + }, - public async clickCloneJobAction(jobId: string) { + async clickCloneJobAction(jobId: string) { await this.ensureJobActionsMenuOpen(jobId); await testSubjects.click('mlActionButtonCloneJob'); await testSubjects.existOrFail('~mlPageJobWizard'); - } + }, - public async clickCloneJobActionWhenNoDataViewExists(jobId: string) { + async clickCloneJobActionWhenNoDataViewExists(jobId: string) { await this.ensureJobActionsMenuOpen(jobId); await testSubjects.click('mlActionButtonCloneJob'); await this.assertNoDataViewForCloneJobWarningToastExist(); - } + }, - public async assertNoDataViewForCloneJobWarningToastExist() { + async assertNoDataViewForCloneJobWarningToastExist() { await testSubjects.existOrFail('mlCloneJobNoDataViewExistsWarningToast', { timeout: 5000 }); - } + }, - public async clickEditJobAction(jobId: string) { + async clickEditJobAction(jobId: string) { await this.ensureJobActionsMenuOpen(jobId); await testSubjects.click('mlActionButtonEditJob'); await testSubjects.existOrFail('mlJobEditFlyout'); - } + }, - public async clickDeleteJobAction(jobId: string) { + async clickDeleteJobAction(jobId: string) { await this.ensureJobActionsMenuOpen(jobId); await testSubjects.click('mlActionButtonDeleteJob'); await testSubjects.existOrFail('mlDeleteJobConfirmModal'); - } + }, - public async confirmDeleteJobModal() { + async confirmDeleteJobModal() { await testSubjects.click('mlDeleteJobConfirmModal > mlDeleteJobConfirmModalButton'); await testSubjects.missingOrFail('mlDeleteJobConfirmModal', { timeout: 30 * 1000 }); - } + }, - public async clickDeleteAnnotationsInDeleteJobModal(checked: boolean) { + async clickDeleteAnnotationsInDeleteJobModal(checked: boolean) { await testSubjects.setEuiSwitch( 'mlDeleteJobConfirmModal > mlDeleteJobConfirmModalDeleteAnnotationsSwitch', checked ? 'check' : 'uncheck' @@ -552,23 +474,23 @@ export function MachineLearningJobTableProvider( ); expect(isChecked).to.eql(checked, `Expected delete annotations switch to be ${checked}`); - } + }, - public async clickOpenJobInSingleMetricViewerButton(jobId: string) { + async clickOpenJobInSingleMetricViewerButton(jobId: string) { await testSubjects.click(this.rowSelector(jobId, 'mlOpenJobsInSingleMetricViewerButton')); await testSubjects.existOrFail('~mlPageSingleMetricViewer'); - } + }, - public async clickOpenJobInAnomalyExplorerButton(jobId: string) { + async clickOpenJobInAnomalyExplorerButton(jobId: string) { await testSubjects.click(this.rowSelector(jobId, 'mlOpenJobsInAnomalyExplorerButton')); await testSubjects.existOrFail('~mlPageAnomalyExplorer'); - } + }, - public async isJobRowSelected(jobId: string): Promise { + async isJobRowSelected(jobId: string): Promise { return await testSubjects.isChecked(this.rowSelector(jobId, `checkboxSelectRow-${jobId}`)); - } + }, - public async assertJobRowSelected(jobId: string, expectedValue: boolean) { + async assertJobRowSelected(jobId: string, expectedValue: boolean) { const isSelected = await this.isJobRowSelected(jobId); expect(isSelected).to.eql( expectedValue, @@ -576,37 +498,37 @@ export function MachineLearningJobTableProvider( expectedValue ? 'selected' : 'deselected' }' (got '${isSelected ? 'selected' : 'deselected'}')` ); - } + }, - public async selectJobRow(jobId: string) { + async selectJobRow(jobId: string) { if ((await this.isJobRowSelected(jobId)) === false) { await testSubjects.click(this.rowSelector(jobId, `checkboxSelectRow-${jobId}`)); } await this.assertJobRowSelected(jobId, true); await this.assertMultiSelectActionsAreaActive(); - } + }, - public async deselectJobRow(jobId: string) { + async deselectJobRow(jobId: string) { if ((await this.isJobRowSelected(jobId)) === true) { await testSubjects.click(this.rowSelector(jobId, `checkboxSelectRow-${jobId}`)); } await this.assertJobRowSelected(jobId, false); await this.assertMultiSelectActionsAreaInactive(); - } + }, - public async assertMultiSelectActionsAreaActive() { + async assertMultiSelectActionsAreaActive() { await testSubjects.existOrFail('mlADJobListMultiSelectActionsArea active'); - } + }, - public async assertMultiSelectActionsAreaInactive() { + async assertMultiSelectActionsAreaInactive() { await testSubjects.existOrFail('mlADJobListMultiSelectActionsArea inactive', { allowHidden: true, }); - } + }, - public async assertMultiSelectActionSingleMetricViewerButtonEnabled(expectedValue: boolean) { + async assertMultiSelectActionSingleMetricViewerButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( '~mlADJobListMultiSelectActionsArea > mlOpenJobsInSingleMetricViewerButton' ); @@ -616,9 +538,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertMultiSelectActionAnomalyExplorerButtonEnabled(expectedValue: boolean) { + async assertMultiSelectActionAnomalyExplorerButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( '~mlADJobListMultiSelectActionsArea > mlOpenJobsInAnomalyExplorerButton' ); @@ -628,9 +550,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertMultiSelectActionEditJobGroupsButtonEnabled(expectedValue: boolean) { + async assertMultiSelectActionEditJobGroupsButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( '~mlADJobListMultiSelectActionsArea > mlADJobListMultiSelectEditJobGroupsButton' ); @@ -640,9 +562,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertMultiSelectManagementActionsButtonEnabled(expectedValue: boolean) { + async assertMultiSelectManagementActionsButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( '~mlADJobListMultiSelectActionsArea > mlADJobListMultiSelectManagementActionsButton' ); @@ -652,9 +574,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertMultiSelectStartDatafeedActionButtonEnabled(expectedValue: boolean) { + async assertMultiSelectStartDatafeedActionButtonEnabled(expectedValue: boolean) { await this.ensureMultiSelectManagementActionsMenuOpen(); const isEnabled = await testSubjects.isEnabled( 'mlADJobListMultiSelectStartDatafeedActionButton' @@ -665,9 +587,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async assertMultiSelectDeleteJobActionButtonEnabled(expectedValue: boolean) { + async assertMultiSelectDeleteJobActionButtonEnabled(expectedValue: boolean) { await this.ensureMultiSelectManagementActionsMenuOpen(); const isEnabled = await testSubjects.isEnabled('mlADJobListMultiSelectDeleteJobActionButton'); expect(isEnabled).to.eql( @@ -676,9 +598,9 @@ export function MachineLearningJobTableProvider( expectedValue ? 'enabled' : 'disabled' }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); - } + }, - public async ensureMultiSelectManagementActionsMenuOpen() { + async ensureMultiSelectManagementActionsMenuOpen() { await retry.tryForTime(30 * 1000, async () => { if (!(await testSubjects.exists('mlADJobListMultiSelectDeleteJobActionButton'))) { await testSubjects.click('mlADJobListMultiSelectManagementActionsButton'); @@ -687,48 +609,44 @@ export function MachineLearningJobTableProvider( }); } }); - } + }, - public async openEditCustomUrlsForJobTab(jobId: string) { + async openEditCustomUrlsForJobTab(jobId: string) { await this.clickEditJobAction(jobId); // click Custom URLs tab await testSubjects.click('mlEditJobFlyout-customUrls'); await this.ensureEditCustomUrlTabOpen(); await headerPage.waitUntilLoadingHasFinished(); - } + }, - public async ensureEditCustomUrlTabOpen() { + async ensureEditCustomUrlTabOpen() { await testSubjects.existOrFail('mlJobOpenCustomUrlFormButton', { timeout: 5000 }); - } + }, - public async closeEditJobFlyout() { + async closeEditJobFlyout() { if (await testSubjects.exists('mlEditJobFlyoutCloseButton')) { await testSubjects.click('mlEditJobFlyoutCloseButton'); await testSubjects.missingOrFail('mlJobEditFlyout'); } - } + }, - public async saveEditJobFlyoutChanges() { + async saveEditJobFlyoutChanges() { await testSubjects.click('mlEditJobFlyoutSaveButton'); await testSubjects.missingOrFail('mlJobEditFlyout', { timeout: 5000 }); - } + }, - public async clickOpenCustomUrlEditor() { + async clickOpenCustomUrlEditor() { await this.ensureEditCustomUrlTabOpen(); await testSubjects.click('mlJobOpenCustomUrlFormButton'); await testSubjects.existOrFail('mlJobCustomUrlForm'); - } + }, - public async getExistingCustomUrlCount(): Promise { + async getExistingCustomUrlCount(): Promise { const existingCustomUrls = await testSubjects.findAll('mlJobEditCustomUrlItemLabel'); return existingCustomUrls.length; - } + }, - public async saveCustomUrl( - expectedLabel: string, - expectedIndex: number, - expectedValue?: string - ) { + async saveCustomUrl(expectedLabel: string, expectedIndex: number, expectedValue?: string) { await retry.tryForTime(5000, async () => { await testSubjects.click('mlJobAddCustomUrl'); await customUrls.assertCustomUrlLabel(expectedIndex, expectedLabel); @@ -737,9 +655,9 @@ export function MachineLearningJobTableProvider( if (expectedValue !== undefined) { await customUrls.assertCustomUrlUrlValue(expectedIndex, expectedValue); } - } + }, - public async fillInDiscoverUrlForm(customUrl: DiscoverUrlConfig) { + async fillInDiscoverUrlForm(customUrl: DiscoverUrlConfig) { await this.clickOpenCustomUrlEditor(); await customUrls.setCustomUrlLabel(customUrl.label); await mlCommonUI.selectRadioGroupValue( @@ -758,9 +676,9 @@ export function MachineLearningJobTableProvider( if (customUrl.timeRange === TIME_RANGE_TYPE.INTERVAL) { await customUrls.setCustomUrlTimeRangeInterval(customUrl.timeRangeInterval!); } - } + }, - public async fillInDashboardUrlForm(customUrl: DashboardUrlConfig) { + async fillInDashboardUrlForm(customUrl: DashboardUrlConfig) { await this.clickOpenCustomUrlEditor(); await customUrls.setCustomUrlLabel(customUrl.label); await mlCommonUI.selectRadioGroupValue( @@ -779,16 +697,16 @@ export function MachineLearningJobTableProvider( if (customUrl.timeRange === TIME_RANGE_TYPE.INTERVAL) { await customUrls.setCustomUrlTimeRangeInterval(customUrl.timeRangeInterval!); } - } + }, - public async fillInOtherUrlForm(customUrl: OtherUrlConfig) { + async fillInOtherUrlForm(customUrl: OtherUrlConfig) { await this.clickOpenCustomUrlEditor(); await customUrls.setCustomUrlLabel(customUrl.label); await mlCommonUI.selectRadioGroupValue(`mlJobCustomUrlLinkToTypeInput`, URL_TYPE.OTHER); await customUrls.setCustomUrlOtherTypeUrl(customUrl.url); - } + }, - public async addDiscoverCustomUrl(jobId: string, customUrl: DiscoverUrlConfig) { + async addDiscoverCustomUrl(jobId: string, customUrl: DiscoverUrlConfig) { await retry.tryForTime(30 * 1000, async () => { await this.closeEditJobFlyout(); await this.openEditCustomUrlsForJobTab(jobId); @@ -800,9 +718,9 @@ export function MachineLearningJobTableProvider( // Save the job await this.saveEditJobFlyoutChanges(); - } + }, - public async addDashboardCustomUrl( + async addDashboardCustomUrl( jobId: string, customUrl: DashboardUrlConfig, expectedResult: { index: number; url: string } @@ -816,9 +734,9 @@ export function MachineLearningJobTableProvider( // Save the job await this.saveEditJobFlyoutChanges(); - } + }, - public async addOtherTypeCustomUrl(jobId: string, customUrl: OtherUrlConfig) { + async addOtherTypeCustomUrl(jobId: string, customUrl: OtherUrlConfig) { await retry.tryForTime(30 * 1000, async () => { await this.closeEditJobFlyout(); await this.openEditCustomUrlsForJobTab(jobId); @@ -830,9 +748,9 @@ export function MachineLearningJobTableProvider( // Save the job await this.saveEditJobFlyoutChanges(); - } + }, - public async editCustomUrl( + async editCustomUrl( jobId: string, indexInList: number, customUrl: { label: string; url: string } @@ -843,9 +761,9 @@ export function MachineLearningJobTableProvider( // Save the edit await this.saveEditJobFlyoutChanges(); - } + }, - public async deleteCustomUrl(jobId: string, indexInList: number) { + async deleteCustomUrl(jobId: string, indexInList: number) { await this.openEditCustomUrlsForJobTab(jobId); const beforeCustomUrls = await testSubjects.findAll('mlJobEditCustomUrlItemLabel'); await customUrls.deleteCustomUrl(indexInList); @@ -855,30 +773,31 @@ export function MachineLearningJobTableProvider( await this.openEditCustomUrlsForJobTab(jobId); await customUrls.assertCustomUrlsLength(beforeCustomUrls.length - 1); await this.closeEditJobFlyout(); - } + }, - public async openTestCustomUrl(jobId: string, indexInList: number) { + async openTestCustomUrl(jobId: string, indexInList: number) { await this.openEditCustomUrlsForJobTab(jobId); await customUrls.clickTestCustomUrl(indexInList); - } + }, - public async testDiscoverCustomUrlAction(expectedHitCountFormatted: string) { + async testDiscoverCustomUrlAction(expectedHitCountFormatted: string) { await customUrls.assertDiscoverCustomUrlAction(expectedHitCountFormatted); - } + }, - public async testDashboardCustomUrlAction(expectedPanelCount: number) { + async testDashboardCustomUrlAction(expectedPanelCount: number) { await customUrls.assertDashboardCustomUrlAction(expectedPanelCount); - } + }, - public async testOtherTypeCustomUrlAction( - jobId: string, - indexInList: number, - expectedUrl: string - ) { + async testOtherTypeCustomUrlAction(jobId: string, indexInList: number, expectedUrl: string) { // Can't test the contents of the external page, so just check the expected URL. await this.openEditCustomUrlsForJobTab(jobId); await customUrls.assertCustomUrlUrlValue(indexInList, expectedUrl); await this.closeEditJobFlyout(); - } - })(); + }, + + async assertJobListMultiSelectionText(expectedMsg: string): Promise { + const visibleText = await testSubjects.getVisibleText('~mlADJobListMultiSelectActionsArea'); + expect(visibleText).to.be(expectedMsg); + }, + }; } From 9d22e8c6a8d92449055fd99b67b745d1995b5107 Mon Sep 17 00:00:00 2001 From: Tre Date: Mon, 16 Sep 2024 15:42:06 +0100 Subject: [PATCH 04/16] [FTR] Collapse Alerting API Helpers Impl (#192216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resolves: https://github.com/elastic/kibana/issues/192201 - Expose `TryWithRetriesOptions` - Tune timeouts to pass ci - Add attempt count debug info to `retry/retry_for_success.ts` - Helps with tuning timeout params - Move exposure of `AlertingApiProvider` from `x-pack/test_serverless/api_integration/services/index.ts` -> `x-pack/test_serverless/shared/services/deployment_agnostic_services.ts` - This exposes the alerting api under Deployment Agnostic Services (DA), and DA is exposed within `x-pack/test_serverless/functional/services/index.ts` (Shared Services [Serverless]) - Collapse helper script functions into just another object literal stanza within `AlertingApiProvider` - Update all references - Refactor alerting api to use `retry` service, instead of p-retry (following [this pr](https://github.com/elastic/kibana/pull/178660)) ### Additional debug logging Run in debug mode (add `-v`): ``` node scripts/functional_tests \ --config x-pack/test_serverless/api_integration/test_suites/search/common_configs/config.group1.ts \ --grep "Summary actions" -v ``` #### After ``` │ sill retry.tryWithRetries('Alerting API - waitForDocumentInIndex, retryOptions: {"retryCount":5,"retryDelay":200}', [object AsyncFunction], [object Object]) │ debg --- retry.tryWithRetries error: index_not_found_exception │ Root causes: │ index_not_found_exception: no such index [alert-action-es-query] - Attempt #: 1 │ sill es.search([object Object]) │ debg --- retry.tryWithRetries failed again with the same message... - Attempt #: 2 │ sill es.search([object Object]) │ debg --- retry.tryWithRetries failed again with the same message... - Attempt #: 3 │ sill es.search([object Object]) │ debg --- retry.tryWithRetries failed again with the same message... - Attempt #: 4 │ sill es.search([object Object]) │ debg --- retry.tryWithRetries failed again with the same message... - Attempt #: 5 ... // Msg after all attempts fail: │ Error: retry.tryWithRetries reached the limit of attempts waiting for 'Alerting API - waitForDocumentInIndex, retryOptions: {"retryCount":5,"retryDelay":200}': 5 out of 5 │ ResponseError: index_not_found_exception │ Root causes: │ index_not_found_exception: no such index [alert-action-es-query] │ at SniffingTransport._request (node_modules/@elastic/transport/src/Transport.ts:601:17) │ at processTicksAndRejections (node:internal/process/task_queues:95:5) │ at /Users/trezworkbox/dev/main.worktrees/cleanup-alerting-api/node_modules/@elastic/transport/src/Transport.ts:704:22 │ at SniffingTransport.request (node_modules/@elastic/transport/src/Transport.ts:701:14) │ at Proxy.SearchApi (node_modules/@elastic/elasticsearch/src/api/api/search.ts:96:10) │ at alerting_api.ts:123:28 │ at runAttempt (retry_for_success.ts:30:15) │ at retryForSuccess (retry_for_success.ts:99:21) │ at Proxy.tryWithRetries (retry.ts:113:12) │ at Object.waitForDocumentInIndex (alerting_api.ts:120:14) │ at Context. (summary_actions.ts:146:20) │ at Object.apply (wrap_function.js:74:16) │ at Object.apply (wrap_function.js:74:16) │ at onFailure (retry_for_success.ts:18:9) │ at retryForSuccess (retry_for_success.ts:75:7) │ at Proxy.tryWithRetries (retry.ts:113:12) │ at Object.waitForDocumentInIndex (alerting_api.ts:120:14) │ at Context. (summary_actions.ts:146:20) │ at Object.apply (wrap_function.js:74:16) │ at Object.apply (wrap_function.js:74:16) ``` ### Notes Was put back in draft to additional scope detailed in issue linked above. --------- Co-authored-by: Elastic Machine --- .../index.ts | 2 +- .../services/retry/index.ts | 2 +- .../services/retry/retry.ts | 2 +- .../services/retry/retry_for_success.ts | 10 +- .../api_integration/services/alerting_api.ts | 176 --- .../api_integration/services/index.ts | 2 - .../common/alerting/alert_documents.ts | 35 +- .../alerting/helpers/alerting_api_helper.ts | 478 -------- .../helpers/alerting_wait_for_helpers.ts | 402 ------- .../test_suites/common/alerting/rules.ts | 234 ++-- .../common/alerting/summary_actions.ts | 87 +- .../es_query_rule/es_query_rule.ts | 6 +- .../functional/services/index.ts | 3 +- .../observability/rules/rules_list.ts | 221 ++-- .../test_suites/search/rules/rule_details.ts | 56 +- .../shared/services/alerting_api.ts | 1032 +++++++++++++++++ .../services/deployment_agnostic_services.ts | 3 +- 17 files changed, 1306 insertions(+), 1445 deletions(-) delete mode 100644 x-pack/test_serverless/api_integration/services/alerting_api.ts delete mode 100644 x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_api_helper.ts delete mode 100644 x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_wait_for_helpers.ts create mode 100644 x-pack/test_serverless/shared/services/alerting_api.ts diff --git a/packages/kbn-ftr-common-functional-services/index.ts b/packages/kbn-ftr-common-functional-services/index.ts index e156949f0daf9..506566216c721 100644 --- a/packages/kbn-ftr-common-functional-services/index.ts +++ b/packages/kbn-ftr-common-functional-services/index.ts @@ -14,7 +14,7 @@ import { KibanaServerProvider } from './services/kibana_server'; export { KibanaServerProvider } from './services/kibana_server'; export type KibanaServer = ProvidedType; -export { RetryService } from './services/retry'; +export { RetryService, type TryWithRetriesOptions } from './services/retry'; import { EsArchiverProvider } from './services/es_archiver'; export type EsArchiver = ProvidedType; diff --git a/packages/kbn-ftr-common-functional-services/services/retry/index.ts b/packages/kbn-ftr-common-functional-services/services/retry/index.ts index 6f42e0368364d..f96e413da2680 100644 --- a/packages/kbn-ftr-common-functional-services/services/retry/index.ts +++ b/packages/kbn-ftr-common-functional-services/services/retry/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { RetryService } from './retry'; +export { RetryService, type TryWithRetriesOptions } from './retry'; diff --git a/packages/kbn-ftr-common-functional-services/services/retry/retry.ts b/packages/kbn-ftr-common-functional-services/services/retry/retry.ts index 9ddd13ea583a7..614f57064512c 100644 --- a/packages/kbn-ftr-common-functional-services/services/retry/retry.ts +++ b/packages/kbn-ftr-common-functional-services/services/retry/retry.ts @@ -11,7 +11,7 @@ import { FtrService } from '../ftr_provider_context'; import { retryForSuccess } from './retry_for_success'; import { retryForTruthy } from './retry_for_truthy'; -interface TryWithRetriesOptions { +export interface TryWithRetriesOptions { retryCount: number; retryDelay?: number; timeout?: number; diff --git a/packages/kbn-ftr-common-functional-services/services/retry/retry_for_success.ts b/packages/kbn-ftr-common-functional-services/services/retry/retry_for_success.ts index 5401eb21286d1..921efacd88fcc 100644 --- a/packages/kbn-ftr-common-functional-services/services/retry/retry_for_success.ts +++ b/packages/kbn-ftr-common-functional-services/services/retry/retry_for_success.ts @@ -92,7 +92,7 @@ export async function retryForSuccess(log: ToolingLog, options: Options) { if (lastError && onFailureBlock) { const before = await runAttempt(onFailureBlock); if ('error' in before) { - log.debug(`--- onRetryBlock error: ${before.error.message}`); + log.debug(`--- onRetryBlock error: ${before.error.message} - Attempt #: ${attemptCounter}`); } } @@ -104,9 +104,13 @@ export async function retryForSuccess(log: ToolingLog, options: Options) { if ('error' in attempt) { if (lastError && lastError.message === attempt.error.message) { - log.debug(`--- ${methodName} failed again with the same message...`); + log.debug( + `--- ${methodName} failed again with the same message... - Attempt #: ${attemptCounter}` + ); } else { - log.debug(`--- ${methodName} error: ${attempt.error.message}`); + log.debug( + `--- ${methodName} error: ${attempt.error.message} - Attempt #: ${attemptCounter}` + ); } lastError = attempt.error; diff --git a/x-pack/test_serverless/api_integration/services/alerting_api.ts b/x-pack/test_serverless/api_integration/services/alerting_api.ts deleted file mode 100644 index 6000e9d8bdc88..0000000000000 --- a/x-pack/test_serverless/api_integration/services/alerting_api.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - AggregationsAggregate, - SearchResponse, -} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -import { MetricThresholdParams } from '@kbn/infra-plugin/common/alerting/metrics'; -import { ThresholdParams } from '@kbn/observability-plugin/common/custom_threshold_rule/types'; -import { RoleCredentials } from '../../shared/services'; -import { SloBurnRateRuleParams } from './slo_api'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function AlertingApiProvider({ getService }: FtrProviderContext) { - const retry = getService('retry'); - const es = getService('es'); - const requestTimeout = 30 * 1000; - const retryTimeout = 120 * 1000; - const logger = getService('log'); - const svlCommonApi = getService('svlCommonApi'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); - - return { - async waitForRuleStatus({ - roleAuthc, - ruleId, - expectedStatus, - }: { - roleAuthc: RoleCredentials; - ruleId: string; - expectedStatus: string; - }) { - if (!ruleId) { - throw new Error(`'ruleId' is undefined`); - } - return await retry.tryForTime(retryTimeout, async () => { - const response = await supertestWithoutAuth - .get(`/api/alerting/rule/${ruleId}`) - .set(svlCommonApi.getInternalRequestHeader()) - .set(roleAuthc.apiKeyHeader) - .timeout(requestTimeout); - const { execution_status: executionStatus } = response.body || {}; - const { status } = executionStatus || {}; - if (status !== expectedStatus) { - throw new Error(`waitForStatus(${expectedStatus}): got ${status}`); - } - return executionStatus?.status; - }); - }, - - async waitForDocumentInIndex({ - indexName, - docCountTarget = 1, - }: { - indexName: string; - docCountTarget?: number; - }): Promise>> { - return await retry.tryForTime(retryTimeout, async () => { - const response = await es.search({ - index: indexName, - rest_total_hits_as_int: true, - }); - logger.debug(`Found ${response.hits.total} docs, looking for atleast ${docCountTarget}.`); - if (!response.hits.total || (response.hits.total as number) < docCountTarget) { - throw new Error('No hits found'); - } - return response; - }); - }, - - async waitForAlertInIndex({ - indexName, - ruleId, - }: { - indexName: string; - ruleId: string; - }): Promise>> { - if (!ruleId) { - throw new Error(`'ruleId' is undefined`); - } - return await retry.tryForTime(retryTimeout, async () => { - const response = await es.search({ - index: indexName, - body: { - query: { - term: { - 'kibana.alert.rule.uuid': ruleId, - }, - }, - }, - }); - if (response.hits.hits.length === 0) { - throw new Error('No hits found'); - } - return response; - }); - }, - - async createIndexConnector({ - roleAuthc, - name, - indexName, - }: { - roleAuthc: RoleCredentials; - name: string; - indexName: string; - }) { - const { body } = await supertestWithoutAuth - .post(`/api/actions/connector`) - .set(svlCommonApi.getInternalRequestHeader()) - .set(roleAuthc.apiKeyHeader) - .send({ - name, - config: { - index: indexName, - refresh: true, - }, - connector_type_id: '.index', - }); - return body.id as string; - }, - - async createRule({ - roleAuthc, - name, - ruleTypeId, - params, - actions = [], - tags = [], - schedule, - consumer, - }: { - roleAuthc: RoleCredentials; - ruleTypeId: string; - name: string; - params: MetricThresholdParams | ThresholdParams | SloBurnRateRuleParams; - actions?: any[]; - tags?: any[]; - schedule?: { interval: string }; - consumer: string; - }) { - const { body } = await supertestWithoutAuth - .post(`/api/alerting/rule`) - .set(svlCommonApi.getInternalRequestHeader()) - .set(roleAuthc.apiKeyHeader) - .send({ - params, - consumer, - schedule: schedule || { - interval: '5m', - }, - tags, - name, - rule_type_id: ruleTypeId, - actions, - }); - return body; - }, - - async findRule(roleAuthc: RoleCredentials, ruleId: string) { - if (!ruleId) { - throw new Error(`'ruleId' is undefined`); - } - const response = await supertestWithoutAuth - .get('/api/alerting/rules/_find') - .set(svlCommonApi.getInternalRequestHeader()) - .set(roleAuthc.apiKeyHeader); - return response.body.data.find((obj: any) => obj.id === ruleId); - }, - }; -} diff --git a/x-pack/test_serverless/api_integration/services/index.ts b/x-pack/test_serverless/api_integration/services/index.ts index 347fc1f68b0ca..22ce9b3bb4794 100644 --- a/x-pack/test_serverless/api_integration/services/index.ts +++ b/x-pack/test_serverless/api_integration/services/index.ts @@ -9,7 +9,6 @@ import { GenericFtrProviderContext } from '@kbn/test'; import { services as deploymentAgnosticSharedServices } from '../../shared/services/deployment_agnostic_services'; import { services as svlSharedServices } from '../../shared/services'; -import { AlertingApiProvider } from './alerting_api'; import { SamlToolsProvider } from './saml_tools'; import { SvlCasesServiceProvider } from './svl_cases'; import { SloApiProvider } from './slo_api'; @@ -35,7 +34,6 @@ export const services = { // serverless FTR services ...svlSharedServices, - alertingApi: AlertingApiProvider, samlTools: SamlToolsProvider, svlCases: SvlCasesServiceProvider, sloApi: SloApiProvider, diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts index 93dd4e5565db5..cf727fd9fd1bc 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts @@ -42,22 +42,18 @@ import { ALERT_PREVIOUS_ACTION_GROUP, } from '@kbn/rule-data-utils'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { createEsQueryRule } from './helpers/alerting_api_helper'; -import { waitForAlertInIndex, waitForNumRuleRuns } from './helpers/alerting_wait_for_helpers'; import { ObjectRemover } from '../../../../shared/lib'; -import { InternalRequestHeader, RoleCredentials } from '../../../../shared/services'; +import { RoleCredentials } from '../../../../shared/services'; const OPEN_OR_ACTIVE = new Set(['open', 'active']); export default function ({ getService }: FtrProviderContext) { - const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); let roleAdmin: RoleCredentials; - let internalReqHeader: InternalRequestHeader; const supertest = getService('supertest'); const esClient = getService('es'); const objectRemover = new ObjectRemover(supertest); + const alertingApi = getService('alertingApi'); describe('Alert documents', function () { // Timeout of 360000ms exceeded @@ -68,7 +64,6 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { roleAdmin = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); - internalReqHeader = svlCommonApi.getInternalRequestHeader(); }); afterEach(async () => { @@ -80,10 +75,8 @@ export default function ({ getService }: FtrProviderContext) { }); it('should generate an alert document for an active alert', async () => { - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -103,17 +96,15 @@ export default function ({ getService }: FtrProviderContext) { // get the first alert document written const testStart1 = new Date(); - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 1, ruleId, esClient, testStart: testStart1, }); - const alResp1 = await waitForAlertInIndex({ + const alResp1 = await alertingApi.helpers.waitForAlertInIndex({ esClient, filter: testStart1, indexName: ALERT_INDEX, @@ -206,10 +197,8 @@ export default function ({ getService }: FtrProviderContext) { }); it('should update an alert document for an ongoing alert', async () => { - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -229,17 +218,15 @@ export default function ({ getService }: FtrProviderContext) { // get the first alert document written const testStart1 = new Date(); - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 1, ruleId, esClient, testStart: testStart1, }); - const alResp1 = await waitForAlertInIndex({ + const alResp1 = await alertingApi.helpers.waitForAlertInIndex({ esClient, filter: testStart1, indexName: ALERT_INDEX, @@ -249,17 +236,15 @@ export default function ({ getService }: FtrProviderContext) { // wait for another run, get the updated alert document const testStart2 = new Date(); - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 1, ruleId, esClient, testStart: testStart2, }); - const alResp2 = await waitForAlertInIndex({ + const alResp2 = await alertingApi.helpers.waitForAlertInIndex({ esClient, filter: testStart2, indexName: ALERT_INDEX, diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_api_helper.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_api_helper.ts deleted file mode 100644 index f7a909c688d0e..0000000000000 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_api_helper.ts +++ /dev/null @@ -1,478 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { v4 as uuidv4 } from 'uuid'; -import type { Agent as SuperTestAgent } from 'supertest'; -import { - InternalRequestHeader, - RoleCredentials, - SupertestWithoutAuthProviderType, -} from '../../../../../shared/services'; - -interface CreateEsQueryRuleParams { - size: number; - thresholdComparator: string; - threshold: number[]; - timeWindowSize?: number; - timeWindowUnit?: string; - esQuery?: string; - timeField?: string; - searchConfiguration?: unknown; - indexName?: string; - excludeHitsFromPreviousRun?: boolean; - aggType?: string; - aggField?: string; - groupBy?: string; - termField?: string; - termSize?: number; - index?: string[]; -} - -export async function createIndexConnector({ - supertestWithoutAuth, - roleAuthc, - internalReqHeader, - name, - indexName, -}: { - supertestWithoutAuth: SupertestWithoutAuthProviderType; - roleAuthc: RoleCredentials; - internalReqHeader: InternalRequestHeader; - name: string; - indexName: string; -}) { - const { body } = await supertestWithoutAuth - .post(`/api/actions/connector`) - .set(internalReqHeader) - .set(roleAuthc.apiKeyHeader) - .send({ - name, - config: { - index: indexName, - refresh: true, - }, - connector_type_id: '.index', - }) - .expect(200); - return body; -} - -export async function createSlackConnector({ - supertestWithoutAuth, - roleAuthc, - internalReqHeader, - name, -}: { - supertestWithoutAuth: SupertestWithoutAuthProviderType; - roleAuthc: RoleCredentials; - internalReqHeader: InternalRequestHeader; - name: string; -}) { - const { body } = await supertestWithoutAuth - .post(`/api/actions/connector`) - .set(internalReqHeader) - .set(roleAuthc.apiKeyHeader) - .send({ - name, - config: {}, - secrets: { - webhookUrl: 'http://test', - }, - connector_type_id: '.slack', - }) - .expect(200); - return body; -} - -export async function createEsQueryRule({ - supertestWithoutAuth, - roleAuthc, - internalReqHeader, - name, - ruleTypeId, - params, - actions = [], - tags = [], - schedule, - consumer, - notifyWhen, - enabled = true, -}: { - supertestWithoutAuth: SupertestWithoutAuthProviderType; - roleAuthc: RoleCredentials; - internalReqHeader: InternalRequestHeader; - ruleTypeId: string; - name: string; - params: CreateEsQueryRuleParams; - consumer: string; - actions?: any[]; - tags?: any[]; - schedule?: { interval: string }; - notifyWhen?: string; - enabled?: boolean; -}) { - const { body } = await supertestWithoutAuth - .post(`/api/alerting/rule`) - .set(internalReqHeader) - .set(roleAuthc.apiKeyHeader) - .send({ - enabled, - params, - consumer, - schedule: schedule || { - interval: '1h', - }, - tags, - name, - rule_type_id: ruleTypeId, - actions, - ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), - }) - .expect(200); - return body; -} - -export const generateUniqueKey = () => uuidv4().replace(/-/g, ''); - -export async function createAnomalyRule({ - supertest, - name = generateUniqueKey(), - actions = [], - tags = ['foo', 'bar'], - schedule, - consumer = 'alerts', - notifyWhen, - enabled = true, - ruleTypeId = 'apm.anomaly', - params, -}: { - supertest: SuperTestAgent; - name?: string; - consumer?: string; - actions?: any[]; - tags?: any[]; - schedule?: { interval: string }; - notifyWhen?: string; - enabled?: boolean; - ruleTypeId?: string; - params?: any; -}) { - const { body } = await supertest - .post(`/api/alerting/rule`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .send({ - enabled, - params: params || { - anomalySeverityType: 'critical', - anomalyDetectorTypes: ['txLatency'], - environment: 'ENVIRONMENT_ALL', - windowSize: 30, - windowUnit: 'm', - }, - consumer, - schedule: schedule || { - interval: '1m', - }, - tags, - name, - rule_type_id: ruleTypeId, - actions, - ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), - }) - .expect(200); - return body; -} - -export async function createLatencyThresholdRule({ - supertest, - name = generateUniqueKey(), - actions = [], - tags = ['foo', 'bar'], - schedule, - consumer = 'apm', - notifyWhen, - enabled = true, - ruleTypeId = 'apm.transaction_duration', - params, -}: { - supertest: SuperTestAgent; - name?: string; - consumer?: string; - actions?: any[]; - tags?: any[]; - schedule?: { interval: string }; - notifyWhen?: string; - enabled?: boolean; - ruleTypeId?: string; - params?: any; -}) { - const { body } = await supertest - .post(`/api/alerting/rule`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .send({ - enabled, - params: params || { - aggregationType: 'avg', - environment: 'ENVIRONMENT_ALL', - threshold: 1500, - windowSize: 5, - windowUnit: 'm', - }, - consumer, - schedule: schedule || { - interval: '1m', - }, - tags, - name, - rule_type_id: ruleTypeId, - actions, - ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), - }); - return body; -} - -export async function createInventoryRule({ - supertest, - name = generateUniqueKey(), - actions = [], - tags = ['foo', 'bar'], - schedule, - consumer = 'alerts', - notifyWhen, - enabled = true, - ruleTypeId = 'metrics.alert.inventory.threshold', - params, -}: { - supertest: SuperTestAgent; - name?: string; - consumer?: string; - actions?: any[]; - tags?: any[]; - schedule?: { interval: string }; - notifyWhen?: string; - enabled?: boolean; - ruleTypeId?: string; - params?: any; -}) { - const { body } = await supertest - .post(`/api/alerting/rule`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .send({ - enabled, - params: params || { - nodeType: 'host', - criteria: [ - { - metric: 'cpu', - comparator: '>', - threshold: [5], - timeSize: 1, - timeUnit: 'm', - customMetric: { - type: 'custom', - id: 'alert-custom-metric', - field: '', - aggregation: 'avg', - }, - }, - ], - sourceId: 'default', - }, - consumer, - schedule: schedule || { - interval: '1m', - }, - tags, - name, - rule_type_id: ruleTypeId, - actions, - ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), - }) - .expect(200); - return body; -} - -export async function disableRule({ - supertest, - ruleId, -}: { - supertest: SuperTestAgent; - ruleId: string; -}) { - const { body } = await supertest - .post(`/api/alerting/rule/${ruleId}/_disable`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .expect(204); - return body; -} - -export async function updateEsQueryRule({ - supertest, - ruleId, - updates, -}: { - supertest: SuperTestAgent; - ruleId: string; - updates: any; -}) { - const { body: r } = await supertest - .get(`/api/alerting/rule/${ruleId}`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .expect(200); - const body = await supertest - .put(`/api/alerting/rule/${ruleId}`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .send({ - ...{ - name: r.name, - schedule: r.schedule, - throttle: r.throttle, - tags: r.tags, - params: r.params, - notify_when: r.notifyWhen, - actions: r.actions.map((action: any) => ({ - group: action.group, - params: action.params, - id: action.id, - frequency: action.frequency, - })), - }, - ...updates, - }) - .expect(200); - return body; -} - -export async function runRule({ - supertestWithoutAuth, - roleAuthc, - internalReqHeader, - ruleId, -}: { - supertestWithoutAuth: SupertestWithoutAuthProviderType; - roleAuthc: RoleCredentials; - internalReqHeader: InternalRequestHeader; - ruleId: string; -}) { - const response = await supertestWithoutAuth - .post(`/internal/alerting/rule/${ruleId}/_run_soon`) - .set(internalReqHeader) - .set(roleAuthc.apiKeyHeader) - .expect(204); - return response; -} - -export async function muteRule({ - supertest, - ruleId, -}: { - supertest: SuperTestAgent; - ruleId: string; -}) { - const { body } = await supertest - .post(`/api/alerting/rule/${ruleId}/_mute_all`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .expect(204); - return body; -} - -export async function enableRule({ - supertest, - ruleId, -}: { - supertest: SuperTestAgent; - ruleId: string; -}) { - const { body } = await supertest - .post(`/api/alerting/rule/${ruleId}/_enable`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .expect(204); - return body; -} - -export async function muteAlert({ - supertest, - ruleId, - alertId, -}: { - supertest: SuperTestAgent; - ruleId: string; - alertId: string; -}) { - const { body } = await supertest - .post(`/api/alerting/rule/${ruleId}/alert/${alertId}/_mute`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .expect(204); - return body; -} - -export async function unmuteRule({ - supertest, - ruleId, -}: { - supertest: SuperTestAgent; - ruleId: string; -}) { - const { body } = await supertest - .post(`/api/alerting/rule/${ruleId}/_unmute_all`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .expect(204); - return body; -} - -export async function snoozeRule({ - supertest, - ruleId, -}: { - supertest: SuperTestAgent; - ruleId: string; -}) { - const { body } = await supertest - .post(`/internal/alerting/rule/${ruleId}/_snooze`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') - .send({ - snooze_schedule: { - duration: 100000000, - rRule: { - count: 1, - dtstart: moment().format(), - tzid: 'UTC', - }, - }, - }) - .expect(204); - return body; -} - -export async function findRule({ - supertest, - ruleId, -}: { - supertest: SuperTestAgent; - ruleId: string; -}) { - if (!ruleId) { - throw new Error(`'ruleId' is undefined`); - } - const response = await supertest - .get(`/api/alerting/rule/${ruleId}`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo'); - return response.body || {}; -} diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_wait_for_helpers.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_wait_for_helpers.ts deleted file mode 100644 index c7f2ac357e4a2..0000000000000 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/helpers/alerting_wait_for_helpers.ts +++ /dev/null @@ -1,402 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import pRetry from 'p-retry'; -import type { Client } from '@elastic/elasticsearch'; -import type { - AggregationsAggregate, - SearchResponse, -} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { runRule } from './alerting_api_helper'; -import type { SupertestWithoutAuthProviderType } from '../../../../../shared/services'; -import { RoleCredentials } from '../../../../../shared/services'; -import { InternalRequestHeader } from '../../../../../shared/services'; - -export async function waitForDocumentInIndex({ - esClient, - indexName, - ruleId, - num = 1, - sort = 'desc', -}: { - esClient: Client; - indexName: string; - ruleId: string; - num?: number; - sort?: 'asc' | 'desc'; -}): Promise { - return await pRetry( - async () => { - const response = await esClient.search({ - index: indexName, - sort: `date:${sort}`, - body: { - query: { - bool: { - must: [ - { - term: { - 'ruleId.keyword': ruleId, - }, - }, - ], - }, - }, - }, - }); - if (response.hits.hits.length < num) { - throw new Error(`Only found ${response.hits.hits.length} / ${num} documents`); - } - return response; - }, - { retries: 10 } - ); -} - -export async function getDocumentsInIndex({ - esClient, - indexName, - ruleId, -}: { - esClient: Client; - indexName: string; - ruleId: string; -}): Promise { - return await esClient.search({ - index: indexName, - body: { - query: { - bool: { - must: [ - { - term: { - 'ruleId.keyword': ruleId, - }, - }, - ], - }, - }, - }, - }); -} - -export async function createIndex({ - esClient, - indexName, -}: { - esClient: Client; - indexName: string; -}) { - return await esClient.indices.create( - { - index: indexName, - body: {}, - }, - { meta: true } - ); -} - -export async function waitForAlertInIndex({ - esClient, - filter, - indexName, - ruleId, - num = 1, -}: { - esClient: Client; - filter: Date; - indexName: string; - ruleId: string; - num: number; -}): Promise>> { - return await pRetry( - async () => { - const response = await esClient.search({ - index: indexName, - body: { - query: { - bool: { - must: [ - { - term: { - 'kibana.alert.rule.uuid': ruleId, - }, - }, - { - range: { - '@timestamp': { - gte: filter.getTime().toString(), - }, - }, - }, - ], - }, - }, - }, - }); - if (response.hits.hits.length < num) { - throw new Error(`Only found ${response.hits.hits.length} / ${num} documents`); - } - return response; - }, - { retries: 10 } - ); -} - -export async function waitForAllTasksIdle({ - esClient, - filter, -}: { - esClient: Client; - filter: Date; -}): Promise { - return await pRetry( - async () => { - const response = await esClient.search({ - index: '.kibana_task_manager', - body: { - query: { - bool: { - must: [ - { - terms: { - 'task.scope': ['actions', 'alerting'], - }, - }, - { - range: { - 'task.scheduledAt': { - gte: filter.getTime().toString(), - }, - }, - }, - ], - must_not: [ - { - term: { - 'task.status': 'idle', - }, - }, - ], - }, - }, - }, - }); - if (response.hits.hits.length !== 0) { - throw new Error(`Expected 0 hits but received ${response.hits.hits.length}`); - } - return response; - }, - { retries: 10 } - ); -} - -export async function waitForAllTasks({ - esClient, - filter, - taskType, - attempts, -}: { - esClient: Client; - filter: Date; - taskType: string; - attempts: number; -}): Promise { - return await pRetry( - async () => { - const response = await esClient.search({ - index: '.kibana_task_manager', - body: { - query: { - bool: { - must: [ - { - term: { - 'task.status': 'idle', - }, - }, - { - term: { - 'task.attempts': attempts, - }, - }, - { - terms: { - 'task.scope': ['actions', 'alerting'], - }, - }, - { - term: { - 'task.taskType': taskType, - }, - }, - { - range: { - 'task.scheduledAt': { - gte: filter.getTime().toString(), - }, - }, - }, - ], - }, - }, - }, - }); - if (response.hits.hits.length === 0) { - throw new Error('No hits found'); - } - return response; - }, - { retries: 10 } - ); -} - -export async function waitForDisabled({ - esClient, - ruleId, - filter, -}: { - esClient: Client; - ruleId: string; - filter: Date; -}): Promise { - return await pRetry( - async () => { - const response = await esClient.search({ - index: '.kibana_task_manager', - body: { - query: { - bool: { - must: [ - { - term: { - 'task.id': `task:${ruleId}`, - }, - }, - { - terms: { - 'task.scope': ['actions', 'alerting'], - }, - }, - { - range: { - 'task.scheduledAt': { - gte: filter.getTime().toString(), - }, - }, - }, - { - term: { - 'task.enabled': true, - }, - }, - ], - }, - }, - }, - }); - if (response.hits.hits.length !== 0) { - throw new Error(`Expected 0 hits but received ${response.hits.hits.length}`); - } - return response; - }, - { retries: 10 } - ); -} - -export async function waitForExecutionEventLog({ - esClient, - filter, - ruleId, - num = 1, -}: { - esClient: Client; - filter: Date; - ruleId: string; - num?: number; -}): Promise { - return await pRetry( - async () => { - const response = await esClient.search({ - index: '.kibana-event-log*', - body: { - query: { - bool: { - filter: [ - { - term: { - 'rule.id': { - value: ruleId, - }, - }, - }, - { - term: { - 'event.provider': { - value: 'alerting', - }, - }, - }, - { - term: { - 'event.action': 'execute', - }, - }, - { - range: { - '@timestamp': { - gte: filter.getTime().toString(), - }, - }, - }, - ], - }, - }, - }, - }); - if (response.hits.hits.length < num) { - throw new Error('No hits found'); - } - return response; - }, - { retries: 10 } - ); -} - -export async function waitForNumRuleRuns({ - supertestWithoutAuth, - roleAuthc, - internalReqHeader, - numOfRuns, - ruleId, - esClient, - testStart, -}: { - supertestWithoutAuth: SupertestWithoutAuthProviderType; - roleAuthc: RoleCredentials; - internalReqHeader: InternalRequestHeader; - numOfRuns: number; - ruleId: string; - esClient: Client; - testStart: Date; -}) { - for (let i = 0; i < numOfRuns; i++) { - await pRetry( - async () => { - await runRule({ supertestWithoutAuth, roleAuthc, internalReqHeader, ruleId }); - await waitForExecutionEventLog({ - esClient, - filter: testStart, - ruleId, - num: i + 1, - }); - await waitForAllTasksIdle({ esClient, filter: testStart }); - }, - { retries: 10 } - ); - } -} diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts index 37b78a5e1b36f..593c10f371f09 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/rules.ts @@ -9,28 +9,6 @@ import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; import expect from '@kbn/expect'; import { omit } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { - createIndexConnector, - createEsQueryRule, - disableRule, - updateEsQueryRule, - runRule, - muteRule, - enableRule, - muteAlert, - unmuteRule, - createSlackConnector, -} from './helpers/alerting_api_helper'; -import { - createIndex, - getDocumentsInIndex, - waitForAllTasks, - waitForAllTasksIdle, - waitForDisabled, - waitForDocumentInIndex, - waitForExecutionEventLog, - waitForNumRuleRuns, -} from './helpers/alerting_wait_for_helpers'; import type { InternalRequestHeader, RoleCredentials } from '../../../../shared/services'; export default function ({ getService }: FtrProviderContext) { @@ -39,7 +17,8 @@ export default function ({ getService }: FtrProviderContext) { const esDeleteAllIndices = getService('esDeleteAllIndices'); const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); + const alertingApi = getService('alertingApi'); + let roleAdmin: RoleCredentials; let internalReqHeader: InternalRequestHeader; @@ -73,19 +52,15 @@ export default function ({ getService }: FtrProviderContext) { it('should schedule task, run rule and schedule actions when appropriate', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -130,10 +105,14 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Wait for the action to index a document before disabling the alert and waiting for tasks to finish - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, + retryOptions: { + retryCount: 12, + retryDelay: 2000, + }, }); expect(resp.hits.hits.length).to.be(1); @@ -151,7 +130,7 @@ export default function ({ getService }: FtrProviderContext) { tags: '', }); - const eventLogResp = await waitForExecutionEventLog({ + const eventLogResp = await alertingApi.helpers.waiting.waitForExecutionEventLog({ esClient, filter: testStart, ruleId, @@ -171,19 +150,15 @@ export default function ({ getService }: FtrProviderContext) { it('should pass updated rule params to executor', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -228,10 +203,11 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Wait for the action to index a document before disabling the alert and waiting for tasks to finish - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, + retryOptions: { retryDelay: 800, retryCount: 10 }, }); expect(resp.hits.hits.length).to.be(1); @@ -249,13 +225,13 @@ export default function ({ getService }: FtrProviderContext) { tags: '', }); - await waitForAllTasksIdle({ + await alertingApi.helpers.waiting.waitForAllTasksIdle({ esClient, filter: testStart, }); - await updateEsQueryRule({ - supertest, + await alertingApi.helpers.updateEsQueryRule({ + roleAuthc: roleAdmin, ruleId, updates: { name: 'def', @@ -263,15 +239,13 @@ export default function ({ getService }: FtrProviderContext) { }, }); - await runRule({ - supertestWithoutAuth, + await alertingApi.helpers.runRule({ roleAuthc: roleAdmin, - internalReqHeader, ruleId, }); // make sure alert info passed to executor is correct - const resp2 = await waitForDocumentInIndex({ + const resp2 = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -298,18 +272,14 @@ export default function ({ getService }: FtrProviderContext) { const testStart = new Date(); // Should fail - const createdConnector = await createSlackConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createSlackConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Slack Connector: Alerting API test', }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -341,7 +311,7 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Should retry when the the action fails - const resp = await waitForAllTasks({ + const resp = await alertingApi.helpers.waiting.waitForAllTasks({ esClient, filter: testStart, taskType: 'actions:.slack', @@ -353,19 +323,15 @@ export default function ({ getService }: FtrProviderContext) { it('should throttle alerts when appropriate', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -407,29 +373,27 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Wait until alerts ran at least 3 times before disabling the alert and waiting for tasks to finish - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 3, ruleId, esClient, testStart, }); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc: roleAdmin, ruleId, }); - await waitForDisabled({ + await alertingApi.helpers.waiting.waitForDisabled({ esClient, ruleId, filter: testStart, }); // Ensure actions only executed once - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -440,19 +404,15 @@ export default function ({ getService }: FtrProviderContext) { it('should throttle alerts with throttled action when appropriate', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -498,29 +458,27 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Wait until alerts ran at least 3 times before disabling the alert and waiting for tasks to finish - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 3, ruleId, esClient, testStart, }); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc: roleAdmin, ruleId, }); - await waitForDisabled({ + await alertingApi.helpers.waiting.waitForDisabled({ esClient, ruleId, filter: testStart, }); // Ensure actions only executed once - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -531,19 +489,15 @@ export default function ({ getService }: FtrProviderContext) { it('should reset throttle window when not firing and should not throttle when changing groups', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -614,21 +568,21 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Wait for the action to index a document - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waiting.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, }); expect(resp.hits.hits.length).to.be(1); - await waitForAllTasksIdle({ + await alertingApi.helpers.waiting.waitForAllTasksIdle({ esClient, filter: testStart, }); // Update the rule to recover - await updateEsQueryRule({ - supertest, + await alertingApi.helpers.updateEsQueryRule({ + roleAuthc: roleAdmin, ruleId, updates: { name: 'never fire', @@ -645,34 +599,36 @@ export default function ({ getService }: FtrProviderContext) { }, }); - await runRule({ - supertestWithoutAuth, + await alertingApi.helpers.runRule({ roleAuthc: roleAdmin, - internalReqHeader, ruleId, }); - const eventLogResp = await waitForExecutionEventLog({ + const eventLogResp = await alertingApi.helpers.waiting.waitForExecutionEventLog({ esClient, filter: testStart, ruleId, num: 2, + retryOptions: { + retryCount: 12, + retryDelay: 2000, + }, }); expect(eventLogResp.hits.hits.length).to.be(2); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc: roleAdmin, ruleId, }); - await waitForDisabled({ + await alertingApi.helpers.waiting.waitForDisabled({ esClient, ruleId, filter: testStart, }); // Ensure only 2 actions are executed - const resp2 = await waitForDocumentInIndex({ + const resp2 = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -683,21 +639,17 @@ export default function ({ getService }: FtrProviderContext) { it(`shouldn't schedule actions when alert is muted`, async () => { const testStart = new Date(); - await createIndex({ esClient, indexName: ALERT_ACTION_INDEX }); + await alertingApi.helpers.waiting.createIndex({ esClient, indexName: ALERT_ACTION_INDEX }); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, enabled: false, consumer: 'alerts', name: 'always fire', @@ -742,41 +694,39 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - await muteRule({ - supertest, + await alertingApi.helpers.muteRule({ + roleAuthc: roleAdmin, ruleId, }); - await enableRule({ - supertest, + await alertingApi.helpers.enableRule({ + roleAuthc: roleAdmin, ruleId, }); // Wait until alerts schedule actions twice to ensure actions had a chance to skip // execution once before disabling the alert and waiting for tasks to finish - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 2, ruleId, esClient, testStart, }); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc: roleAdmin, ruleId, }); - await waitForDisabled({ + await alertingApi.helpers.waiting.waitForDisabled({ esClient, ruleId, filter: testStart, }); // Should not have executed any action - const resp2 = await getDocumentsInIndex({ + const resp2 = await alertingApi.helpers.waiting.getDocumentsInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -786,21 +736,17 @@ export default function ({ getService }: FtrProviderContext) { it(`shouldn't schedule actions when alert instance is muted`, async () => { const testStart = new Date(); - await createIndex({ esClient, indexName: ALERT_ACTION_INDEX }); + await alertingApi.helpers.waiting.createIndex({ esClient, indexName: ALERT_ACTION_INDEX }); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, enabled: false, consumer: 'alerts', name: 'always fire', @@ -845,42 +791,40 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - await muteAlert({ - supertest, + await alertingApi.helpers.muteAlert({ + roleAuthc: roleAdmin, ruleId, alertId: 'query matched', }); - await enableRule({ - supertest, + await alertingApi.helpers.enableRule({ + roleAuthc: roleAdmin, ruleId, }); // Wait until alerts schedule actions twice to ensure actions had a chance to skip // execution once before disabling the alert and waiting for tasks to finish - await waitForNumRuleRuns({ - supertestWithoutAuth, + await alertingApi.helpers.waitForNumRuleRuns({ roleAuthc: roleAdmin, - internalReqHeader, numOfRuns: 2, ruleId, esClient, testStart, }); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc: roleAdmin, ruleId, }); - await waitForDisabled({ + await alertingApi.helpers.waiting.waitForDisabled({ esClient, ruleId, filter: testStart, }); // Should not have executed any action - const resp2 = await getDocumentsInIndex({ + const resp2 = await alertingApi.helpers.waiting.getDocumentsInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -889,19 +833,15 @@ export default function ({ getService }: FtrProviderContext) { }); it(`should unmute all instances when unmuting an alert`, async () => { - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, enabled: false, consumer: 'alerts', name: 'always fire', @@ -946,29 +886,29 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - await muteAlert({ - supertest, + await alertingApi.helpers.muteAlert({ + roleAuthc: roleAdmin, ruleId, alertId: 'query matched', }); - await muteRule({ - supertest, + await alertingApi.helpers.muteRule({ + roleAuthc: roleAdmin, ruleId, }); - await unmuteRule({ - supertest, + await alertingApi.helpers.unmuteRule({ + roleAuthc: roleAdmin, ruleId, }); - await enableRule({ - supertest, + await alertingApi.helpers.enableRule({ + roleAuthc: roleAdmin, ruleId, }); // Should have one document indexed by the action - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts index 995a7ee197610..2726af585e28f 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts @@ -29,25 +29,15 @@ import { } from '@kbn/rule-data-utils'; import { omit, padStart } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { createIndexConnector, createEsQueryRule } from './helpers/alerting_api_helper'; -import { - createIndex, - getDocumentsInIndex, - waitForAlertInIndex, - waitForDocumentInIndex, -} from './helpers/alerting_wait_for_helpers'; -import { InternalRequestHeader, RoleCredentials } from '../../../../shared/services'; +import { RoleCredentials } from '../../../../shared/services'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esClient = getService('es'); const esDeleteAllIndices = getService('esDeleteAllIndices'); - - const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); + const alertingApi = getService('alertingApi'); let roleAdmin: RoleCredentials; - let internalReqHeader: InternalRequestHeader; describe('Summary actions', function () { const RULE_TYPE_ID = '.es-query'; @@ -75,7 +65,6 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { roleAdmin = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); - internalReqHeader = svlCommonApi.getInternalRequestHeader(); }); afterEach(async () => { @@ -98,19 +87,15 @@ export default function ({ getService }: FtrProviderContext) { it('should schedule actions for summary of alerts per rule run', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -158,19 +143,27 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, + retryOptions: { + retryCount: 20, + retryDelay: 15_000, + }, }); expect(resp.hits.hits.length).to.be(1); - const resp2 = await waitForAlertInIndex({ + const resp2 = await alertingApi.helpers.waitForAlertInIndex({ esClient, filter: testStart, indexName: ALERT_INDEX, ruleId, num: 1, + retryOptions: { + retryCount: 20, + retryDelay: 15_000, + }, }); expect(resp2.hits.hits.length).to.be(1); @@ -228,19 +221,15 @@ export default function ({ getService }: FtrProviderContext) { it('should filter alerts by kql', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -288,19 +277,27 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, + retryOptions: { + retryCount: 20, + retryDelay: 15_000, + }, }); expect(resp.hits.hits.length).to.be(1); - const resp2 = await waitForAlertInIndex({ + const resp2 = await alertingApi.helpers.waitForAlertInIndex({ esClient, filter: testStart, indexName: ALERT_INDEX, ruleId, num: 1, + retryOptions: { + retryCount: 20, + retryDelay: 15_000, + }, }); expect(resp2.hits.hits.length).to.be(1); @@ -365,21 +362,17 @@ export default function ({ getService }: FtrProviderContext) { const start = `${hour}:${minutes}`; const end = `${hour}:${minutes}`; - await createIndex({ esClient, indexName: ALERT_ACTION_INDEX }); + await alertingApi.helpers.waiting.createIndex({ esClient, indexName: ALERT_ACTION_INDEX }); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -433,7 +426,7 @@ export default function ({ getService }: FtrProviderContext) { ruleId = createdRule.id; // Should not have executed any action - const resp = await getDocumentsInIndex({ + const resp = await alertingApi.helpers.waiting.getDocumentsInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, @@ -443,19 +436,15 @@ export default function ({ getService }: FtrProviderContext) { it('should schedule actions for summary of alerts on a custom interval', async () => { const testStart = new Date(); - const createdConnector = await createIndexConnector({ - supertestWithoutAuth, + const createdConnector = await alertingApi.helpers.createIndexConnector({ roleAuthc: roleAdmin, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: ALERT_ACTION_INDEX, }); connectorId = createdConnector.id; - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc: roleAdmin, - internalReqHeader, consumer: 'alerts', name: 'always fire', ruleTypeId: RULE_TYPE_ID, @@ -501,20 +490,28 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - const resp = await waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndex({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, num: 2, sort: 'asc', + retryOptions: { + retryCount: 20, + retryDelay: 10_000, + }, }); - const resp2 = await waitForAlertInIndex({ + const resp2 = await alertingApi.helpers.waitForAlertInIndex({ esClient, filter: testStart, indexName: ALERT_INDEX, ruleId, num: 1, + retryOptions: { + retryCount: 20, + retryDelay: 15_000, + }, }); expect(resp2.hits.hits.length).to.be(1); diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/es_query_rule/es_query_rule.ts b/x-pack/test_serverless/api_integration/test_suites/observability/es_query_rule/es_query_rule.ts index 39edd9ba01eb9..8d627413ecbc0 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/es_query_rule/es_query_rule.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/es_query_rule/es_query_rule.ts @@ -12,7 +12,6 @@ */ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { createEsQueryRule } from '../../common/alerting/helpers/alerting_api_helper'; import { InternalRequestHeader, RoleCredentials } from '../../../../shared/services'; export default function ({ getService }: FtrProviderContext) { @@ -22,7 +21,6 @@ export default function ({ getService }: FtrProviderContext) { const alertingApi = getService('alertingApi'); const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); let roleAuthc: RoleCredentials; let internalReqHeader: InternalRequestHeader; @@ -58,10 +56,8 @@ export default function ({ getService }: FtrProviderContext) { indexName: ALERT_ACTION_INDEX, }); - const createdRule = await createEsQueryRule({ - supertestWithoutAuth, + const createdRule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'observability', name: 'always fire', ruleTypeId: RULE_TYPE_ID, diff --git a/x-pack/test_serverless/functional/services/index.ts b/x-pack/test_serverless/functional/services/index.ts index c63a16b4402f1..770cdbb88c97a 100644 --- a/x-pack/test_serverless/functional/services/index.ts +++ b/x-pack/test_serverless/functional/services/index.ts @@ -7,7 +7,6 @@ import { services as deploymentAgnosticFunctionalServices } from './deployment_agnostic_services'; import { services as svlSharedServices } from '../../shared/services'; - import { SvlCommonNavigationServiceProvider } from './svl_common_navigation'; import { SvlObltNavigationServiceProvider } from './svl_oblt_navigation'; import { SvlSearchNavigationServiceProvider } from './svl_search_navigation'; @@ -17,6 +16,7 @@ import { SvlCasesServiceProvider } from '../../api_integration/services/svl_case import { MachineLearningProvider } from './ml'; import { LogsSynthtraceProvider } from './log'; import { UISettingsServiceProvider } from './ui_settings'; +import { services as SvlApiIntegrationSvcs } from '../../api_integration/services'; export const services = { // deployment agnostic FTR services @@ -34,4 +34,5 @@ export const services = { uiSettings: UISettingsServiceProvider, // log services svlLogsSynthtraceClient: LogsSynthtraceProvider, + alertingApi: SvlApiIntegrationSvcs.alertingApi, }; diff --git a/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts b/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts index c2b878511a4dd..6a0d515afd232 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts @@ -7,17 +7,7 @@ import { expect } from 'expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { - createAnomalyRule as createRule, - disableRule, - enableRule, - runRule, - createIndexConnector, - snoozeRule, - createLatencyThresholdRule, - createEsQueryRule, -} from '../../../../api_integration/test_suites/common/alerting/helpers/alerting_api_helper'; -import { InternalRequestHeader, RoleCredentials } from '../../../../shared/services'; +import { RoleCredentials } from '../../../../shared/services'; export default ({ getPageObject, getService }: FtrProviderContext) => { const svlCommonPage = getPageObject('svlCommonPage'); @@ -31,11 +21,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const retry = getService('retry'); const toasts = getService('toasts'); const log = getService('log'); - const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); + const alertingApi = getService('alertingApi'); let roleAuthc: RoleCredentials; - let internalReqHeader: InternalRequestHeader; async function refreshRulesList() { const existsClearFilter = await testSubjects.exists('rules-list-clear-filter'); @@ -57,10 +45,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { numAttempts: number; }) { for (let i = 0; i < numAttempts; i++) { - await runRule({ supertestWithoutAuth, roleAuthc, internalReqHeader, ruleId }); + await alertingApi.helpers.runRule({ roleAuthc, ruleId }); await new Promise((resolve) => setTimeout(resolve, intervalMilliseconds)); - await disableRule({ supertest, ruleId }); + await alertingApi.helpers.disableRule({ + roleAuthc, + ruleId, + }); await new Promise((resolve) => setTimeout(resolve, intervalMilliseconds)); await refreshRulesList(); @@ -68,7 +59,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const rulesStatuses = result.map((item: { status: string }) => item.status); if (rulesStatuses.includes('Failed')) return; - await enableRule({ supertest, ruleId }); + await alertingApi.helpers.enableRule({ roleAuthc, ruleId }); } } @@ -84,7 +75,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { before(async () => { roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); - internalReqHeader = svlCommonApi.getInternalRequestHeader(); await svlCommonPage.loginWithPrivilegedRole(); await svlObltNavigation.navigateToLandingPage(); await svlCommonNavigation.sidenav.clickLink({ text: 'Alerts' }); @@ -107,10 +97,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should create an ES Query Rule and display it when consumer is observability', async () => { - const esQuery = await createEsQueryRule({ - supertestWithoutAuth, + const esQuery = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, name: 'ES Query', consumer: 'observability', ruleTypeId: '.es-query', @@ -134,10 +122,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should create an ES Query rule but not display it when consumer is stackAlerts', async () => { - const esQuery = await createEsQueryRule({ - supertestWithoutAuth, + const esQuery = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, name: 'ES Query', consumer: 'stackAlerts', ruleTypeId: '.es-query', @@ -159,7 +145,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should create and display an APM latency rule', async () => { - const apmLatency = await createLatencyThresholdRule({ supertest, name: 'Apm latency' }); + const apmLatency = await alertingApi.helpers.createLatencyThresholdRule({ + roleAuthc, + name: 'Apm latency', + }); ruleIdList = [apmLatency.id]; await refreshRulesList(); @@ -169,16 +158,16 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should display rules in alphabetical order', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'b', }); - const rule2 = await createRule({ - supertest, + const rule2 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'c', }); - const rule3 = await createRule({ - supertest, + const rule3 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'a', }); @@ -194,8 +183,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should search for rule', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; @@ -215,13 +204,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should update rule list on the search clear button click', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'a', }); - const rule2 = await createRule({ - supertest, + const rule2 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'b', tags: [], }); @@ -266,8 +255,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should search for tags', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'a', tags: ['tag', 'tagtag', 'taggity tag'], }); @@ -289,8 +278,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should display an empty list when search did not return any rules', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; @@ -301,8 +290,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should disable single rule', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; @@ -329,14 +318,17 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should re-enable single rule', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'a', }); ruleIdList = [rule1.id]; - await disableRule({ supertest, ruleId: rule1.id }); + await alertingApi.helpers.disableRule({ + roleAuthc, + ruleId: rule1.id, + }); await refreshRulesList(); await svlTriggersActionsUI.searchRules(rule1.name); @@ -360,13 +352,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should delete single rule', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'a', }); - const rule2 = await createRule({ - supertest, + const rule2 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'b', }); @@ -392,8 +384,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should disable all selection', async () => { - const createdRule1 = await createRule({ - supertest, + const createdRule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [createdRule1.id]; @@ -422,13 +414,16 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should enable all selection', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; - await disableRule({ supertest, ruleId: rule1.id }); + await alertingApi.helpers.disableRule({ + roleAuthc, + ruleId: rule1.id, + }); await refreshRulesList(); await svlTriggersActionsUI.searchRules(rule1.name); @@ -445,8 +440,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should render percentile column and cells correctly', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; @@ -481,8 +476,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should delete all selection', async () => { - const createdRule1 = await createRule({ - supertest, + const createdRule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [createdRule1.id]; @@ -508,12 +503,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it.skip('should filter rules by the status', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); - const failedRule = await createRule({ - supertest, + const failedRule = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id, failedRule.id]; @@ -558,8 +553,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it.skip('should display total rules by status and error banner only when exists rules with status error', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); await refreshRulesList(); @@ -582,8 +577,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { ); expect(alertsErrorBannerWhenNoErrors).toHaveLength(0); - const failedRule = await createRule({ - supertest, + const failedRule = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id, failedRule.id]; @@ -617,8 +612,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it.skip('Expand error in rules table when there is rule with an error associated', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, name: 'a', }); @@ -639,8 +634,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { let expandRulesErrorLink = await find.allByCssSelector('[data-test-subj="expandRulesError"]'); expect(expandRulesErrorLink).toHaveLength(0); - const failedRule = await createRule({ - supertest, + const failedRule = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id, failedRule.id]; @@ -666,12 +661,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should filter rules by the rule type', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); - const rule2 = await createLatencyThresholdRule({ - supertest, + const rule2 = await alertingApi.helpers.createLatencyThresholdRule({ + roleAuthc, }); ruleIdList = [rule1.id, rule2.id]; @@ -730,36 +725,36 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }; // Enabled alert - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); - const disabledRule = await createRule({ - supertest, + const disabledRule = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc, ruleId: disabledRule.id, }); - const snoozedRule = await createRule({ - supertest, + const snoozedRule = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); - await snoozeRule({ - supertest, + await alertingApi.helpers.snoozeRule({ + roleAuthc, ruleId: snoozedRule.id, }); - const snoozedAndDisabledRule = await createRule({ - supertest, + const snoozedAndDisabledRule = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); - await snoozeRule({ - supertest, + await alertingApi.helpers.snoozeRule({ + roleAuthc, ruleId: snoozedAndDisabledRule.id, }); - await disableRule({ - supertest, + await alertingApi.helpers.disableRule({ + roleAuthc, ruleId: snoozedAndDisabledRule.id, }); @@ -801,28 +796,28 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should filter rules by the tag', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, tags: ['a'], }); - const rule2 = await createRule({ - supertest, + const rule2 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, tags: ['b'], }); - const rule3 = await createRule({ - supertest, + const rule3 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, tags: ['a', 'b'], }); - const rule4 = await createRule({ - supertest, + const rule4 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, tags: ['b', 'c'], }); - const rule5 = await createRule({ - supertest, + const rule5 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, tags: ['c'], }); @@ -864,17 +859,15 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should not prevent rules with action execution capabilities from being edited', async () => { - const action = await createIndexConnector({ - supertestWithoutAuth, + const action = await alertingApi.helpers.createIndexConnector({ roleAuthc, - internalReqHeader, name: 'Index Connector: Alerting API test', indexName: '.alerts-observability.apm.alerts-default', }); expect(action).not.toBe(undefined); - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, actions: [ { group: 'threshold_met', @@ -902,8 +895,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should allow rules to be snoozed using the right side dropdown', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; @@ -922,8 +915,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should allow rules to be snoozed indefinitely using the right side dropdown', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; @@ -942,14 +935,14 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('should allow snoozed rules to be unsnoozed using the right side dropdown', async () => { - const rule1 = await createRule({ - supertest, + const rule1 = await alertingApi.helpers.createAnomalyRule({ + roleAuthc, }); ruleIdList = [rule1.id]; - await snoozeRule({ - supertest, + await alertingApi.helpers.snoozeRule({ + roleAuthc, ruleId: rule1.id, }); diff --git a/x-pack/test_serverless/functional/test_suites/search/rules/rule_details.ts b/x-pack/test_serverless/functional/test_suites/search/rules/rule_details.ts index 40d57101693bc..00363f21299de 100644 --- a/x-pack/test_serverless/functional/test_suites/search/rules/rule_details.ts +++ b/x-pack/test_serverless/functional/test_suites/search/rules/rule_details.ts @@ -7,13 +7,8 @@ import { expect } from 'expect'; import { v4 as uuidv4 } from 'uuid'; -import { InternalRequestHeader, RoleCredentials } from '../../../../shared/services'; +import { RoleCredentials } from '../../../../shared/services'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { - createEsQueryRule as createRule, - createSlackConnector, - createIndexConnector, -} from '../../../../api_integration/test_suites/common/alerting/helpers/alerting_api_helper'; export enum RuleNotifyWhen { CHANGE = 'onActionGroupChange', @@ -34,6 +29,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const comboBox = getService('comboBox'); const config = getService('config'); + const alertingApi = getService('alertingApi'); const openFirstRule = async (ruleName: string) => { await svlTriggersActionsUI.searchRules(ruleName); @@ -66,15 +62,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { let ruleIdList: string[]; let connectorIdList: string[]; - const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); let roleAuthc: RoleCredentials; - let internalReqHeader: InternalRequestHeader; before(async () => { roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); - internalReqHeader = svlCommonApi.getInternalRequestHeader(); await svlCommonPage.loginAsViewer(); }); @@ -88,10 +80,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const RULE_TYPE_ID = '.es-query'; before(async () => { - const rule = await createRule({ - supertestWithoutAuth, + const rule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'alerts', name: ruleName, ruleTypeId: RULE_TYPE_ID, @@ -261,10 +251,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const RULE_TYPE_ID = '.es-query'; before(async () => { - const rule = await createRule({ - supertestWithoutAuth, + const rule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'alerts', name: ruleName, ruleTypeId: RULE_TYPE_ID, @@ -369,26 +357,20 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('should show and update deleted connectors when there are existing connectors of the same type', async () => { const testRunUuid = uuidv4(); - const connector1 = await createSlackConnector({ - supertestWithoutAuth, + const connector1 = await alertingApi.helpers.createSlackConnector({ roleAuthc, - internalReqHeader, name: `slack-${testRunUuid}-${0}`, }); - const connector2 = await createSlackConnector({ - supertestWithoutAuth, + const connector2 = await alertingApi.helpers.createSlackConnector({ roleAuthc, - internalReqHeader, name: `slack-${testRunUuid}-${1}`, }); connectorIdList = [connector2.id]; - const rule = await createRule({ - supertestWithoutAuth, + const rule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'alerts', name: testRunUuid, ruleTypeId: RULE_TYPE_ID, @@ -450,18 +432,14 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('should show and update deleted connectors when there are no existing connectors of the same type', async () => { const testRunUuid = uuidv4(); - const connector = await createIndexConnector({ - supertestWithoutAuth, + const connector = await alertingApi.helpers.createIndexConnector({ roleAuthc, - internalReqHeader, name: `index-${testRunUuid}-${2}`, indexName: ALERT_ACTION_INDEX, }); - const rule = await createRule({ - supertestWithoutAuth, + const rule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'alerts', name: testRunUuid, ruleTypeId: RULE_TYPE_ID, @@ -576,26 +554,20 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const testRunUuid = uuidv4(); const RULE_TYPE_ID = '.es-query'; - const connector1 = await createSlackConnector({ - supertestWithoutAuth, + const connector1 = await alertingApi.helpers.createSlackConnector({ roleAuthc, - internalReqHeader, name: `slack-${testRunUuid}-${0}`, }); - const connector2 = await createSlackConnector({ - supertestWithoutAuth, + const connector2 = await alertingApi.helpers.createSlackConnector({ roleAuthc, - internalReqHeader, name: `slack-${testRunUuid}-${1}`, }); connectorIdList = [connector1.id, connector2.id]; - const rule = await createRule({ - supertestWithoutAuth, + const rule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'alerts', name: `test-rule-${testRunUuid}`, ruleTypeId: RULE_TYPE_ID, @@ -670,10 +642,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('renders a disabled rule details view in app button', async () => { - const rule = await createRule({ - supertestWithoutAuth, + const rule = await alertingApi.helpers.createEsQueryRule({ roleAuthc, - internalReqHeader, consumer: 'alerts', name: ruleName, ruleTypeId: RULE_TYPE_ID, diff --git a/x-pack/test_serverless/shared/services/alerting_api.ts b/x-pack/test_serverless/shared/services/alerting_api.ts new file mode 100644 index 0000000000000..afed22fbe2c9a --- /dev/null +++ b/x-pack/test_serverless/shared/services/alerting_api.ts @@ -0,0 +1,1032 @@ +/* + * 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 moment from 'moment'; +import type { + AggregationsAggregate, + SearchResponse, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Client } from '@elastic/elasticsearch'; +import { MetricThresholdParams } from '@kbn/infra-plugin/common/alerting/metrics'; +import { ThresholdParams } from '@kbn/observability-plugin/common/custom_threshold_rule/types'; +import { v4 as uuidv4 } from 'uuid'; +import type { TryWithRetriesOptions } from '@kbn/ftr-common-functional-services'; +import { RoleCredentials } from '.'; +import type { SloBurnRateRuleParams } from '../../api_integration/services/slo_api'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +interface CreateEsQueryRuleParams { + size: number; + thresholdComparator: string; + threshold: number[]; + timeWindowSize?: number; + timeWindowUnit?: string; + esQuery?: string; + timeField?: string; + searchConfiguration?: unknown; + indexName?: string; + excludeHitsFromPreviousRun?: boolean; + aggType?: string; + aggField?: string; + groupBy?: string; + termField?: string; + termSize?: number; + index?: string[]; +} +const RETRY_COUNT = 10; +const RETRY_DELAY = 1000; + +export function AlertingApiProvider({ getService }: FtrProviderContext) { + const retry = getService('retry'); + const es = getService('es'); + const requestTimeout = 30 * 1000; + const retryTimeout = 120 * 1000; + const logger = getService('log'); + const svlCommonApi = getService('svlCommonApi'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + const generateUniqueKey = () => uuidv4().replace(/-/g, ''); + + const helpers = { + async waitForAlertInIndex({ + esClient, + filter, + indexName, + ruleId, + num = 1, + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + filter: Date; + indexName: string; + ruleId: string; + num: number; + retryOptions?: TryWithRetriesOptions; + }): Promise>> { + return await retry.tryWithRetries( + `Alerting API - waitForAlertInIndex, retryOptions: ${JSON.stringify(retryOptions)}`, + async () => { + const response = await esClient.search({ + index: indexName, + body: { + query: { + bool: { + must: [ + { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + { + range: { + '@timestamp': { + gte: filter.getTime().toString(), + }, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length < num) + throw new Error(`Only found ${response.hits.hits.length} / ${num} documents`); + + return response; + }, + retryOptions + ); + }, + + async waitForDocumentInIndex({ + esClient, + indexName, + ruleId, + num = 1, + sort = 'desc', + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + indexName: string; + ruleId: string; + num?: number; + sort?: 'asc' | 'desc'; + retryOptions?: TryWithRetriesOptions; + }): Promise { + return await retry.tryWithRetries( + `Alerting API - waitForDocumentInIndex, retryOptions: ${JSON.stringify(retryOptions)}`, + async () => { + const response = await esClient.search({ + index: indexName, + sort: `date:${sort}`, + body: { + query: { + bool: { + must: [ + { + term: { + 'ruleId.keyword': ruleId, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length < num) { + throw new Error(`Only found ${response.hits.hits.length} / ${num} documents`); + } + return response; + }, + retryOptions + ); + }, + + async createIndexConnector({ + roleAuthc, + name, + indexName, + }: { + roleAuthc: RoleCredentials; + name: string; + indexName: string; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/actions/connector`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + name, + config: { + index: indexName, + refresh: true, + }, + connector_type_id: '.index', + }) + .expect(200); + return body; + }, + + async createSlackConnector({ roleAuthc, name }: { roleAuthc: RoleCredentials; name: string }) { + const { body } = await supertestWithoutAuth + .post(`/api/actions/connector`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + name, + config: {}, + secrets: { + webhookUrl: 'http://test', + }, + connector_type_id: '.slack', + }) + .expect(200); + return body; + }, + + async createEsQueryRule({ + roleAuthc, + name, + ruleTypeId, + params, + actions = [], + tags = [], + schedule, + consumer, + notifyWhen, + enabled = true, + }: { + roleAuthc: RoleCredentials; + ruleTypeId: string; + name: string; + params: CreateEsQueryRuleParams; + consumer: string; + actions?: any[]; + tags?: any[]; + schedule?: { interval: string }; + notifyWhen?: string; + enabled?: boolean; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + enabled, + params, + consumer, + schedule: schedule || { + interval: '1h', + }, + tags, + name, + rule_type_id: ruleTypeId, + actions, + ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), + }) + .expect(200); + return body; + }, + + async createAnomalyRule({ + roleAuthc, + name = generateUniqueKey(), + actions = [], + tags = ['foo', 'bar'], + schedule, + consumer = 'alerts', + notifyWhen, + enabled = true, + ruleTypeId = 'apm.anomaly', + params, + }: { + roleAuthc: RoleCredentials; + name?: string; + consumer?: string; + actions?: any[]; + tags?: any[]; + schedule?: { interval: string }; + notifyWhen?: string; + enabled?: boolean; + ruleTypeId?: string; + params?: any; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + enabled, + params: params || { + anomalySeverityType: 'critical', + anomalyDetectorTypes: ['txLatency'], + environment: 'ENVIRONMENT_ALL', + windowSize: 30, + windowUnit: 'm', + }, + consumer, + schedule: schedule || { + interval: '1m', + }, + tags, + name, + rule_type_id: ruleTypeId, + actions, + ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), + }) + .expect(200); + return body; + }, + + async createLatencyThresholdRule({ + roleAuthc, + name = generateUniqueKey(), + actions = [], + tags = ['foo', 'bar'], + schedule, + consumer = 'apm', + notifyWhen, + enabled = true, + ruleTypeId = 'apm.transaction_duration', + params, + }: { + roleAuthc: RoleCredentials; + name?: string; + consumer?: string; + actions?: any[]; + tags?: any[]; + schedule?: { interval: string }; + notifyWhen?: string; + enabled?: boolean; + ruleTypeId?: string; + params?: any; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + enabled, + params: params || { + aggregationType: 'avg', + environment: 'ENVIRONMENT_ALL', + threshold: 1500, + windowSize: 5, + windowUnit: 'm', + }, + consumer, + schedule: schedule || { + interval: '1m', + }, + tags, + name, + rule_type_id: ruleTypeId, + actions, + ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), + }); + return body; + }, + + async createInventoryRule({ + roleAuthc, + name = generateUniqueKey(), + actions = [], + tags = ['foo', 'bar'], + schedule, + consumer = 'alerts', + notifyWhen, + enabled = true, + ruleTypeId = 'metrics.alert.inventory.threshold', + params, + }: { + roleAuthc: RoleCredentials; + name?: string; + consumer?: string; + actions?: any[]; + tags?: any[]; + schedule?: { interval: string }; + notifyWhen?: string; + enabled?: boolean; + ruleTypeId?: string; + params?: any; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + enabled, + params: params || { + nodeType: 'host', + criteria: [ + { + metric: 'cpu', + comparator: '>', + threshold: [5], + timeSize: 1, + timeUnit: 'm', + customMetric: { + type: 'custom', + id: 'alert-custom-metric', + field: '', + aggregation: 'avg', + }, + }, + ], + sourceId: 'default', + }, + consumer, + schedule: schedule || { + interval: '1m', + }, + tags, + name, + rule_type_id: ruleTypeId, + actions, + ...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}), + }) + .expect(200); + return body; + }, + + async disableRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule/${ruleId}/_disable`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(204); + return body; + }, + + async updateEsQueryRule({ + roleAuthc, + ruleId, + updates, + }: { + roleAuthc: RoleCredentials; + ruleId: string; + updates: any; + }) { + const { body: r } = await supertestWithoutAuth + .get(`/api/alerting/rule/${ruleId}`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(200); + const body = await supertestWithoutAuth + .put(`/api/alerting/rule/${ruleId}`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + ...{ + name: r.name, + schedule: r.schedule, + throttle: r.throttle, + tags: r.tags, + params: r.params, + notify_when: r.notifyWhen, + actions: r.actions.map((action: any) => ({ + group: action.group, + params: action.params, + id: action.id, + frequency: action.frequency, + })), + }, + ...updates, + }) + .expect(200); + return body; + }, + + async runRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + const response = await supertestWithoutAuth + .post(`/internal/alerting/rule/${ruleId}/_run_soon`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(204); + return response; + }, + + async waitForNumRuleRuns({ + roleAuthc, + numOfRuns, + ruleId, + esClient, + testStart, + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + roleAuthc: RoleCredentials; + numOfRuns: number; + ruleId: string; + esClient: Client; + testStart: Date; + retryOptions?: TryWithRetriesOptions; + }) { + for (let i = 0; i < numOfRuns; i++) { + await retry.tryWithRetries( + `Alerting API - waitForNumRuleRuns, retryOptions: ${JSON.stringify(retryOptions)}`, + async () => { + await this.runRule({ roleAuthc, ruleId }); + await this.waiting.waitForExecutionEventLog({ + esClient, + filter: testStart, + ruleId, + num: i + 1, + }); + await this.waiting.waitForAllTasksIdle({ esClient, filter: testStart }); + }, + retryOptions + ); + } + }, + + async muteRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule/${ruleId}/_mute_all`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(204); + return body; + }, + + async enableRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule/${ruleId}/_enable`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(204); + return body; + }, + + async muteAlert({ + roleAuthc, + ruleId, + alertId, + }: { + roleAuthc: RoleCredentials; + ruleId: string; + alertId: string; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule/${ruleId}/alert/${alertId}/_mute`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(204); + return body; + }, + + async unmuteRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule/${ruleId}/_unmute_all`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .expect(204); + return body; + }, + + async snoozeRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + const { body } = await supertestWithoutAuth + .post(`/internal/alerting/rule/${ruleId}/_snooze`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + snooze_schedule: { + duration: 100000000, + rRule: { + count: 1, + dtstart: moment().format(), + tzid: 'UTC', + }, + }, + }) + .expect(204); + return body; + }, + + async findRule({ roleAuthc, ruleId }: { roleAuthc: RoleCredentials; ruleId: string }) { + if (!ruleId) { + throw new Error(`'ruleId' is undefined`); + } + const response = await supertestWithoutAuth + .get(`/api/alerting/rule/${ruleId}`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader); + return response.body || {}; + }, + + waiting: { + async waitForDocumentInIndex({ + esClient, + indexName, + ruleId, + num = 1, + sort = 'desc', + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + indexName: string; + ruleId: string; + num?: number; + sort?: 'asc' | 'desc'; + retryOptions?: TryWithRetriesOptions; + }): Promise { + return await retry.tryWithRetries( + `Alerting API - waiting.waitForDocumentInIndex, retryOptions: ${JSON.stringify( + retryOptions + )}`, + async () => { + const response = await esClient.search({ + index: indexName, + sort: `date:${sort}`, + body: { + query: { + bool: { + must: [ + { + term: { + 'ruleId.keyword': ruleId, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length < num) { + throw new Error(`Only found ${response.hits.hits.length} / ${num} documents`); + } + return response; + }, + retryOptions + ); + }, + + async getDocumentsInIndex({ + esClient, + indexName, + ruleId, + }: { + esClient: Client; + indexName: string; + ruleId: string; + }): Promise { + return await esClient.search({ + index: indexName, + body: { + query: { + bool: { + must: [ + { + term: { + 'ruleId.keyword': ruleId, + }, + }, + ], + }, + }, + }, + }); + }, + + async waitForAllTasksIdle({ + esClient, + filter, + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + filter: Date; + retryOptions?: TryWithRetriesOptions; + }): Promise { + return await retry.tryWithRetries( + `Alerting API - waiting.waitForAllTasksIdle, retryOptions: ${JSON.stringify( + retryOptions + )}`, + async () => { + const response = await esClient.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + terms: { + 'task.scope': ['actions', 'alerting'], + }, + }, + { + range: { + 'task.scheduledAt': { + gte: filter.getTime().toString(), + }, + }, + }, + ], + must_not: [ + { + term: { + 'task.status': 'idle', + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length !== 0) { + throw new Error(`Expected 0 hits but received ${response.hits.hits.length}`); + } + return response; + }, + retryOptions + ); + }, + + async waitForExecutionEventLog({ + esClient, + filter, + ruleId, + num = 1, + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + filter: Date; + ruleId: string; + num?: number; + retryOptions?: TryWithRetriesOptions; + }): Promise { + return await retry.tryWithRetries( + `Alerting API - waiting.waitForExecutionEventLog, retryOptions: ${JSON.stringify( + retryOptions + )}`, + async () => { + const response = await esClient.search({ + index: '.kibana-event-log*', + body: { + query: { + bool: { + filter: [ + { + term: { + 'rule.id': { + value: ruleId, + }, + }, + }, + { + term: { + 'event.provider': { + value: 'alerting', + }, + }, + }, + { + term: { + 'event.action': 'execute', + }, + }, + { + range: { + '@timestamp': { + gte: filter.getTime().toString(), + }, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length < num) { + throw new Error('No hits found'); + } + return response; + }, + retryOptions + ); + }, + + async createIndex({ esClient, indexName }: { esClient: Client; indexName: string }) { + return await esClient.indices.create( + { + index: indexName, + body: {}, + }, + { meta: true } + ); + }, + + async waitForAllTasks({ + esClient, + filter, + taskType, + attempts, + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + filter: Date; + taskType: string; + attempts: number; + retryOptions?: TryWithRetriesOptions; + }): Promise { + return await retry.tryWithRetries( + `Alerting API - waiting.waitForAllTasks, retryOptions: ${JSON.stringify(retryOptions)}`, + async () => { + const response = await esClient.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + term: { + 'task.status': 'idle', + }, + }, + { + term: { + 'task.attempts': attempts, + }, + }, + { + terms: { + 'task.scope': ['actions', 'alerting'], + }, + }, + { + term: { + 'task.taskType': taskType, + }, + }, + { + range: { + 'task.scheduledAt': { + gte: filter.getTime().toString(), + }, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length === 0) { + throw new Error('No hits found'); + } + return response; + }, + retryOptions + ); + }, + + async waitForDisabled({ + esClient, + ruleId, + filter, + retryOptions = { retryCount: RETRY_COUNT, retryDelay: RETRY_DELAY }, + }: { + esClient: Client; + ruleId: string; + filter: Date; + retryOptions?: TryWithRetriesOptions; + }): Promise { + return await retry.tryWithRetries( + `Alerting API - waiting.waitForDisabled, retryOptions: ${JSON.stringify(retryOptions)}`, + async () => { + const response = await esClient.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + term: { + 'task.id': `task:${ruleId}`, + }, + }, + { + terms: { + 'task.scope': ['actions', 'alerting'], + }, + }, + { + range: { + 'task.scheduledAt': { + gte: filter.getTime().toString(), + }, + }, + }, + { + term: { + 'task.enabled': true, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length !== 0) { + throw new Error(`Expected 0 hits but received ${response.hits.hits.length}`); + } + return response; + }, + retryOptions + ); + }, + }, + }; + + return { + helpers, + + async waitForRuleStatus({ + roleAuthc, + ruleId, + expectedStatus, + }: { + roleAuthc: RoleCredentials; + ruleId: string; + expectedStatus: string; + }) { + if (!ruleId) { + throw new Error(`'ruleId' is undefined`); + } + return await retry.tryForTime(retryTimeout, async () => { + const response = await supertestWithoutAuth + .get(`/api/alerting/rule/${ruleId}`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .timeout(requestTimeout); + const { execution_status: executionStatus } = response.body || {}; + const { status } = executionStatus || {}; + if (status !== expectedStatus) { + throw new Error(`waitForStatus(${expectedStatus}): got ${status}`); + } + return executionStatus?.status; + }); + }, + + async waitForDocumentInIndex({ + indexName, + docCountTarget = 1, + }: { + indexName: string; + docCountTarget?: number; + }): Promise>> { + return await retry.tryForTime(retryTimeout, async () => { + const response = await es.search({ + index: indexName, + rest_total_hits_as_int: true, + }); + logger.debug(`Found ${response.hits.total} docs, looking for at least ${docCountTarget}.`); + if (!response.hits.total || (response.hits.total as number) < docCountTarget) { + throw new Error('No hits found'); + } + return response; + }); + }, + + async waitForAlertInIndex({ + indexName, + ruleId, + }: { + indexName: string; + ruleId: string; + }): Promise>> { + if (!ruleId) { + throw new Error(`'ruleId' is undefined`); + } + return await retry.tryForTime(retryTimeout, async () => { + const response = await es.search({ + index: indexName, + body: { + query: { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + }, + }); + if (response.hits.hits.length === 0) { + throw new Error('No hits found'); + } + return response; + }); + }, + + async createIndexConnector({ + roleAuthc, + name, + indexName, + }: { + roleAuthc: RoleCredentials; + name: string; + indexName: string; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/actions/connector`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + name, + config: { + index: indexName, + refresh: true, + }, + connector_type_id: '.index', + }); + return body.id as string; + }, + + async createRule({ + roleAuthc, + name, + ruleTypeId, + params, + actions = [], + tags = [], + schedule, + consumer, + }: { + roleAuthc: RoleCredentials; + ruleTypeId: string; + name: string; + params: MetricThresholdParams | ThresholdParams | SloBurnRateRuleParams; + actions?: any[]; + tags?: any[]; + schedule?: { interval: string }; + consumer: string; + }) { + const { body } = await supertestWithoutAuth + .post(`/api/alerting/rule`) + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) + .send({ + params, + consumer, + schedule: schedule || { + interval: '5m', + }, + tags, + name, + rule_type_id: ruleTypeId, + actions, + }); + return body; + }, + + async findRule(roleAuthc: RoleCredentials, ruleId: string) { + if (!ruleId) { + throw new Error(`'ruleId' is undefined`); + } + const response = await supertestWithoutAuth + .get('/api/alerting/rules/_find') + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader); + return response.body.data.find((obj: any) => obj.id === ruleId); + }, + }; +} diff --git a/x-pack/test_serverless/shared/services/deployment_agnostic_services.ts b/x-pack/test_serverless/shared/services/deployment_agnostic_services.ts index 97a5963bd9e3b..2272890e52eb4 100644 --- a/x-pack/test_serverless/shared/services/deployment_agnostic_services.ts +++ b/x-pack/test_serverless/shared/services/deployment_agnostic_services.ts @@ -8,7 +8,7 @@ import _ from 'lodash'; import { services as apiIntegrationServices } from '@kbn/test-suites-xpack/api_integration/services'; - +import { AlertingApiProvider } from './alerting_api'; /* * Some FTR services from api integration stateful tests are compatible with serverless environment * While adding a new one, make sure to verify that it works on both Kibana CI and MKI @@ -35,4 +35,5 @@ const deploymentAgnosticApiIntegrationServices = _.pick(apiIntegrationServices, export const services = { // deployment agnostic FTR services ...deploymentAgnosticApiIntegrationServices, + alertingApi: AlertingApiProvider, }; From 6689169687977595f03a536ee2bbfda7b0135fb1 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Mon, 16 Sep 2024 10:50:50 -0400 Subject: [PATCH 05/16] Move @elastic/kibana-gis ownership to @elastic/kibana-presentation (#192521) ## Summary The legacy `@elastic/kibana-gis` team is now a part of `@elastic/kibana-presentation`. So we should move ownership of all code to the correct team. --- .github/CODEOWNERS | 12 ++++++------ packages/kbn-mapbox-gl/kibana.jsonc | 2 +- renovate.json | 1 - src/plugins/maps_ems/kibana.jsonc | 2 +- .../third_party_maps_source_example/kibana.jsonc | 2 +- x-pack/packages/maps/vector_tile_utils/kibana.jsonc | 2 +- x-pack/plugins/file_upload/kibana.jsonc | 2 +- x-pack/plugins/maps/kibana.jsonc | 2 +- 8 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9adb05cb22ecc..457458ec5b1c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -461,7 +461,7 @@ src/plugins/field_formats @elastic/kibana-data-discovery packages/kbn-field-types @elastic/kibana-data-discovery packages/kbn-field-utils @elastic/kibana-data-discovery x-pack/plugins/fields_metadata @elastic/obs-ux-logs-team -x-pack/plugins/file_upload @elastic/kibana-gis @elastic/ml-ui +x-pack/plugins/file_upload @elastic/kibana-presentation @elastic/ml-ui examples/files_example @elastic/appex-sharedux src/plugins/files_management @elastic/appex-sharedux src/plugins/files @elastic/appex-sharedux @@ -583,11 +583,11 @@ packages/kbn-management/settings/types @elastic/kibana-management packages/kbn-management/settings/utilities @elastic/kibana-management packages/kbn-management/storybook/config @elastic/kibana-management test/plugin_functional/plugins/management_test_plugin @elastic/kibana-management -packages/kbn-mapbox-gl @elastic/kibana-gis -x-pack/examples/third_party_maps_source_example @elastic/kibana-gis -src/plugins/maps_ems @elastic/kibana-gis -x-pack/plugins/maps @elastic/kibana-gis -x-pack/packages/maps/vector_tile_utils @elastic/kibana-gis +packages/kbn-mapbox-gl @elastic/kibana-presentation +x-pack/examples/third_party_maps_source_example @elastic/kibana-presentation +src/plugins/maps_ems @elastic/kibana-presentation +x-pack/plugins/maps @elastic/kibana-presentation +x-pack/packages/maps/vector_tile_utils @elastic/kibana-presentation x-pack/plugins/observability_solution/metrics_data_access @elastic/obs-knowledge-team @elastic/obs-ux-infra_services-team x-pack/packages/ml/agg_utils @elastic/ml-ui x-pack/packages/ml/anomaly_utils @elastic/ml-ui diff --git a/packages/kbn-mapbox-gl/kibana.jsonc b/packages/kbn-mapbox-gl/kibana.jsonc index 4238b33f6aefd..6cc7e1f7b2b30 100644 --- a/packages/kbn-mapbox-gl/kibana.jsonc +++ b/packages/kbn-mapbox-gl/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/mapbox-gl", - "owner": "@elastic/kibana-gis" + "owner": "@elastic/kibana-presentation" } diff --git a/renovate.json b/renovate.json index 02ec0d0c127a4..6f3b61c6e1b12 100644 --- a/renovate.json +++ b/renovate.json @@ -371,7 +371,6 @@ "team:kibana-presentation", "team:kibana-data-discovery", "team:kibana-management", - "team:kibana-gis", "team:security-solution" ], "matchBaseBranches": ["main"], diff --git a/src/plugins/maps_ems/kibana.jsonc b/src/plugins/maps_ems/kibana.jsonc index f71542e94ae71..a341ad05f4e4b 100644 --- a/src/plugins/maps_ems/kibana.jsonc +++ b/src/plugins/maps_ems/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "plugin", "id": "@kbn/maps-ems-plugin", - "owner": "@elastic/kibana-gis", + "owner": "@elastic/kibana-presentation", "plugin": { "id": "mapsEms", "server": true, diff --git a/x-pack/examples/third_party_maps_source_example/kibana.jsonc b/x-pack/examples/third_party_maps_source_example/kibana.jsonc index 6b1317437401d..5b987dcd966ab 100644 --- a/x-pack/examples/third_party_maps_source_example/kibana.jsonc +++ b/x-pack/examples/third_party_maps_source_example/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "plugin", "id": "@kbn/maps-custom-raster-source-plugin", - "owner": "@elastic/kibana-gis", + "owner": "@elastic/kibana-presentation", "description": "An example plugin for creating a custom raster source for Elastic Maps", "plugin": { "id": "mapsCustomRasterSource", diff --git a/x-pack/packages/maps/vector_tile_utils/kibana.jsonc b/x-pack/packages/maps/vector_tile_utils/kibana.jsonc index 7fa54b903a4a5..5e1e9957ecdf3 100644 --- a/x-pack/packages/maps/vector_tile_utils/kibana.jsonc +++ b/x-pack/packages/maps/vector_tile_utils/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/maps-vector-tile-utils", - "owner": "@elastic/kibana-gis" + "owner": "@elastic/kibana-presentation" } diff --git a/x-pack/plugins/file_upload/kibana.jsonc b/x-pack/plugins/file_upload/kibana.jsonc index 6c6e3fddd0e7c..9d8143dafcb46 100644 --- a/x-pack/plugins/file_upload/kibana.jsonc +++ b/x-pack/plugins/file_upload/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "plugin", "id": "@kbn/file-upload-plugin", - "owner": ["@elastic/kibana-gis", "@elastic/ml-ui"], + "owner": ["@elastic/kibana-presentation", "@elastic/ml-ui"], "description": "The file upload plugin contains components and services for uploading a file, analyzing its data, and then importing the data into an Elasticsearch index. Supported file types include CSV, TSV, newline-delimited JSON and GeoJSON.", "plugin": { "id": "fileUpload", diff --git a/x-pack/plugins/maps/kibana.jsonc b/x-pack/plugins/maps/kibana.jsonc index b042d0250b0c2..421817e87344f 100644 --- a/x-pack/plugins/maps/kibana.jsonc +++ b/x-pack/plugins/maps/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "plugin", "id": "@kbn/maps-plugin", - "owner": "@elastic/kibana-gis", + "owner": "@elastic/kibana-presentation", "plugin": { "id": "maps", "server": true, From f029f8086a6731b5f435775c915d46e110a34658 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Mon, 16 Sep 2024 16:58:13 +0200 Subject: [PATCH 06/16] [Observability] Create observability-specific setting for excluding data tiers from queries (#192570) part of [#190559](https://github.com/elastic/kibana/issues/190559) ## Summary This PR introduces a new `Advanced Settings` under `Observabilty` to provide a way of configuring the exclusion of indices in the `data_cold` and/or `data_frozen` tiers from queries. The change will help to address issues encountered in O11y, most specifically in APM, and could also affect Infra and other features, with unbounded queries targeting the frozen tier. ### For reviewers This PR replaces https://github.com/elastic/kibana/pull/192276 --------- Co-authored-by: Elastic Machine --- .../settings/setting_ids/index.ts | 1 + .../settings/observability_project/index.ts | 1 + .../server/collectors/management/schema.ts | 7 +++++++ .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 9 +++++++++ .../observability/common/ui_settings_keys.ts | 1 + .../observability/server/ui_settings.ts | 19 +++++++++++++++++++ 7 files changed, 39 insertions(+) diff --git a/packages/kbn-management/settings/setting_ids/index.ts b/packages/kbn-management/settings/setting_ids/index.ts index 08ce7f3579229..0f79a5fff0506 100644 --- a/packages/kbn-management/settings/setting_ids/index.ts +++ b/packages/kbn-management/settings/setting_ids/index.ts @@ -150,6 +150,7 @@ export const OBSERVABILITY_AI_ASSISTANT_SIMULATED_FUNCTION_CALLING = 'observability:aiAssistantSimulatedFunctionCalling'; export const OBSERVABILITY_AI_ASSISTANT_SEARCH_CONNECTOR_INDEX_PATTERN = 'observability:aiAssistantSearchConnectorIndexPattern'; +export const OBSERVABILITY_SEARCH_EXCLUDED_DATA_TIERS = 'observability:searchExcludedDataTiers'; // Reporting settings export const XPACK_REPORTING_CUSTOM_PDF_LOGO_ID = 'xpackReporting:customPdfLogo'; diff --git a/packages/serverless/settings/observability_project/index.ts b/packages/serverless/settings/observability_project/index.ts index 470964954d166..85f6327bf0a07 100644 --- a/packages/serverless/settings/observability_project/index.ts +++ b/packages/serverless/settings/observability_project/index.ts @@ -37,4 +37,5 @@ export const OBSERVABILITY_PROJECT_SETTINGS = [ settings.OBSERVABILITY_AI_ASSISTANT_LOGS_INDEX_PATTERN_ID, settings.OBSERVABILITY_AI_ASSISTANT_SIMULATED_FUNCTION_CALLING, settings.OBSERVABILITY_AI_ASSISTANT_SEARCH_CONNECTOR_INDEX_PATTERN, + settings.OBSERVABILITY_SEARCH_EXCLUDED_DATA_TIERS, ]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index d1ab81f3e60a7..52c0df738246a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -694,4 +694,11 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:searchExcludedDataTiers': { + type: 'array', + items: { + type: 'keyword', + _meta: { description: 'Non-default value of setting.' }, + }, + }, }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index c66f4f07a296e..0a0ebe8ebbac6 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -181,4 +181,5 @@ export interface UsageStats { 'aiAssistant:preferredAIAssistantType': string; 'observability:profilingFetchTopNFunctionsFromStacktraces': boolean; 'securitySolution:excludedDataTiersForRuleExecution': string[]; + 'observability:searchExcludedDataTiers': string[]; } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 70fbeec73bc5d..77e050334803b 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -10355,6 +10355,15 @@ } } }, + "observability:searchExcludedDataTiers": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "Non-default value of setting." + } + } + }, "banners:placement": { "type": "keyword", "_meta": { diff --git a/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts index fe43cd30705db..efceaca9a0427 100644 --- a/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts @@ -48,3 +48,4 @@ export const profilingAzureCostDiscountRate = 'observability:profilingAzureCostD export const apmEnableTransactionProfiling = 'observability:apmEnableTransactionProfiling'; export const profilingFetchTopNFunctionsFromStacktraces = 'observability:profilingFetchTopNFunctionsFromStacktraces'; +export const searchExcludedDataTiers = 'observability:searchExcludedDataTiers'; diff --git a/x-pack/plugins/observability_solution/observability/server/ui_settings.ts b/x-pack/plugins/observability_solution/observability/server/ui_settings.ts index d404606b4ce79..81c0596722106 100644 --- a/x-pack/plugins/observability_solution/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability_solution/observability/server/ui_settings.ts @@ -46,6 +46,7 @@ import { apmEnableServiceInventoryTableSearchBar, profilingFetchTopNFunctionsFromStacktraces, enableInfrastructureContainerAssetView, + searchExcludedDataTiers, } from '../common/ui_settings_keys'; const betaLabel = i18n.translate('xpack.observability.uiSettings.betaLabel', { @@ -640,6 +641,24 @@ export const uiSettings: Record = { schema: schema.boolean(), requiresPageReload: false, }, + [searchExcludedDataTiers]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.searchExcludedDataTiers', { + defaultMessage: 'Excluded data tiers from search', + }), + description: i18n.translate( + 'xpack.observability.advancedSettings.searchExcludedDataTiersDesc', + { + defaultMessage: `Specify the data tiers to exclude from search, such as data_cold and/or data_frozen. + When configured, indices allocated in the selected tiers will be ignored from search requests. Affected apps: APM`, + } + ), + value: [], + schema: schema.arrayOf( + schema.oneOf([schema.literal('data_cold'), schema.literal('data_frozen')]) + ), + requiresPageReload: false, + }, }; function throttlingDocsLink({ href }: { href: string }) { From cd964f1229b1fdc919677768dae22cf1c05fa3e2 Mon Sep 17 00:00:00 2001 From: Tiago Vila Verde Date: Mon, 16 Sep 2024 17:15:10 +0200 Subject: [PATCH 07/16] [Security Solution][Entity Analytics] APIs for Entity Store engine (#191986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces the following API routes for setting up Entity Store "engines":
Initialise Engine | POST /api/entity_store/engines//init -- | -- Start Engine | POST /api/entity_store/engines//start Stop Engine | POST /api/entity_store/engines//stop Delete Engine | DELETE /api/entity_store/engines/ Get engine | GET  /api/entity_store/engines/ List Engines | GET /api/entity_store/engines
The PR includes the following: - Adding the `EntityManager` plugin (see elastic/obs-entities) as a dependency of the Security Solution - The OpenAPI schemas for the new routes - The actual Kibana side endpoints - A `Saved Object` to track the installed engines - A new `EntityStoreDataClient` - A new feature flag `entityStoreEngineRoutesEnabled` ### How to test 1. Add some host/user data * Easiest is to use [elastic/security-data-generator](https://github.com/elastic/security-documents-generator) 2. Make sure to add `entityStoreEngineRoutesEnabled` under `xpack.securitySolution.enableExperimental` in your `kibana.dev.yml` 3. In kibana dev tools or your terminal, call the `INIT` route for either `user` or `host`. 4. You should now see 2 transforms in kibana. Make sure to re-trigger them if needed so they process the documents. 5. Check that new entities have been observed by querying the new entities index via: * `GET .entities.v1.latest.ea*/_search` 6. Check the other endpoints are working (`START`, `STOP`, `LIST`, etc) 7. Calling `DELETE` should remove the transforms Implements https://github.com/elastic/security-team/issues/10230 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../current_fields.json | 6 + .../current_mappings.json | 17 ++ .../check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + .../entity_store/common.gen.ts | 38 ++++ .../entity_store/common.schema.yaml | 37 ++++ .../entity_store/engine/delete.gen.ts | 43 ++++ .../entity_store/engine/delete.schema.yaml | 37 ++++ .../entity_store/engine/get.gen.ts | 33 +++ .../entity_store/engine/get.schema.yaml | 25 +++ .../entity_store/engine/init.gen.ts | 38 ++++ .../entity_store/engine/init.schema.yaml | 39 ++++ .../entity_store/engine/list.gen.ts | 25 +++ .../entity_store/engine/list.schema.yaml | 25 +++ .../entity_store/engine/start.gen.ts | 33 +++ .../entity_store/engine/start.schema.yaml | 31 +++ .../entity_store/engine/stats.gen.ts | 39 ++++ .../entity_store/engine/stats.schema.yaml | 41 ++++ .../entity_store/engine/stop.gen.ts | 33 +++ .../entity_store/engine/stop.schema.yaml | 30 +++ .../common/api/quickstart_client.gen.ts | 134 ++++++++++++ .../common/experimental_features.ts | 5 + ...alytics_api_2023_10_31.bundled.schema.yaml | 205 ++++++++++++++++++ ...alytics_api_2023_10_31.bundled.schema.yaml | 205 ++++++++++++++++++ x-pack/plugins/security_solution/kibana.jsonc | 5 +- .../routes/__mocks__/request_context.ts | 3 + .../entity_store/constants.ts | 21 ++ .../entity_store/definition.ts | 56 +++++ .../entity_store_data_client.mock.ts | 20 ++ .../entity_store/entity_store_data_client.ts | 120 ++++++++++ .../entity_store/routes/delete.ts | 64 ++++++ .../entity_store/routes/get.ts | 62 ++++++ .../entity_store/routes/index.ts | 8 + .../entity_store/routes/init.ts | 65 ++++++ .../entity_store/routes/list.ts | 57 +++++ .../routes/register_entity_store_routes.ts | 23 ++ .../entity_store/routes/start.ts | 59 +++++ .../entity_store/routes/stats.ts | 58 +++++ .../entity_store/routes/stop.ts | 59 +++++ .../saved_object/engine_descriptor.ts | 76 +++++++ .../saved_object/engine_descriptor_type.ts | 36 +++ .../entity_store/saved_object/index.ts | 8 + .../entity_store/utils/utils.ts | 33 +++ .../register_entity_analytics_routes.ts | 4 + .../server/request_context_factory.ts | 18 ++ .../security_solution/server/saved_objects.ts | 2 + .../plugins/security_solution/server/types.ts | 2 + .../plugins/security_solution/tsconfig.json | 2 + .../services/security_solution_api.gen.ts | 83 +++++++ .../platform_security/authorization.ts | 34 +++ 50 files changed, 2097 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.schema.yaml create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.mock.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor_type.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/utils.ts diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 0447ba6a226dd..ec14f4519d344 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -312,6 +312,12 @@ "entity-discovery-api-key": [ "apiKey" ], + "entity-engine-status": [ + "filter", + "indexPattern", + "status", + "type" + ], "epm-packages": [ "additional_spaces_installed_kibana", "es_index_patterns", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index bda2270001bd9..2bdf6e75ad1cb 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1057,6 +1057,23 @@ } } }, + "entity-engine-status": { + "dynamic": false, + "properties": { + "filter": { + "type": "keyword" + }, + "indexPattern": { + "type": "keyword" + }, + "status": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, "epm-packages": { "properties": { "additional_spaces_installed_kibana": { diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 92de0c925951b..170cfa5958782 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -93,6 +93,7 @@ describe('checking migration metadata changes on all registered SO types', () => "enterprise_search_telemetry": "9ac912e1417fc8681e0cd383775382117c9e3d3d", "entity-definition": "61be3e95966045122b55e181bb39658b1dc9bbe9", "entity-discovery-api-key": "c267a65c69171d1804362155c1378365f5acef88", + "entity-engine-status": "0738aa1a06d3361911740f8f166071ea43a00927", "epm-packages": "8042d4a1522f6c4e6f5486e791b3ffe3a22f88fd", "epm-packages-assets": "7a3e58efd9a14191d0d1a00b8aaed30a145fd0b1", "event-annotation-group": "715ba867d8c68f3c9438052210ea1c30a9362582", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index 4320b0eb689d9..e95a82e63d0ff 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -124,6 +124,7 @@ const previouslyRegisteredTypes = [ 'security-rule', 'security-solution-signals-migration', 'risk-engine-configuration', + 'entity-engine-status', 'server', 'siem-detection-engine-rule-actions', 'siem-detection-engine-rule-execution-info', diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts new file mode 100644 index 0000000000000..e5f8c631fcbae --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Entity Store Common Schema + * version: 1 + */ + +import { z } from '@kbn/zod'; + +export type EntityType = z.infer; +export const EntityType = z.enum(['user', 'host']); +export type EntityTypeEnum = typeof EntityType.enum; +export const EntityTypeEnum = EntityType.enum; + +export type IndexPattern = z.infer; +export const IndexPattern = z.string(); + +export type EngineStatus = z.infer; +export const EngineStatus = z.enum(['installing', 'started', 'stopped']); +export type EngineStatusEnum = typeof EngineStatus.enum; +export const EngineStatusEnum = EngineStatus.enum; + +export type EngineDescriptor = z.infer; +export const EngineDescriptor = z.object({ + type: EntityType.optional(), + indexPattern: IndexPattern.optional(), + status: EngineStatus.optional(), + filter: z.string().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml new file mode 100644 index 0000000000000..dc17ad6193ee5 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.0 +info: + title: Entity Store Common Schema + description: Common schema for Entity Store + version: '1' +paths: {} +components: + schemas: + + EntityType: + type: string + enum: + - user + - host + + EngineDescriptor: + type: object + properties: + type: + $ref: '#/components/schemas/EntityType' + indexPattern: + $ref: '#/components/schemas/IndexPattern' + status: + $ref: '#/components/schemas/EngineStatus' + filter: + type: string + + EngineStatus: + type: string + enum: + - installing + - started + - stopped + + IndexPattern: + type: string + \ No newline at end of file diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.gen.ts new file mode 100644 index 0000000000000..34acf2a802076 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.gen.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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Delete the entity store engine + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; +import { BooleanFromString } from '@kbn/zod-helpers'; + +import { EntityType } from '../common.gen'; + +export type DeleteEntityStoreRequestQuery = z.infer; +export const DeleteEntityStoreRequestQuery = z.object({ + /** + * Control flag to also delete the entity data. + */ + data: BooleanFromString.optional(), +}); +export type DeleteEntityStoreRequestQueryInput = z.input; + +export type DeleteEntityStoreRequestParams = z.infer; +export const DeleteEntityStoreRequestParams = z.object({ + /** + * The entity type of the store (either 'user' or 'host'). + */ + entityType: EntityType, +}); +export type DeleteEntityStoreRequestParamsInput = z.input; + +export type DeleteEntityStoreResponse = z.infer; +export const DeleteEntityStoreResponse = z.object({ + deleted: z.boolean().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.schema.yaml new file mode 100644 index 0000000000000..c766d9895c5fa --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/delete.schema.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.0 + +info: + title: Delete the entity store engine + version: '2023-10-31' +paths: + /api/entity_store/engines/{entityType}: + delete: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: DeleteEntityStore + summary: Delete the Entity Store engine + parameters: + - name: entityType + in: path + required: true + schema: + $ref: '../common.schema.yaml#/components/schemas/EntityType' + description: The entity type of the store (either 'user' or 'host'). + + - name: data + in: query + required: false + schema: + type: boolean + description: Control flag to also delete the entity data. + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + deleted: + type: boolean + \ No newline at end of file diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.gen.ts new file mode 100644 index 0000000000000..44f6f45844fc1 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.gen.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get Entity Store engine + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { EntityType, EngineDescriptor } from '../common.gen'; + +export type GetEntityStoreEngineRequestParams = z.infer; +export const GetEntityStoreEngineRequestParams = z.object({ + /** + * The entity type of the store (either 'user' or 'host'). + */ + entityType: EntityType, +}); +export type GetEntityStoreEngineRequestParamsInput = z.input< + typeof GetEntityStoreEngineRequestParams +>; + +export type GetEntityStoreEngineResponse = z.infer; +export const GetEntityStoreEngineResponse = EngineDescriptor; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.schema.yaml new file mode 100644 index 0000000000000..d65a5906e54d9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get.schema.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.0 +info: + title: Get Entity Store engine + version: '2023-10-31' +paths: + /api/entity_store/engines/{entityType}: + get: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: GetEntityStoreEngine + summary: Get the Entity Store engine + parameters: + - name: entityType + in: path + required: true + schema: + $ref: '../common.schema.yaml#/components/schemas/EntityType' + description: The entity type of the store (either 'user' or 'host'). + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../common.schema.yaml#/components/schemas/EngineDescriptor' diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.gen.ts new file mode 100644 index 0000000000000..07f32f4cb7144 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.gen.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Init Entity Store types + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { EntityType, IndexPattern, EngineDescriptor } from '../common.gen'; + +export type InitEntityStoreRequestParams = z.infer; +export const InitEntityStoreRequestParams = z.object({ + /** + * The entity type of the store (either 'user' or 'host'). + */ + entityType: EntityType, +}); +export type InitEntityStoreRequestParamsInput = z.input; + +export type InitEntityStoreRequestBody = z.infer; +export const InitEntityStoreRequestBody = z.object({ + indexPattern: IndexPattern.optional(), + filter: z.string().optional(), +}); +export type InitEntityStoreRequestBodyInput = z.input; + +export type InitEntityStoreResponse = z.infer; +export const InitEntityStoreResponse = EngineDescriptor; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.schema.yaml new file mode 100644 index 0000000000000..8e826d57ce40a --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/init.schema.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.0 + +info: + title: Init Entity Store types + version: '2023-10-31' +paths: + /api/entity_store/engines/{entityType}/init: + post: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: InitEntityStore + summary: Initialize the Entity Store + parameters: + - name: entityType + in: path + required: true + schema: + $ref: '../common.schema.yaml#/components/schemas/EntityType' + description: The entity type of the store (either 'user' or 'host'). + requestBody: + description: Schema for the engine initialization + required: true + content: + application/json: + schema: + type: object + properties: + indexPattern: + $ref: '../common.schema.yaml#/components/schemas/IndexPattern' + filter: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../common.schema.yaml#/components/schemas/EngineDescriptor' + diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.gen.ts new file mode 100644 index 0000000000000..926549a329a4b --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.gen.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: List Entity Store engines + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { EngineDescriptor } from '../common.gen'; + +export type ListEntityStoreEnginesResponse = z.infer; +export const ListEntityStoreEnginesResponse = z.object({ + count: z.number().int().optional(), + engines: z.array(EngineDescriptor).optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.schema.yaml new file mode 100644 index 0000000000000..efad1a4380352 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/list.schema.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.0 +info: + title: List Entity Store engines + version: '2023-10-31' +paths: + /api/entity_store/engines: + get: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: ListEntityStoreEngines + summary: List the Entity Store engines + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + count: + type: integer + engines: + type: array + items: + $ref: '../common.schema.yaml#/components/schemas/EngineDescriptor' \ No newline at end of file diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.gen.ts new file mode 100644 index 0000000000000..b8e94d00551c0 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.gen.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Start the entity store engine + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { EntityType } from '../common.gen'; + +export type StartEntityStoreRequestParams = z.infer; +export const StartEntityStoreRequestParams = z.object({ + /** + * The entity type of the store (either 'user' or 'host'). + */ + entityType: EntityType, +}); +export type StartEntityStoreRequestParamsInput = z.input; + +export type StartEntityStoreResponse = z.infer; +export const StartEntityStoreResponse = z.object({ + started: z.boolean().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.schema.yaml new file mode 100644 index 0000000000000..5c048bf3d973c --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/start.schema.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 + +info: + title: Start the entity store engine + version: '2023-10-31' +paths: + /api/entity_store/engines/{entityType}/start: + post: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: StartEntityStore + summary: Start the Entity Store engine + parameters: + - name: entityType + in: path + required: true + schema: + $ref: '../common.schema.yaml#/components/schemas/EntityType' + description: The entity type of the store (either 'user' or 'host'). + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + started: + type: boolean + + \ No newline at end of file diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts new file mode 100644 index 0000000000000..631399faebc96 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get the entity store engine stats + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { EntityType, IndexPattern, EngineStatus } from '../common.gen'; + +export type GetEntityStoreStatsRequestParams = z.infer; +export const GetEntityStoreStatsRequestParams = z.object({ + /** + * The entity type of the store (either 'user' or 'host'). + */ + entityType: EntityType, +}); +export type GetEntityStoreStatsRequestParamsInput = z.input< + typeof GetEntityStoreStatsRequestParams +>; + +export type GetEntityStoreStatsResponse = z.infer; +export const GetEntityStoreStatsResponse = z.object({ + type: EntityType.optional(), + indexPattern: IndexPattern.optional(), + status: EngineStatus.optional(), + transforms: z.array(z.object({})).optional(), + indices: z.array(z.object({})).optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml new file mode 100644 index 0000000000000..8d8327d5e6468 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml @@ -0,0 +1,41 @@ +openapi: 3.0.0 + +info: + title: Get the entity store engine stats + version: '2023-10-31' +paths: + /api/entity_store/engines/{entityType}/stats: + post: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: GetEntityStoreStats + summary: Get the Entity Store engine stats + parameters: + - name: entityType + in: path + required: true + schema: + $ref: '../common.schema.yaml#/components/schemas/EntityType' + description: The entity type of the store (either 'user' or 'host'). + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + type: + $ref : '../common.schema.yaml#/components/schemas/EntityType' + indexPattern: + $ref : '../common.schema.yaml#/components/schemas/IndexPattern' + status: + $ref : '../common.schema.yaml#/components/schemas/EngineStatus' + transforms: + type: array + items: + type: object + indices: + type: array + items: + type: object diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.gen.ts new file mode 100644 index 0000000000000..ff3ef7a2f3eac --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.gen.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Stop the entity store engine + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; + +import { EntityType } from '../common.gen'; + +export type StopEntityStoreRequestParams = z.infer; +export const StopEntityStoreRequestParams = z.object({ + /** + * The entity type of the store (either 'user' or 'host'). + */ + entityType: EntityType, +}); +export type StopEntityStoreRequestParamsInput = z.input; + +export type StopEntityStoreResponse = z.infer; +export const StopEntityStoreResponse = z.object({ + stopped: z.boolean().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.schema.yaml new file mode 100644 index 0000000000000..214f803a76e34 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stop.schema.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.0 + +info: + title: Stop the entity store engine + version: '2023-10-31' +paths: + /api/entity_store/engines/{entityType}/stop: + post: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: StopEntityStore + summary: Stop the Entity Store engine + parameters: + - name: entityType + in: path + required: true + schema: + $ref: '../common.schema.yaml#/components/schemas/EntityType' + description: The entity type of the store (either 'user' or 'host'). + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + stopped: + type: boolean + diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index edd0bfe89fc8c..c08f807d4926b 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -242,6 +242,33 @@ import type { InternalUploadAssetCriticalityRecordsResponse, UploadAssetCriticalityRecordsResponse, } from './entity_analytics/asset_criticality/upload_asset_criticality_csv.gen'; +import type { + DeleteEntityStoreRequestQueryInput, + DeleteEntityStoreRequestParamsInput, + DeleteEntityStoreResponse, +} from './entity_analytics/entity_store/engine/delete.gen'; +import type { + GetEntityStoreEngineRequestParamsInput, + GetEntityStoreEngineResponse, +} from './entity_analytics/entity_store/engine/get.gen'; +import type { + InitEntityStoreRequestParamsInput, + InitEntityStoreRequestBodyInput, + InitEntityStoreResponse, +} from './entity_analytics/entity_store/engine/init.gen'; +import type { ListEntityStoreEnginesResponse } from './entity_analytics/entity_store/engine/list.gen'; +import type { + StartEntityStoreRequestParamsInput, + StartEntityStoreResponse, +} from './entity_analytics/entity_store/engine/start.gen'; +import type { + GetEntityStoreStatsRequestParamsInput, + GetEntityStoreStatsResponse, +} from './entity_analytics/entity_store/engine/stats.gen'; +import type { + StopEntityStoreRequestParamsInput, + StopEntityStoreResponse, +} from './entity_analytics/entity_store/engine/stop.gen'; import type { DisableRiskEngineResponse } from './entity_analytics/risk_engine/engine_disable_route.gen'; import type { EnableRiskEngineResponse } from './entity_analytics/risk_engine/engine_enable_route.gen'; import type { InitRiskEngineResponse } from './entity_analytics/risk_engine/engine_init_route.gen'; @@ -620,6 +647,20 @@ Migrations are initiated per index. While the process is neither destructive nor }) .catch(catchAxiosErrorFormatAndThrow); } + async deleteEntityStore(props: DeleteEntityStoreProps) { + this.log.info(`${new Date().toISOString()} Calling API DeleteEntityStore`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_store/engines/{entityType}', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'DELETE', + + query: props.query, + }) + .catch(catchAxiosErrorFormatAndThrow); + } async deleteNote(props: DeleteNoteProps) { this.log.info(`${new Date().toISOString()} Calling API DeleteNote`); return this.kbnClient @@ -1155,6 +1196,30 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async getEntityStoreEngine(props: GetEntityStoreEngineProps) { + this.log.info(`${new Date().toISOString()} Calling API GetEntityStoreEngine`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_store/engines/{entityType}', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } + async getEntityStoreStats(props: GetEntityStoreStatsProps) { + this.log.info(`${new Date().toISOString()} Calling API GetEntityStoreStats`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_store/engines/{entityType}/stats', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'POST', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Gets notes */ @@ -1311,6 +1376,19 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async initEntityStore(props: InitEntityStoreProps) { + this.log.info(`${new Date().toISOString()} Calling API InitEntityStore`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_store/engines/{entityType}/init', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'POST', + body: props.body, + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Initializes the Risk Engine by creating the necessary indices and mappings, removing old transforms, and starting the new risk engine */ @@ -1367,6 +1445,18 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async listEntityStoreEngines() { + this.log.info(`${new Date().toISOString()} Calling API ListEntityStoreEngines`); + return this.kbnClient + .request({ + path: '/api/entity_store/engines', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Update specific fields of an existing detection rule using the `rule_id` or `id` field. */ @@ -1699,6 +1789,30 @@ detection engine rules. }) .catch(catchAxiosErrorFormatAndThrow); } + async startEntityStore(props: StartEntityStoreProps) { + this.log.info(`${new Date().toISOString()} Calling API StartEntityStore`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_store/engines/{entityType}/start', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'POST', + }) + .catch(catchAxiosErrorFormatAndThrow); + } + async stopEntityStore(props: StopEntityStoreProps) { + this.log.info(`${new Date().toISOString()} Calling API StopEntityStore`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_store/engines/{entityType}/stop', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', + }, + method: 'POST', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Suggests user profiles. */ @@ -1809,6 +1923,10 @@ export interface CreateUpdateProtectionUpdatesNoteProps { export interface DeleteAssetCriticalityRecordProps { query: DeleteAssetCriticalityRecordRequestQueryInput; } +export interface DeleteEntityStoreProps { + query: DeleteEntityStoreRequestQueryInput; + params: DeleteEntityStoreRequestParamsInput; +} export interface DeleteNoteProps { body: DeleteNoteRequestBodyInput; } @@ -1902,6 +2020,12 @@ export interface GetEndpointSuggestionsProps { params: GetEndpointSuggestionsRequestParamsInput; body: GetEndpointSuggestionsRequestBodyInput; } +export interface GetEntityStoreEngineProps { + params: GetEntityStoreEngineRequestParamsInput; +} +export interface GetEntityStoreStatsProps { + params: GetEntityStoreStatsRequestParamsInput; +} export interface GetNotesProps { query: GetNotesRequestQueryInput; } @@ -1932,6 +2056,10 @@ export interface ImportRulesProps { export interface ImportTimelinesProps { body: ImportTimelinesRequestBodyInput; } +export interface InitEntityStoreProps { + params: InitEntityStoreRequestParamsInput; + body: InitEntityStoreRequestBodyInput; +} export interface InstallPrepackedTimelinesProps { body: InstallPrepackedTimelinesRequestBodyInput; } @@ -1984,6 +2112,12 @@ export interface SetAlertsStatusProps { export interface SetAlertTagsProps { body: SetAlertTagsRequestBodyInput; } +export interface StartEntityStoreProps { + params: StartEntityStoreRequestParamsInput; +} +export interface StopEntityStoreProps { + params: StopEntityStoreRequestParamsInput; +} export interface SuggestUserProfilesProps { query: SuggestUserProfilesRequestQueryInput; } diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 7d3edafedd1a9..e11965653526f 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -223,6 +223,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the new data ingestion hub */ dataIngestionHubEnabled: false, + + /** + * Enables the new Entity Store engine routes + */ + entityStoreEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index 7ee7a3748df4b..9e56395f2af75 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -256,6 +256,187 @@ paths: summary: List Asset Criticality Records tags: - Security Solution Entity Analytics API + /api/entity_store/engines: + get: + operationId: ListEntityStoreEngines + responses: + '200': + content: + application/json: + schema: + type: object + properties: + count: + type: integer + engines: + items: + $ref: '#/components/schemas/EngineDescriptor' + type: array + description: Successful response + summary: List the Entity Store engines + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}': + delete: + operationId: DeleteEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + - description: Control flag to also delete the entity data. + in: query + name: data + required: false + schema: + type: boolean + responses: + '200': + content: + application/json: + schema: + type: object + properties: + deleted: + type: boolean + description: Successful response + summary: Delete the Entity Store engine + tags: + - Security Solution Entity Analytics API + get: + operationId: GetEntityStoreEngine + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EngineDescriptor' + description: Successful response + summary: Get the Entity Store engine + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/init': + post: + operationId: InitEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + requestBody: + content: + application/json: + schema: + type: object + properties: + filter: + type: string + indexPattern: + $ref: '#/components/schemas/IndexPattern' + description: Schema for the engine initialization + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EngineDescriptor' + description: Successful response + summary: Initialize the Entity Store + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/start': + post: + operationId: StartEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + started: + type: boolean + description: Successful response + summary: Start the Entity Store engine + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/stats': + post: + operationId: GetEntityStoreStats + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + indexPattern: + $ref: '#/components/schemas/IndexPattern' + indices: + items: + type: object + type: array + status: + $ref: '#/components/schemas/EngineStatus' + transforms: + items: + type: object + type: array + type: + $ref: '#/components/schemas/EntityType' + description: Successful response + summary: Get the Entity Store engine stats + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/stop': + post: + operationId: StopEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + stopped: + type: boolean + description: Successful response + summary: Stop the Entity Store engine + tags: + - Security Solution Entity Analytics API /api/risk_score/engine/schedule_now: post: operationId: ScheduleRiskEngineNow @@ -351,11 +532,35 @@ components: $ref: '#/components/schemas/AssetCriticalityLevel' required: - criticality_level + EngineDescriptor: + type: object + properties: + filter: + type: string + indexPattern: + $ref: '#/components/schemas/IndexPattern' + status: + $ref: '#/components/schemas/EngineStatus' + type: + $ref: '#/components/schemas/EntityType' + EngineStatus: + enum: + - installing + - started + - stopped + type: string + EntityType: + enum: + - user + - host + type: string IdField: enum: - host.name - user.name type: string + IndexPattern: + type: string RiskEngineScheduleNowErrorResponse: type: object properties: diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index 845b4ced91545..754c8f94d1c63 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -256,6 +256,187 @@ paths: summary: List Asset Criticality Records tags: - Security Solution Entity Analytics API + /api/entity_store/engines: + get: + operationId: ListEntityStoreEngines + responses: + '200': + content: + application/json: + schema: + type: object + properties: + count: + type: integer + engines: + items: + $ref: '#/components/schemas/EngineDescriptor' + type: array + description: Successful response + summary: List the Entity Store engines + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}': + delete: + operationId: DeleteEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + - description: Control flag to also delete the entity data. + in: query + name: data + required: false + schema: + type: boolean + responses: + '200': + content: + application/json: + schema: + type: object + properties: + deleted: + type: boolean + description: Successful response + summary: Delete the Entity Store engine + tags: + - Security Solution Entity Analytics API + get: + operationId: GetEntityStoreEngine + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EngineDescriptor' + description: Successful response + summary: Get the Entity Store engine + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/init': + post: + operationId: InitEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + requestBody: + content: + application/json: + schema: + type: object + properties: + filter: + type: string + indexPattern: + $ref: '#/components/schemas/IndexPattern' + description: Schema for the engine initialization + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EngineDescriptor' + description: Successful response + summary: Initialize the Entity Store + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/start': + post: + operationId: StartEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + started: + type: boolean + description: Successful response + summary: Start the Entity Store engine + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/stats': + post: + operationId: GetEntityStoreStats + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + indexPattern: + $ref: '#/components/schemas/IndexPattern' + indices: + items: + type: object + type: array + status: + $ref: '#/components/schemas/EngineStatus' + transforms: + items: + type: object + type: array + type: + $ref: '#/components/schemas/EntityType' + description: Successful response + summary: Get the Entity Store engine stats + tags: + - Security Solution Entity Analytics API + '/api/entity_store/engines/{entityType}/stop': + post: + operationId: StopEntityStore + parameters: + - description: The entity type of the store (either 'user' or 'host'). + in: path + name: entityType + required: true + schema: + $ref: '#/components/schemas/EntityType' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + stopped: + type: boolean + description: Successful response + summary: Stop the Entity Store engine + tags: + - Security Solution Entity Analytics API /api/risk_score/engine/schedule_now: post: operationId: ScheduleRiskEngineNow @@ -351,11 +532,35 @@ components: $ref: '#/components/schemas/AssetCriticalityLevel' required: - criticality_level + EngineDescriptor: + type: object + properties: + filter: + type: string + indexPattern: + $ref: '#/components/schemas/IndexPattern' + status: + $ref: '#/components/schemas/EngineStatus' + type: + $ref: '#/components/schemas/EntityType' + EngineStatus: + enum: + - installing + - started + - stopped + type: string + EntityType: + enum: + - user + - host + type: string IdField: enum: - host.name - user.name type: string + IndexPattern: + type: string RiskEngineScheduleNowErrorResponse: type: object properties: diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index f682ca478a17f..e5840a6662e79 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -53,7 +53,8 @@ "notifications", "savedSearch", "unifiedDocViewer", - "charts" + "charts", + "entityManager" ], "optionalPlugins": [ "cloudExperiments", @@ -87,4 +88,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index b39cba0cf4952..a5e0c8c60b1fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -36,6 +36,7 @@ import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint import type { EndpointAuthz } from '../../../../../common/endpoint/types/authz'; import { riskEngineDataClientMock } from '../../../entity_analytics/risk_engine/risk_engine_data_client.mock'; import { riskScoreDataClientMock } from '../../../entity_analytics/risk_score/risk_score_data_client.mock'; +import { entityStoreDataClientMock } from '../../../entity_analytics/entity_store/entity_store_data_client.mock'; import { assetCriticalityDataClientMock } from '../../../entity_analytics/asset_criticality/asset_criticality_data_client.mock'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { detectionRulesClientMock } from '../../rule_management/logic/detection_rules_client/__mocks__/detection_rules_client'; @@ -72,6 +73,7 @@ export const createMockClients = () => { riskEngineDataClient: riskEngineDataClientMock.create(), riskScoreDataClient: riskScoreDataClientMock.create(), assetCriticalityDataClient: assetCriticalityDataClientMock.create(), + entityStoreDataClient: entityStoreDataClientMock.create(), internalFleetServices: { packages: packageServiceMock.createClient(), @@ -159,6 +161,7 @@ const createSecuritySolutionRequestContextMock = ( getRiskScoreDataClient: jest.fn(() => clients.riskScoreDataClient), getAssetCriticalityDataClient: jest.fn(() => clients.assetCriticalityDataClient), getAuditLogger: jest.fn(() => mockAuditLogger), + getEntityStoreDataClient: jest.fn(() => clients.entityStoreDataClient), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts new file mode 100644 index 0000000000000..ce5a61fa7e6c9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EngineStatus } from '../../../../common/api/entity_analytics/entity_store/common.gen'; +import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; + +/** + * Default index pattern for entity store + * This is the same as the default index pattern for the SIEM app but might diverge in the future + */ +export const ENTITY_STORE_DEFAULT_SOURCE_INDICES = DEFAULT_INDEX_PATTERN; + +export const ENGINE_STATUS: Record, EngineStatus> = { + INSTALLING: 'installing', + STARTED: 'started', + STOPPED: 'stopped', +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts new file mode 100644 index 0000000000000..32859b9841e7f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.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 { entityDefinitionSchema, type EntityDefinition } from '@kbn/entities-schema'; +import { ENTITY_STORE_DEFAULT_SOURCE_INDICES } from './constants'; + +export const HOST_ENTITY_DEFINITION: EntityDefinition = entityDefinitionSchema.parse({ + id: 'ea_host_entity_store', + name: 'EA Host Store', + type: 'host', + indexPatterns: ENTITY_STORE_DEFAULT_SOURCE_INDICES, + identityFields: ['host.name'], + displayNameTemplate: '{{host.name}}', + metadata: [ + 'host.domain', + 'host.hostname', + 'host.id', + 'host.ip', + 'host.mac', + 'host.name', + 'host.type', + 'host.architecture', + ], + history: { + timestampField: '@timestamp', + interval: '1m', + }, + version: '1.0.0', +}); + +export const USER_ENTITY_DEFINITION: EntityDefinition = entityDefinitionSchema.parse({ + id: 'ea_user_entity_store', + name: 'EA User Store', + type: 'user', + indexPatterns: ENTITY_STORE_DEFAULT_SOURCE_INDICES, + identityFields: ['user.name'], + displayNameTemplate: '{{user.name}}', + metadata: [ + 'user.domain', + 'user.email', + 'user.full_name', + 'user.hash', + 'user.id', + 'user.name', + 'user.roles', + ], + history: { + timestampField: '@timestamp', + interval: '1m', + }, + version: '1.0.0', +}); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.mock.ts new file mode 100644 index 0000000000000..095565343e130 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.mock.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EntityStoreDataClient } from './entity_store_data_client'; + +const createEntityStoreDataClientMock = () => + ({ + init: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + get: jest.fn(), + list: jest.fn(), + delete: jest.fn(), + } as unknown as jest.Mocked); + +export const entityStoreDataClientMock = { create: createEntityStoreDataClientMock }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts new file mode 100644 index 0000000000000..cb4d59139a25f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger, ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; +import type { EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; + +import type { + InitEntityStoreRequestBody, + InitEntityStoreResponse, +} from '../../../../common/api/entity_analytics/entity_store/engine/init.gen'; +import type { + EngineDescriptor, + EntityType, +} from '../../../../common/api/entity_analytics/entity_store/common.gen'; +import { entityEngineDescriptorTypeName } from './saved_object'; +import { EngineDescriptorClient } from './saved_object/engine_descriptor'; +import { getEntityDefinition } from './utils/utils'; +import { ENGINE_STATUS } from './constants'; + +interface EntityStoreClientOpts { + logger: Logger; + esClient: ElasticsearchClient; + entityClient: EntityClient; + namespace: string; + soClient: SavedObjectsClientContract; +} + +export class EntityStoreDataClient { + private engineClient: EngineDescriptorClient; + constructor(private readonly options: EntityStoreClientOpts) { + this.engineClient = new EngineDescriptorClient(options.soClient); + } + + public async init( + entityType: EntityType, + { indexPattern = '', filter = '' }: InitEntityStoreRequestBody + ): Promise { + const definition = getEntityDefinition(entityType); + + this.options.logger.info(`Initializing entity store for ${entityType}`); + + const descriptor = await this.engineClient.init(entityType, definition, filter); + await this.options.entityClient.createEntityDefinition({ + definition: { + ...definition, + filter, + indexPatterns: indexPattern + ? [...definition.indexPatterns, ...indexPattern.split(',')] + : definition.indexPatterns, + }, + }); + const updated = await this.engineClient.update(definition.id, ENGINE_STATUS.STARTED); + + return { ...descriptor, ...updated }; + } + + public async start(entityType: EntityType) { + const definition = getEntityDefinition(entityType); + + const descriptor = await this.engineClient.get(entityType); + + if (descriptor.status !== ENGINE_STATUS.STOPPED) { + throw new Error( + `Cannot start Entity engine for ${entityType} when current status is: ${descriptor.status}` + ); + } + + this.options.logger.info(`Starting entity store for ${entityType}`); + await this.options.entityClient.startEntityDefinition(definition); + + return this.engineClient.update(definition.id, ENGINE_STATUS.STARTED); + } + + public async stop(entityType: EntityType) { + const definition = getEntityDefinition(entityType); + + const descriptor = await this.engineClient.get(entityType); + + if (descriptor.status !== ENGINE_STATUS.STARTED) { + throw new Error( + `Cannot stop Entity engine for ${entityType} when current status is: ${descriptor.status}` + ); + } + + this.options.logger.info(`Stopping entity store for ${entityType}`); + await this.options.entityClient.stopEntityDefinition(definition); + + return this.engineClient.update(definition.id, ENGINE_STATUS.STOPPED); + } + + public async get(entityType: EntityType) { + return this.engineClient.get(entityType); + } + + public async list() { + return this.options.soClient + .find({ + type: entityEngineDescriptorTypeName, + }) + .then(({ saved_objects: engines }) => ({ + engines: engines.map((engine) => engine.attributes), + count: engines.length, + })); + } + + public async delete(entityType: EntityType, deleteData: boolean) { + const { id } = getEntityDefinition(entityType); + + this.options.logger.info(`Deleting entity store for ${entityType}`); + + await this.options.entityClient.deleteEntityDefinition({ id, deleteData }); + await this.engineClient.delete(id); + + return { deleted: true }; + } +} diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts new file mode 100644 index 0000000000000..44352cfa47c57 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts @@ -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 type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { DeleteEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/delete.gen'; +import { + DeleteEntityStoreRequestQuery, + DeleteEntityStoreRequestParams, +} from '../../../../../common/api/entity_analytics/entity_store/engine/delete.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; + +export const deleteEntityEngineRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .delete({ + access: 'public', + path: '/api/entity_store/engines/{entityType}', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + query: buildRouteValidationWithZod(DeleteEntityStoreRequestQuery), + params: buildRouteValidationWithZod(DeleteEntityStoreRequestParams), + }, + }, + }, + + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + const body = await secSol + .getEntityStoreDataClient() + .delete(request.params.entityType, !!request.query.data); + + return response.ok({ body }); + } catch (e) { + logger.error('Error in DeleteEntityStore:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts new file mode 100644 index 0000000000000..79a74303c49c2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { GetEntityStoreEngineResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/get.gen'; +import { GetEntityStoreEngineRequestParams } from '../../../../../common/api/entity_analytics/entity_store/engine/get.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; + +export const getEntityEngineRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .get({ + access: 'public', + path: '/api/entity_store/engines/{entityType}', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + params: buildRouteValidationWithZod(GetEntityStoreEngineRequestParams), + }, + }, + }, + + async ( + context, + request, + response + ): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + const body = await secSol.getEntityStoreDataClient().get(request.params.entityType); + + return response.ok({ body }); + } catch (e) { + logger.error('Error in GetEntityStoreEngine:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/index.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/index.ts new file mode 100644 index 0000000000000..52aa6b22c2df8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { registerEntityStoreRoutes } from './register_entity_store_routes'; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts new file mode 100644 index 0000000000000..6159cd584b06d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { InitEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/init.gen'; +import { + InitEntityStoreRequestBody, + InitEntityStoreRequestParams, +} from '../../../../../common/api/entity_analytics/entity_store/engine/init.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; + +export const initEntityEngineRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .post({ + access: 'public', + path: '/api/entity_store/engines/{entityType}/init', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + params: buildRouteValidationWithZod(InitEntityStoreRequestParams), + body: buildRouteValidationWithZod(InitEntityStoreRequestBody), + }, + }, + }, + + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + + const body: InitEntityStoreResponse = await secSol + .getEntityStoreDataClient() + .init(request.params.entityType, request.body); + + return response.ok({ body }); + } catch (e) { + logger.error('Error in InitEntityStore:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts new file mode 100644 index 0000000000000..53d9a8521ce00 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import type { ListEntityStoreEnginesResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/list.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; + +import type { EntityAnalyticsRoutesDeps } from '../../types'; + +export const listEntityEnginesRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .get({ + access: 'public', + path: '/api/entity_store/engines', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: {}, + }, + + async ( + context, + request, + response + ): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + const body = await secSol.getEntityStoreDataClient().list(); + + return response.ok({ body }); + } catch (e) { + logger.error('Error in ListEntityStoreEngines:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts new file mode 100644 index 0000000000000..b78316b02c91e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { deleteEntityEngineRoute } from './delete'; +import { getEntityEngineRoute } from './get'; +import { initEntityEngineRoute } from './init'; +import { listEntityEnginesRoute } from './list'; +import { startEntityEngineRoute } from './start'; +import { stopEntityEngineRoute } from './stop'; + +export const registerEntityStoreRoutes = ({ router, logger }: EntityAnalyticsRoutesDeps) => { + initEntityEngineRoute(router, logger); + startEntityEngineRoute(router, logger); + stopEntityEngineRoute(router, logger); + deleteEntityEngineRoute(router, logger); + getEntityEngineRoute(router, logger); + listEntityEnginesRoute(router, logger); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts new file mode 100644 index 0000000000000..6ec6674a5473d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { StartEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/start.gen'; +import { StartEntityStoreRequestParams } from '../../../../../common/api/entity_analytics/entity_store/engine/start.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { ENGINE_STATUS } from '../constants'; + +export const startEntityEngineRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .post({ + access: 'public', + path: '/api/entity_store/engines/{entityType}/start', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + params: buildRouteValidationWithZod(StartEntityStoreRequestParams), + }, + }, + }, + + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + const engine = await secSol.getEntityStoreDataClient().start(request.params.entityType); + + return response.ok({ body: { started: engine.status === ENGINE_STATUS.STARTED } }); + } catch (e) { + logger.error('Error in StartEntityStore:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts new file mode 100644 index 0000000000000..1d7534c17f747 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { GetEntityStoreStatsResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/stats.gen'; +import { GetEntityStoreStatsRequestParams } from '../../../../../common/api/entity_analytics/entity_store/engine/stats.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; + +export const getEntityEngineStatsRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .post({ + access: 'public', + path: '/api/entity_store/engines/{entityType}/stats', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + params: buildRouteValidationWithZod(GetEntityStoreStatsRequestParams), + }, + }, + }, + + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + // TODO + throw new Error('Not implemented'); + + // return response.ok({ body }); + } catch (e) { + logger.error('Error in GetEntityStoreStats:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts new file mode 100644 index 0000000000000..e1ddb464d1204 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; + +import type { StopEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/stop.gen'; +import { StopEntityStoreRequestParams } from '../../../../../common/api/entity_analytics/entity_store/engine/stop.gen'; +import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { ENGINE_STATUS } from '../constants'; + +export const stopEntityEngineRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger +) => { + router.versioned + .post({ + access: 'public', + path: '/api/entity_store/engines/{entityType}/stop', + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + params: buildRouteValidationWithZod(StopEntityStoreRequestParams), + }, + }, + }, + + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + const engine = await secSol.getEntityStoreDataClient().stop(request.params.entityType); + + return response.ok({ body: { stopped: engine.status === ENGINE_STATUS.STOPPED } }); + } catch (e) { + logger.error('Error in StopEntityStore:', e); + const error = transformError(e); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts new file mode 100644 index 0000000000000..9d6a7821a2a9b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from '@kbn/core-saved-objects-api-server'; +import type { EntityDefinition } from '@kbn/entities-schema'; +import type { + EngineDescriptor, + EngineStatus, + EntityType, +} from '../../../../../common/api/entity_analytics/entity_store/common.gen'; + +import { entityEngineDescriptorTypeName } from './engine_descriptor_type'; +import { getByEntityTypeQuery, getEntityDefinition } from '../utils/utils'; +import { ENGINE_STATUS } from '../constants'; + +export class EngineDescriptorClient { + constructor(private readonly soClient: SavedObjectsClientContract) {} + + async init(entityType: EntityType, definition: EntityDefinition, filter: string) { + const engineDescriptor = await this.find(entityType); + + if (engineDescriptor.total > 0) + throw new Error(`Entity engine for ${entityType} already exists`); + + const { attributes } = await this.soClient.create( + entityEngineDescriptorTypeName, + { + status: ENGINE_STATUS.INSTALLING, + type: entityType, + indexPattern: definition.indexPatterns.join(','), + filter, + }, + { id: definition.id } + ); + return attributes; + } + + async update(id: string, status: EngineStatus) { + const { attributes } = await this.soClient.update( + entityEngineDescriptorTypeName, + id, + { status }, + { refresh: 'wait_for' } + ); + return attributes; + } + + async find(entityType: EntityType): Promise> { + return this.soClient.find({ + type: entityEngineDescriptorTypeName, + filter: getByEntityTypeQuery(entityType), + }); + } + + async get(entityType: EntityType): Promise { + const { id } = getEntityDefinition(entityType); + + const { attributes } = await this.soClient.get( + entityEngineDescriptorTypeName, + id + ); + + return attributes; + } + + async delete(id: string) { + return this.soClient.delete(entityEngineDescriptorTypeName, id); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor_type.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor_type.ts new file mode 100644 index 0000000000000..8513dfc018623 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor_type.ts @@ -0,0 +1,36 @@ +/* + * 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 { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import type { SavedObjectsType } from '@kbn/core/server'; + +export const entityEngineDescriptorTypeName = 'entity-engine-status'; + +export const entityEngineDescriptorTypeMappings: SavedObjectsType['mappings'] = { + dynamic: false, + properties: { + indexPattern: { + type: 'keyword', + }, + filter: { + type: 'keyword', + }, + type: { + type: 'keyword', // EntityType: user | host + }, + status: { + type: 'keyword', // EngineStatus: installing | started | stopped + }, + }, +}; +export const entityEngineDescriptorType: SavedObjectsType = { + name: entityEngineDescriptorTypeName, + indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, + hidden: false, + namespaceType: 'multiple-isolated', + mappings: entityEngineDescriptorTypeMappings, +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/index.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/index.ts new file mode 100644 index 0000000000000..d86800da1b5be --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './engine_descriptor_type'; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/utils.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/utils.ts new file mode 100644 index 0000000000000..864fdb2367eb5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/utils.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsFindResponse } from '@kbn/core-saved-objects-api-server'; +import type { + EngineDescriptor, + EntityType, +} from '../../../../../common/api/entity_analytics/entity_store/common.gen'; +import { HOST_ENTITY_DEFINITION, USER_ENTITY_DEFINITION } from '../definition'; +import { entityEngineDescriptorTypeName } from '../saved_object'; + +export const getEntityDefinition = (entityType: EntityType) => { + if (entityType === 'host') return HOST_ENTITY_DEFINITION; + if (entityType === 'user') return USER_ENTITY_DEFINITION; + + throw new Error(`Unsupported entity type: ${entityType}`); +}; + +export const ensureEngineExists = + (entityType: EntityType) => (results: SavedObjectsFindResponse) => { + if (results.total === 0) { + throw new Error(`Entity engine for ${entityType} does not exist`); + } + return results.saved_objects[0].attributes; + }; + +export const getByEntityTypeQuery = (entityType: EntityType) => { + return `${entityEngineDescriptorTypeName}.attributes.type: ${entityType}`; +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts index 31a7ccbb6f30c..b4eb0d36e21fb 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts @@ -9,9 +9,13 @@ import { registerAssetCriticalityRoutes } from './asset_criticality/routes'; import { registerRiskScoreRoutes } from './risk_score/routes'; import { registerRiskEngineRoutes } from './risk_engine/routes'; import type { EntityAnalyticsRoutesDeps } from './types'; +import { registerEntityStoreRoutes } from './entity_store/routes'; export const registerEntityAnalyticsRoutes = (routeDeps: EntityAnalyticsRoutesDeps) => { registerAssetCriticalityRoutes(routeDeps); registerRiskScoreRoutes(routeDeps); registerRiskEngineRoutes(routeDeps); + if (routeDeps.config.experimentalFeatures.entityStoreEnabled) { + registerEntityStoreRoutes(routeDeps); + } }; diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index 4bda7e0338aa8..6316ed3622841 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -10,6 +10,7 @@ import { memoize } from 'lodash'; import type { Logger, KibanaRequest, RequestHandlerContext } from '@kbn/core/server'; import type { BuildFlavor } from '@kbn/config'; +import { EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; import { DEFAULT_SPACE_ID } from '../common/constants'; import { AppClientFactory } from './client'; import type { ConfigType } from './config'; @@ -31,6 +32,7 @@ import { RiskScoreDataClient } from './lib/entity_analytics/risk_score/risk_scor import { AssetCriticalityDataClient } from './lib/entity_analytics/asset_criticality'; import { createDetectionRulesClient } from './lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client'; import { buildMlAuthz } from './lib/machine_learning/authz'; +import { EntityStoreDataClient } from './lib/entity_analytics/entity_store/entity_store_data_client'; export interface IRequestContextFactory { create( @@ -190,6 +192,22 @@ export class RequestContextFactory implements IRequestContextFactory { auditLogger: getAuditLogger(), }) ), + getEntityStoreDataClient: memoize(() => { + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const logger = options.logger; + const soClient = coreContext.savedObjects.client; + return new EntityStoreDataClient({ + namespace: getSpaceId(), + esClient, + logger, + soClient, + entityClient: new EntityClient({ + esClient, + soClient, + logger, + }), + }); + }), }; } } diff --git a/x-pack/plugins/security_solution/server/saved_objects.ts b/x-pack/plugins/security_solution/server/saved_objects.ts index 3659b15a04714..9412e62e6315c 100644 --- a/x-pack/plugins/security_solution/server/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/saved_objects.ts @@ -15,6 +15,7 @@ import { prebuiltRuleAssetType } from './lib/detection_engine/prebuilt_rules'; import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects'; import { manifestType, unifiedManifestType } from './endpoint/lib/artifacts/saved_object_mappings'; import { riskEngineConfigurationType } from './lib/entity_analytics/risk_engine/saved_object'; +import { entityEngineDescriptorType } from './lib/entity_analytics/entity_store/saved_object'; const types = [ noteType, @@ -26,6 +27,7 @@ const types = [ unifiedManifestType, signalsMigrationType, riskEngineConfigurationType, + entityEngineDescriptorType, protectionUpdatesNoteType, ]; diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 121eb7b1758f4..31e10b70adbcf 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -34,6 +34,7 @@ import type { RiskEngineDataClient } from './lib/entity_analytics/risk_engine/ri import type { RiskScoreDataClient } from './lib/entity_analytics/risk_score/risk_score_data_client'; import type { AssetCriticalityDataClient } from './lib/entity_analytics/asset_criticality'; import type { IDetectionRulesClient } from './lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface'; +import type { EntityStoreDataClient } from './lib/entity_analytics/entity_store/entity_store_data_client'; export { AppClient }; export interface SecuritySolutionApiRequestHandlerContext { @@ -55,6 +56,7 @@ export interface SecuritySolutionApiRequestHandlerContext { getRiskEngineDataClient: () => RiskEngineDataClient; getRiskScoreDataClient: () => RiskScoreDataClient; getAssetCriticalityDataClient: () => AssetCriticalityDataClient; + getEntityStoreDataClient: () => EntityStoreDataClient; } export type SecuritySolutionRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 6ccd61fd34394..8264a50988956 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -223,5 +223,7 @@ "@kbn/cloud-security-posture", "@kbn/security-solution-distribution-bar", "@kbn/cloud-security-posture-common", + "@kbn/entityManager-plugin", + "@kbn/entities-schema", ] } diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index cf9722e89b408..6a3d0cf8f3dce 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -37,6 +37,10 @@ import { CreateUpdateProtectionUpdatesNoteRequestBodyInput, } from '@kbn/security-solution-plugin/common/api/endpoint/protection_updates_note/protection_updates_note.gen'; import { DeleteAssetCriticalityRecordRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/asset_criticality/delete_asset_criticality.gen'; +import { + DeleteEntityStoreRequestQueryInput, + DeleteEntityStoreRequestParamsInput, +} from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/delete.gen'; import { DeleteNoteRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/delete_note/delete_note_route.gen'; import { DeleteRuleRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/delete_rule/delete_rule_route.gen'; import { DeleteTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/delete_timelines/delete_timelines_route.gen'; @@ -76,6 +80,8 @@ import { GetEndpointSuggestionsRequestParamsInput, GetEndpointSuggestionsRequestBodyInput, } from '@kbn/security-solution-plugin/common/api/endpoint/suggestions/get_suggestions.gen'; +import { GetEntityStoreEngineRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/get.gen'; +import { GetEntityStoreStatsRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/stats.gen'; import { GetNotesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/timeline/get_notes/get_notes_route.gen'; import { GetPolicyResponseRequestQueryInput } from '@kbn/security-solution-plugin/common/api/endpoint/policy/policy_response.gen'; import { GetProtectionUpdatesNoteRequestParamsInput } from '@kbn/security-solution-plugin/common/api/endpoint/protection_updates_note/protection_updates_note.gen'; @@ -91,6 +97,10 @@ import { GetTimelineRequestQueryInput } from '@kbn/security-solution-plugin/comm import { GetTimelinesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/timeline/get_timelines/get_timelines_route.gen'; import { ImportRulesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/import_rules/import_rules_route.gen'; import { ImportTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/import_timelines/import_timelines_route.gen'; +import { + InitEntityStoreRequestParamsInput, + InitEntityStoreRequestBodyInput, +} from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/init.gen'; import { InstallPrepackedTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route.gen'; import { PatchRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/patch_rule/patch_rule_route.gen'; import { PatchTimelineRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/patch_timelines/patch_timeline_route.gen'; @@ -110,6 +120,8 @@ import { SearchAlertsRequestBodyInput } from '@kbn/security-solution-plugin/comm import { SetAlertAssigneesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/alert_assignees/set_alert_assignees_route.gen'; import { SetAlertsStatusRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals/set_signal_status/set_signals_status_route.gen'; import { SetAlertTagsRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/alert_tags/set_alert_tags/set_alert_tags.gen'; +import { StartEntityStoreRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/start.gen'; +import { StopEntityStoreRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/stop.gen'; import { SuggestUserProfilesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/users/suggest_user_profiles_route.gen'; import { TriggerRiskScoreCalculationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/risk_engine/entity_calculation_route.gen'; import { UpdateRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/update_rule/update_rule_route.gen'; @@ -313,6 +325,14 @@ Migrations are initiated per index. While the process is neither destructive nor .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .query(props.query); }, + deleteEntityStore(props: DeleteEntityStoreProps) { + return supertest + .delete(replaceParams('/api/entity_store/engines/{entityType}', props.params)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .query(props.query); + }, deleteNote(props: DeleteNoteProps) { return supertest .delete('/api/note') @@ -668,6 +688,20 @@ finalize it. .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + getEntityStoreEngine(props: GetEntityStoreEngineProps) { + return supertest + .get(replaceParams('/api/entity_store/engines/{entityType}', props.params)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, + getEntityStoreStats(props: GetEntityStoreStatsProps) { + return supertest + .post(replaceParams('/api/entity_store/engines/{entityType}/stats', props.params)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Gets notes */ @@ -764,6 +798,14 @@ finalize it. .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + initEntityStore(props: InitEntityStoreProps) { + return supertest + .post(replaceParams('/api/entity_store/engines/{entityType}/init', props.params)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send(props.body as object); + }, /** * Initializes the Risk Engine by creating the necessary indices and mappings, removing old transforms, and starting the new risk engine */ @@ -799,6 +841,13 @@ finalize it. .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + listEntityStoreEngines() { + return supertest + .get('/api/entity_store/engines') + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Update specific fields of an existing detection rule using the `rule_id` or `id` field. */ @@ -1018,6 +1067,20 @@ detection engine rules. .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + startEntityStore(props: StartEntityStoreProps) { + return supertest + .post(replaceParams('/api/entity_store/engines/{entityType}/start', props.params)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, + stopEntityStore(props: StopEntityStoreProps) { + return supertest + .post(replaceParams('/api/entity_store/engines/{entityType}/stop', props.params)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Suggests user profiles. */ @@ -1107,6 +1170,10 @@ export interface CreateUpdateProtectionUpdatesNoteProps { export interface DeleteAssetCriticalityRecordProps { query: DeleteAssetCriticalityRecordRequestQueryInput; } +export interface DeleteEntityStoreProps { + query: DeleteEntityStoreRequestQueryInput; + params: DeleteEntityStoreRequestParamsInput; +} export interface DeleteNoteProps { body: DeleteNoteRequestBodyInput; } @@ -1200,6 +1267,12 @@ export interface GetEndpointSuggestionsProps { params: GetEndpointSuggestionsRequestParamsInput; body: GetEndpointSuggestionsRequestBodyInput; } +export interface GetEntityStoreEngineProps { + params: GetEntityStoreEngineRequestParamsInput; +} +export interface GetEntityStoreStatsProps { + params: GetEntityStoreStatsRequestParamsInput; +} export interface GetNotesProps { query: GetNotesRequestQueryInput; } @@ -1229,6 +1302,10 @@ export interface ImportRulesProps { export interface ImportTimelinesProps { body: ImportTimelinesRequestBodyInput; } +export interface InitEntityStoreProps { + params: InitEntityStoreRequestParamsInput; + body: InitEntityStoreRequestBodyInput; +} export interface InstallPrepackedTimelinesProps { body: InstallPrepackedTimelinesRequestBodyInput; } @@ -1278,6 +1355,12 @@ export interface SetAlertsStatusProps { export interface SetAlertTagsProps { body: SetAlertTagsRequestBodyInput; } +export interface StartEntityStoreProps { + params: StartEntityStoreRequestParamsInput; +} +export interface StopEntityStoreProps { + params: StopEntityStoreRequestParamsInput; +} export interface SuggestUserProfilesProps { query: SuggestUserProfilesRequestQueryInput; } diff --git a/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts index 07dbcf7ded031..5cf491188ba96 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts @@ -349,6 +349,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:risk-engine-configuration/delete", "saved_object:risk-engine-configuration/bulk_delete", "saved_object:risk-engine-configuration/share_to_space", + "saved_object:entity-engine-status/bulk_get", + "saved_object:entity-engine-status/get", + "saved_object:entity-engine-status/find", + "saved_object:entity-engine-status/open_point_in_time", + "saved_object:entity-engine-status/close_point_in_time", + "saved_object:entity-engine-status/create", + "saved_object:entity-engine-status/bulk_create", + "saved_object:entity-engine-status/update", + "saved_object:entity-engine-status/bulk_update", + "saved_object:entity-engine-status/delete", + "saved_object:entity-engine-status/bulk_delete", + "saved_object:entity-engine-status/share_to_space", "saved_object:policy-settings-protection-updates-note/bulk_get", "saved_object:policy-settings-protection-updates-note/get", "saved_object:policy-settings-protection-updates-note/find", @@ -1182,6 +1194,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:risk-engine-configuration/delete", "saved_object:risk-engine-configuration/bulk_delete", "saved_object:risk-engine-configuration/share_to_space", + "saved_object:entity-engine-status/bulk_get", + "saved_object:entity-engine-status/get", + "saved_object:entity-engine-status/find", + "saved_object:entity-engine-status/open_point_in_time", + "saved_object:entity-engine-status/close_point_in_time", + "saved_object:entity-engine-status/create", + "saved_object:entity-engine-status/bulk_create", + "saved_object:entity-engine-status/update", + "saved_object:entity-engine-status/bulk_update", + "saved_object:entity-engine-status/delete", + "saved_object:entity-engine-status/bulk_delete", + "saved_object:entity-engine-status/share_to_space", "saved_object:policy-settings-protection-updates-note/bulk_get", "saved_object:policy-settings-protection-updates-note/get", "saved_object:policy-settings-protection-updates-note/find", @@ -1779,6 +1803,11 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:risk-engine-configuration/find", "saved_object:risk-engine-configuration/open_point_in_time", "saved_object:risk-engine-configuration/close_point_in_time", + "saved_object:entity-engine-status/bulk_get", + "saved_object:entity-engine-status/get", + "saved_object:entity-engine-status/find", + "saved_object:entity-engine-status/open_point_in_time", + "saved_object:entity-engine-status/close_point_in_time", "saved_object:policy-settings-protection-updates-note/bulk_get", "saved_object:policy-settings-protection-updates-note/get", "saved_object:policy-settings-protection-updates-note/find", @@ -2135,6 +2164,11 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:risk-engine-configuration/find", "saved_object:risk-engine-configuration/open_point_in_time", "saved_object:risk-engine-configuration/close_point_in_time", + "saved_object:entity-engine-status/bulk_get", + "saved_object:entity-engine-status/get", + "saved_object:entity-engine-status/find", + "saved_object:entity-engine-status/open_point_in_time", + "saved_object:entity-engine-status/close_point_in_time", "saved_object:policy-settings-protection-updates-note/bulk_get", "saved_object:policy-settings-protection-updates-note/get", "saved_object:policy-settings-protection-updates-note/find", From 6755cc180860653c9ec6f3891231a8f69f929c69 Mon Sep 17 00:00:00 2001 From: Stef Nestor <26751266+stefnestor@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:38:46 -0600 Subject: [PATCH 08/16] (Doc+) link video to checking health (#193023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary 👋 howdy team! Ongoing improvement for common support topic, this links [this video walkthrough](https://www.youtube.com/watch?v=AlgGYcpGvOA&list=PL_mJOmq4zsHbQlfEMEh_30_LuV_hZp-3d&index=3) on checking Kibana health. ### Checklist NA ### Risk Matrix NA ### For maintainers NA --------- Co-authored-by: florent-leborgne --- docs/setup/access.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/access.asciidoc b/docs/setup/access.asciidoc index a0bd1207a6a35..3b6457b42f04d 100644 --- a/docs/setup/access.asciidoc +++ b/docs/setup/access.asciidoc @@ -65,4 +65,4 @@ For example: * When {kib} is unable to connect to a healthy {es} cluster, errors like `master_not_discovered_exception` or `unable to revive connection` or `license is not available` errors appear. * When one or more {kib}-backing indices are unhealthy, the `index_not_green_timeout` error appears. -For more information, refer to our https://www.elastic.co/blog/troubleshooting-kibana-health[walkthrough on troubleshooting Kibana Health]. +You can find a Kibana health troubleshooting walkthrough in https://www.elastic.co/blog/troubleshooting-kibana-health[this blog] or in link:https://www.youtube.com/watch?v=AlgGYcpGvOA[this video]. From 4d488818dc5503c40617d01512cd89c05e1e0345 Mon Sep 17 00:00:00 2001 From: Joe McElroy Date: Mon, 16 Sep 2024 17:03:01 +0100 Subject: [PATCH 09/16] [Onboarding] Connection details + Quick Stats (#192636) ## Summary Adding in the connection details and quickstats for the search_details page. ![Screenshot 2024-09-11 at 20 36 31](https://github.com/user-attachments/assets/5f030c06-4a98-4d9d-a465-c6719998ca56) ![Screenshot 2024-09-11 at 20 36 27](https://github.com/user-attachments/assets/d96be2f1-bcaa-42e5-9d32-1612e090b916) ![Screenshot 2024-09-11 at 20 36 09](https://github.com/user-attachments/assets/1f7995ae-5a0d-4810-acfb-3fafe33be451) ### Checklist Delete any items that are not applicable to this PR. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Liam Thompson <32779855+leemthompo@users.noreply.github.com> --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../search_indices/common/doc_links.ts | 2 + .../connection_details/connection_details.tsx | 73 ++++++++ .../components/indices/details_page.tsx | 32 +++- .../quick_stats/mappings_convertor.test.ts | 41 +++++ .../quick_stats/mappings_convertor.ts | 62 +++++++ .../components/quick_stats/quick_stat.tsx | 116 ++++++++++++ .../components/quick_stats/quick_stats.tsx | 167 ++++++++++++++++++ .../components/start/create_index_code.tsx | 10 +- .../public/hooks/api/use_index_mappings.ts | 22 +++ .../public/hooks/use_elasticsearch_url.ts | 18 ++ x-pack/plugins/search_indices/public/types.ts | 8 + .../svl_search_index_detail_page.ts | 34 ++++ .../test_suites/search/search_index_detail.ts | 25 +++ 15 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx create mode 100644 x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.test.ts create mode 100644 x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.ts create mode 100644 x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx create mode 100644 x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx create mode 100644 x-pack/plugins/search_indices/public/hooks/api/use_index_mappings.ts create mode 100644 x-pack/plugins/search_indices/public/hooks/use_elasticsearch_url.ts diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index d2ac9c6678797..3ad5d271bde47 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -219,6 +219,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D searchApplicationsSearch: `${ELASTICSEARCH_DOCS}search-application-client.html`, searchLabs: `${SEARCH_LABS_URL}`, searchLabsRepo: `${SEARCH_LABS_REPO}`, + semanticSearch: `${ELASTICSEARCH_DOCS}semantic-search.html`, searchTemplates: `${ELASTICSEARCH_DOCS}search-template.html`, semanticTextField: `${ELASTICSEARCH_DOCS}semantic-text.html`, start: `${ENTERPRISE_SEARCH_DOCS}start.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index ae6e56a9ac385..cbf085623c3a6 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -183,6 +183,7 @@ export interface DocLinks { readonly searchApplicationsSearch: string; readonly searchLabs: string; readonly searchLabsRepo: string; + readonly semanticSearch: string; readonly searchTemplates: string; readonly semanticTextField: string; readonly start: string; diff --git a/x-pack/plugins/search_indices/common/doc_links.ts b/x-pack/plugins/search_indices/common/doc_links.ts index dbffa8f9f0f33..8cceb45041ab9 100644 --- a/x-pack/plugins/search_indices/common/doc_links.ts +++ b/x-pack/plugins/search_indices/common/doc_links.ts @@ -9,11 +9,13 @@ import { DocLinks } from '@kbn/doc-links'; class SearchIndicesDocLinks { public apiReference: string = ''; + public setupSemanticSearch: string = ''; constructor() {} setDocLinks(newDocLinks: DocLinks) { this.apiReference = newDocLinks.apiReference; + this.setupSemanticSearch = newDocLinks.enterpriseSearch.semanticSearch; } } export const docLinks = new SearchIndicesDocLinks(); diff --git a/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx b/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx new file mode 100644 index 0000000000000..d7ce8f308b683 --- /dev/null +++ b/x-pack/plugins/search_indices/public/components/connection_details/connection_details.tsx @@ -0,0 +1,73 @@ +/* + * 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 { + EuiButtonIcon, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { useElasticsearchUrl } from '../../hooks/use_elasticsearch_url'; + +export const ConnectionDetails: React.FC = () => { + const { euiTheme } = useEuiTheme(); + const elasticsearchUrl = useElasticsearchUrl(); + + return ( + + + +

+ +

+
+
+ +

+ {elasticsearchUrl} +

+
+ + + {(copy) => ( + + )} + + +
+ ); +}; diff --git a/x-pack/plugins/search_indices/public/components/indices/details_page.tsx b/x-pack/plugins/search_indices/public/components/indices/details_page.tsx index afa798814d864..85021e79edbf2 100644 --- a/x-pack/plugins/search_indices/public/components/indices/details_page.tsx +++ b/x-pack/plugins/search_indices/public/components/indices/details_page.tsx @@ -27,13 +27,22 @@ import { i18n } from '@kbn/i18n'; import { SectionLoading } from '@kbn/es-ui-shared-plugin/public'; import { useIndex } from '../../hooks/api/use_index'; import { useKibana } from '../../hooks/use_kibana'; +import { ConnectionDetails } from '../connection_details/connection_details'; +import { QuickStats } from '../quick_stats/quick_stats'; +import { useIndexMapping } from '../../hooks/api/use_index_mappings'; import { DeleteIndexModal } from './delete_index_modal'; import { IndexloadingError } from './details_page_loading_error'; export const SearchIndexDetailsPage = () => { const indexName = decodeURIComponent(useParams<{ indexName: string }>().indexName); const { console: consolePlugin, docLinks, application } = useKibana().services; - const { data: index, refetch, isSuccess, isInitialLoading } = useIndex(indexName); + + const { data: index, refetch, isError: isIndexError, isInitialLoading } = useIndex(indexName); + const { + data: mappings, + isError: isMappingsError, + isInitialLoading: isMappingsInitialLoading, + } = useIndexMapping(indexName); const embeddableConsole = useMemo( () => (consolePlugin?.EmbeddableConsole ? : null), @@ -87,7 +96,7 @@ export const SearchIndexDetailsPage = () => { /> ); - if (isInitialLoading) { + if (isInitialLoading || isMappingsInitialLoading) { return ( {i18n.translate('xpack.searchIndices.loadingDescription', { @@ -103,9 +112,10 @@ export const SearchIndexDetailsPage = () => { restrictWidth={false} data-test-subj="searchIndicesDetailsPage" grow={false} - bottomBorder={false} + panelled + bottomBorder > - {!isSuccess || !index ? ( + {isIndexError || isMappingsError || !index || !mappings ? ( { navigateToIndexListPage={navigateToIndexListPage} /> )} + + + + + + {/* TODO: API KEY */} + + + -
+ + + + )} {embeddableConsole} diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.test.ts b/x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.test.ts new file mode 100644 index 0000000000000..da182123ab4c1 --- /dev/null +++ b/x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { Mappings } from '../../types'; +import { countVectorBasedTypesFromMappings } from './mappings_convertor'; + +describe('mappings convertor', () => { + it('should count vector based types from mappings', () => { + const mappings = { + mappings: { + properties: { + field1: { + type: 'dense_vector', + }, + field2: { + type: 'dense_vector', + }, + field3: { + type: 'sparse_vector', + }, + field4: { + type: 'dense_vector', + }, + field5: { + type: 'semantic_text', + }, + }, + }, + }; + const result = countVectorBasedTypesFromMappings(mappings as unknown as Mappings); + expect(result).toEqual({ + dense_vector: 3, + sparse_vector: 1, + semantic_text: 1, + }); + }); +}); diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.ts b/x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.ts new file mode 100644 index 0000000000000..749fe05de1f54 --- /dev/null +++ b/x-pack/plugins/search_indices/public/components/quick_stats/mappings_convertor.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + MappingProperty, + MappingPropertyBase, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Mappings } from '../../types'; + +interface VectorFieldTypes { + semantic_text: number; + dense_vector: number; + sparse_vector: number; +} + +export function countVectorBasedTypesFromMappings(mappings: Mappings): VectorFieldTypes { + const typeCounts: VectorFieldTypes = { + semantic_text: 0, + dense_vector: 0, + sparse_vector: 0, + }; + + const typeCountKeys = Object.keys(typeCounts); + + function recursiveCount(fields: MappingProperty | Mappings | MappingPropertyBase['fields']) { + if (!fields) { + return; + } + if ('mappings' in fields) { + recursiveCount(fields.mappings); + } + if ('properties' in fields && fields.properties) { + Object.keys(fields.properties).forEach((key) => { + const value = (fields.properties as Record)?.[key]; + + if (value && value.type) { + if (typeCountKeys.includes(value.type)) { + const type = value.type as keyof VectorFieldTypes; + typeCounts[type] = typeCounts[type] + 1; + } + + if ('fields' in value) { + recursiveCount(value.fields); + } + + if ('properties' in value) { + recursiveCount(value.properties); + } + } else if (value.properties || value.fields) { + recursiveCount(value); + } + }); + } + } + + recursiveCount(mappings); + return typeCounts; +} diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx new file mode 100644 index 0000000000000..0d72835ad5779 --- /dev/null +++ b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx @@ -0,0 +1,116 @@ +/* + * 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 { + EuiAccordion, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiText, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; + +interface BaseQuickStatProps { + icon: string; + iconColor: string; + title: string; + secondaryTitle: React.ReactNode; + open: boolean; + content?: React.ReactNode; + stats: Array<{ + title: string; + description: NonNullable; + }>; + setOpen: (open: boolean) => void; + first?: boolean; +} + +export const QuickStat: React.FC = ({ + icon, + title, + stats, + open, + setOpen, + first, + secondaryTitle, + iconColor, + content, + ...rest +}) => { + const { euiTheme } = useEuiTheme(); + + const id = useGeneratedHtmlId({ + prefix: 'formAccordion', + suffix: title, + }); + + return ( + setOpen(!open)} + paddingSize="none" + id={id} + buttonElement="div" + arrowDisplay="right" + {...rest} + css={{ + borderLeft: euiTheme.border.thin, + ...(first ? { borderLeftWidth: 0 } : {}), + '.euiAccordion__arrow': { + marginRight: euiTheme.size.s, + }, + '.euiAccordion__triggerWrapper': { + background: euiTheme.colors.ghost, + }, + '.euiAccordion__children': { + borderTop: euiTheme.border.thin, + padding: euiTheme.size.m, + }, + }} + buttonContent={ + + + + + + + +

{title}

+
+
+ + {secondaryTitle} + +
+
+ } + > + {content ? ( + content + ) : ( + + + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx new file mode 100644 index 0000000000000..cece2b1d39910 --- /dev/null +++ b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx @@ -0,0 +1,167 @@ +/* + * 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, { useMemo, useState } from 'react'; +import type { Index } from '@kbn/index-management-shared-types'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiI18nNumber, + EuiPanel, + EuiText, + useEuiTheme, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Mappings } from '../../types'; +import { countVectorBasedTypesFromMappings } from './mappings_convertor'; +import { QuickStat } from './quick_stat'; +import { useKibana } from '../../hooks/use_kibana'; + +export interface QuickStatsProps { + index: Index; + mappings: Mappings; +} + +export const SetupAISearchButton: React.FC = () => { + const { + services: { docLinks }, + } = useKibana(); + return ( + + + + +
+ {i18n.translate('xpack.searchIndices.quickStats.setup_ai_search_description', { + defaultMessage: 'Build AI-powered search experiences with Elastic', + })} +
+
+
+ + + {i18n.translate('xpack.searchIndices.quickStats.setup_ai_search_button', { + defaultMessage: 'Set up now', + })} + + +
+
+ ); +}; + +export const QuickStats: React.FC = ({ index, mappings }) => { + const [open, setOpen] = useState(false); + const { euiTheme } = useEuiTheme(); + const mappingStats = useMemo(() => countVectorBasedTypesFromMappings(mappings), [mappings]); + const vectorFieldCount = + mappingStats.sparse_vector + mappingStats.dense_vector + mappingStats.semantic_text; + + return ( + ({ + border: euiTheme.border.thin, + background: euiTheme.colors.lightestShade, + overflow: 'hidden', + })} + > + + + } + stats={[ + { + title: i18n.translate('xpack.searchIndices.quickStats.documents.totalTitle', { + defaultMessage: 'Total', + }), + description: , + }, + { + title: i18n.translate('xpack.searchIndices.quickStats.documents.indexSize', { + defaultMessage: 'Index Size', + }), + description: index.size ?? '0b', + }, + ]} + first + /> + + + 0 + ? i18n.translate('xpack.searchIndices.quickStats.total_count', { + defaultMessage: '{value, plural, one {# Field} other {# Fields}}', + values: { + value: vectorFieldCount, + }, + }) + : i18n.translate('xpack.searchIndices.quickStats.no_vector_fields', { + defaultMessage: 'Not configured', + }) + } + content={vectorFieldCount === 0 && } + stats={[ + { + title: i18n.translate('xpack.searchIndices.quickStats.sparse_vector', { + defaultMessage: 'Sparse Vector', + }), + description: i18n.translate('xpack.searchIndices.quickStats.sparse_vector_count', { + defaultMessage: '{value, plural, one {# Field} other {# Fields}}', + values: { value: mappingStats.sparse_vector }, + }), + }, + { + title: i18n.translate('xpack.searchIndices.quickStats.dense_vector', { + defaultMessage: 'Dense Vector', + }), + description: i18n.translate('xpack.searchIndices.quickStats.dense_vector_count', { + defaultMessage: '{value, plural, one {# Field} other {# Fields}}', + values: { value: mappingStats.dense_vector }, + }), + }, + { + title: i18n.translate('xpack.searchIndices.quickStats.semantic_text', { + defaultMessage: 'Semantic Text', + }), + description: i18n.translate('xpack.searchIndices.quickStats.semantic_text_count', { + defaultMessage: '{value, plural, one {# Field} other {# Fields}}', + values: { value: mappingStats.semantic_text }, + }), + }, + ]} + /> + + + + ); +}; diff --git a/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx b/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx index b58bf6c0926f1..4901847eeed22 100644 --- a/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx +++ b/x-pack/plugins/search_indices/public/components/start/create_index_code.tsx @@ -12,12 +12,12 @@ import { TryInConsoleButton } from '@kbn/try-in-console'; import { useKibana } from '../../hooks/use_kibana'; import { CodeSample } from './code_sample'; import { CreateIndexFormState } from './types'; -import { ELASTICSEARCH_URL_PLACEHOLDER } from '../../constants'; import { Languages, AvailableLanguages, LanguageOptions } from '../../code_examples'; import { DenseVectorSeverlessCodeExamples } from '../../code_examples/create_index'; import { LanguageSelector } from '../shared/language_selector'; +import { useElasticsearchUrl } from '../../hooks/use_elasticsearch_url'; export interface CreateIndexCodeViewProps { createIndexForm: CreateIndexFormState; @@ -27,15 +27,17 @@ export interface CreateIndexCodeViewProps { const SelectedCodeExamples = DenseVectorSeverlessCodeExamples; export const CreateIndexCodeView = ({ createIndexForm }: CreateIndexCodeViewProps) => { - const { application, cloud, share, console: consolePlugin } = useKibana().services; + const { application, share, console: consolePlugin } = useKibana().services; // TODO: initing this should be dynamic and possibly saved in the form state const [selectedLanguage, setSelectedLanguage] = useState('python'); + const elasticsearchUrl = useElasticsearchUrl(); + const codeParams = useMemo(() => { return { indexName: createIndexForm.indexName || undefined, - elasticsearchURL: cloud?.elasticsearchUrl ?? ELASTICSEARCH_URL_PLACEHOLDER, + elasticsearchURL: elasticsearchUrl, }; - }, [createIndexForm.indexName, cloud]); + }, [createIndexForm.indexName, elasticsearchUrl]); const selectedCodeExample = useMemo(() => { return SelectedCodeExamples[selectedLanguage]; }, [selectedLanguage]); diff --git a/x-pack/plugins/search_indices/public/hooks/api/use_index_mappings.ts b/x-pack/plugins/search_indices/public/hooks/api/use_index_mappings.ts new file mode 100644 index 0000000000000..a91198f70b4e8 --- /dev/null +++ b/x-pack/plugins/search_indices/public/hooks/api/use_index_mappings.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 { useQuery } from '@tanstack/react-query'; +import { useKibana } from '../use_kibana'; +import { Mappings } from '../../types'; + +export const useIndexMapping = (indexName: string) => { + const { http } = useKibana().services; + const queryKey = ['fetchMapping', indexName]; + const result = useQuery({ + queryKey, + refetchOnWindowFocus: 'always', + queryFn: () => + http.fetch(`/api/index_management/mapping/${encodeURIComponent(indexName)}`), + }); + return { queryKey, ...result }; +}; diff --git a/x-pack/plugins/search_indices/public/hooks/use_elasticsearch_url.ts b/x-pack/plugins/search_indices/public/hooks/use_elasticsearch_url.ts new file mode 100644 index 0000000000000..d07cc62b210de --- /dev/null +++ b/x-pack/plugins/search_indices/public/hooks/use_elasticsearch_url.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from './use_kibana'; + +import { ELASTICSEARCH_URL_PLACEHOLDER } from '../constants'; + +export const useElasticsearchUrl = (): string => { + const { + services: { cloud }, + } = useKibana(); + + return cloud?.elasticsearchUrl ?? ELASTICSEARCH_URL_PLACEHOLDER; +}; diff --git a/x-pack/plugins/search_indices/public/types.ts b/x-pack/plugins/search_indices/public/types.ts index 8e7853543f76f..6e0192e34f87c 100644 --- a/x-pack/plugins/search_indices/public/types.ts +++ b/x-pack/plugins/search_indices/public/types.ts @@ -11,6 +11,7 @@ import type { AppMountParameters, CoreStart } from '@kbn/core/public'; import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { MappingPropertyBase } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; export interface SearchIndicesPluginSetup { enabled: boolean; @@ -44,11 +45,18 @@ export interface AppUsageTracker { load: (eventName: string | string[]) => void; } +export interface Mappings { + mappings: { + properties: MappingPropertyBase['properties']; + }; +} + export interface CodeSnippetParameters { indexName?: string; apiKey?: string; elasticsearchURL: string; } + export type CodeSnippetFunction = (params: CodeSnippetParameters) => string; export interface CodeLanguage { diff --git a/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts b/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts index 5ac440ce6c4f4..09b69aaed5332 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_search_index_detail_page.ts @@ -32,6 +32,40 @@ export function SvlSearchIndexDetailPageProvider({ getService }: FtrProviderCont async expectBackToIndicesButtonRedirectsToListPage() { await testSubjects.existOrFail('indicesList'); }, + async expectConnectionDetails() { + await testSubjects.existOrFail('connectionDetailsEndpoint', { timeout: 2000 }); + expect(await (await testSubjects.find('connectionDetailsEndpoint')).getVisibleText()).to.be( + 'https://fakeprojectid.es.fake-domain.cld.elstc.co:443' + ); + }, + async expectQuickStats() { + await testSubjects.existOrFail('quickStats', { timeout: 2000 }); + const quickStatsElem = await testSubjects.find('quickStats'); + const quickStatsDocumentElem = await quickStatsElem.findByTestSubject( + 'QuickStatsDocumentCount' + ); + expect(await quickStatsDocumentElem.getVisibleText()).to.contain('Document count\n0'); + expect(await quickStatsDocumentElem.getVisibleText()).not.to.contain('Index Size\n0b'); + await quickStatsDocumentElem.click(); + expect(await quickStatsDocumentElem.getVisibleText()).to.contain('Index Size\n0b'); + }, + async expectQuickStatsAIMappings() { + await testSubjects.existOrFail('quickStats', { timeout: 2000 }); + const quickStatsElem = await testSubjects.find('quickStats'); + const quickStatsAIMappingsElem = await quickStatsElem.findByTestSubject( + 'QuickStatsAIMappings' + ); + await quickStatsAIMappingsElem.click(); + await testSubjects.existOrFail('setupAISearchButton', { timeout: 2000 }); + }, + + async expectQuickStatsAIMappingsToHaveVectorFields() { + const quickStatsDocumentElem = await testSubjects.find('QuickStatsAIMappings'); + await quickStatsDocumentElem.click(); + expect(await quickStatsDocumentElem.getVisibleText()).to.contain('AI Search\n1 Field'); + await testSubjects.missingOrFail('setupAISearchButton', { timeout: 2000 }); + }, + async expectMoreOptionsActionButtonExists() { await testSubjects.existOrFail('moreOptionsActionButton'); }, diff --git a/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts b/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts index 9450dca44df57..cd39079274d0a 100644 --- a/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts +++ b/x-pack/test_serverless/functional/test_suites/search/search_index_detail.ts @@ -26,6 +26,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { after(async () => { await esDeleteAllIndices(indexName); }); + describe('index details page overview', () => { before(async () => { await es.indices.create({ index: indexName }); @@ -41,11 +42,35 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should have embedded dev console', async () => { await testHasEmbeddedConsole(pageObjects); }); + it('should have connection details', async () => { + await pageObjects.svlSearchIndexDetailPage.expectConnectionDetails(); + }); + + it('should have quick stats', async () => { + await pageObjects.svlSearchIndexDetailPage.expectQuickStats(); + await pageObjects.svlSearchIndexDetailPage.expectQuickStatsAIMappings(); + await es.indices.putMapping({ + index: indexName, + body: { + properties: { + my_field: { + type: 'dense_vector', + dims: 3, + }, + }, + }, + }); + await svlSearchNavigation.navigateToIndexDetailPage(indexName); + + await pageObjects.svlSearchIndexDetailPage.expectQuickStatsAIMappingsToHaveVectorFields(); + }); + it('back to indices button should redirect to list page', async () => { await pageObjects.svlSearchIndexDetailPage.expectBackToIndicesButtonExists(); await pageObjects.svlSearchIndexDetailPage.clickBackToIndicesButton(); await pageObjects.svlSearchIndexDetailPage.expectBackToIndicesButtonRedirectsToListPage(); }); + describe('page loading error', () => { before(async () => { await svlSearchNavigation.navigateToIndexDetailPage(indexName); From 684da232053c70c8bf3009fb40357c33e607a640 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 16 Sep 2024 12:16:07 -0400 Subject: [PATCH 10/16] chore(rca): Hide X axis on ESQL item (#192696) --- .../items/esql_item/register_esql_item.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx b/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx index 5f2f95807b4e0..7e64db5557fc2 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { css } from '@emotion/css'; +import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { ESQLSearchResponse } from '@kbn/es-types'; import { i18n } from '@kbn/i18n'; @@ -123,29 +122,24 @@ export function EsqlWidget({ suggestion, dataView, esqlQuery, dateHistogramResul [dataView, lens, dateHistogramResults] ); + // in the case of a lnsDatatable, we want to render the preview of the histogram and not the datable (input) itself if (input.attributes.visualizationType === 'lnsDatatable') { let innerElement: React.ReactElement; if (previewInput.error) { innerElement = ; } else if (previewInput.value) { - innerElement = ; + innerElement = ( + + ); } else { innerElement = ; } - return ( - - div { - height: 128px; - } - `} - > - {innerElement} - - - ); + + return {innerElement}; } return ( From 3ad697b74fdf09049464c4a7a8f2cd44550709f0 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Mon, 16 Sep 2024 12:20:33 -0400 Subject: [PATCH 11/16] [Synthetics] persist refresh interval in local storage (#191333) ## Summary Stores refresh interval and refresh paused state in local storage. Also defaults refresh to paused rather than active. --------- Co-authored-by: Shahzad --- .../constants/synthetics/client_defaults.ts | 6 +- .../common/components/auto_refresh_button.tsx | 64 ++----------------- .../common/components/last_refreshed.tsx | 6 +- .../hooks/use_selected_monitor.tsx | 5 +- .../contexts/synthetics_refresh_context.tsx | 55 ++++++++++++++-- .../apps/synthetics/state/ui/actions.ts | 2 - .../public/apps/synthetics/state/ui/index.ts | 14 ---- .../apps/synthetics/state/ui/selectors.ts | 9 --- .../__mocks__/synthetics_store.mock.ts | 2 - .../get_supported_url_params.test.ts | 9 +-- .../url_params/get_supported_url_params.ts | 11 +--- .../url_params/stringify_url_params.test.ts | 10 +-- .../utils/url_params/stringify_url_params.ts | 9 +-- 13 files changed, 64 insertions(+), 138 deletions(-) diff --git a/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/client_defaults.ts b/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/client_defaults.ts index 6ae9dbfef955f..3e5722ce59f10 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/client_defaults.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/client_defaults.ts @@ -16,11 +16,11 @@ export const CLIENT_DEFAULTS_SYNTHETICS = { DATE_RANGE_END: 'now', /** - * The application auto refreshes every 30s by default. + * The application auto refreshes every 60s by default. */ AUTOREFRESH_INTERVAL_SECONDS: 60, /** - * The application's autorefresh feature is enabled. + * The application's autorefresh feature is disabled by default. */ - AUTOREFRESH_IS_PAUSED: false, + AUTOREFRESH_IS_PAUSED: true, }; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/auto_refresh_button.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/auto_refresh_button.tsx index 6f40b000a6873..cea6a7d726926 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/auto_refresh_button.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/auto_refresh_button.tsx @@ -5,69 +5,17 @@ * 2.0. */ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { EuiAutoRefreshButton, OnRefreshChangeProps } from '@elastic/eui'; -import { useDispatch, useSelector } from 'react-redux'; -import { CLIENT_DEFAULTS_SYNTHETICS } from '../../../../../../common/constants/synthetics/client_defaults'; -import { SyntheticsUrlParams } from '../../../utils/url_params'; -import { useUrlParams } from '../../../hooks'; -import { - selectRefreshInterval, - selectRefreshPaused, - setRefreshIntervalAction, - setRefreshPausedAction, -} from '../../../state'; -const { AUTOREFRESH_INTERVAL_SECONDS, AUTOREFRESH_IS_PAUSED } = CLIENT_DEFAULTS_SYNTHETICS; +import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context'; -const replaceDefaults = ({ refreshPaused, refreshInterval }: Partial) => { - return { - refreshInterval: refreshInterval === AUTOREFRESH_INTERVAL_SECONDS ? undefined : refreshInterval, - refreshPaused: refreshPaused === AUTOREFRESH_IS_PAUSED ? undefined : refreshPaused, - }; -}; export const AutoRefreshButton = () => { - const dispatch = useDispatch(); - - const refreshPaused = useSelector(selectRefreshPaused); - const refreshInterval = useSelector(selectRefreshInterval); - - const [getUrlsParams, updateUrlParams] = useUrlParams(); - - const { refreshInterval: urlRefreshInterval, refreshPaused: urlIsPaused } = getUrlsParams(); - - const isFirstRender = useRef(true); - - useEffect(() => { - if (isFirstRender.current) { - // sync url state with redux state on first render - dispatch(setRefreshIntervalAction(urlRefreshInterval)); - dispatch(setRefreshPausedAction(urlIsPaused)); - isFirstRender.current = false; - } else { - // sync redux state with url state on subsequent renders - if (urlRefreshInterval !== refreshInterval || urlIsPaused !== refreshPaused) { - updateUrlParams( - replaceDefaults({ - refreshInterval, - refreshPaused, - }), - true - ); - } - } - }, [updateUrlParams, refreshInterval, refreshPaused, urlRefreshInterval, urlIsPaused, dispatch]); + const { refreshInterval, setRefreshInterval, refreshPaused, setRefreshPaused } = + useSyntheticsRefreshContext(); const onRefreshChange = (newProps: OnRefreshChangeProps) => { - dispatch(setRefreshIntervalAction(newProps.refreshInterval / 1000)); - dispatch(setRefreshPausedAction(newProps.isPaused)); - - updateUrlParams( - replaceDefaults({ - refreshInterval: newProps.refreshInterval / 1000, - refreshPaused: newProps.isPaused, - }), - true - ); + setRefreshPaused(newProps.isPaused); + setRefreshInterval(newProps.refreshInterval / 1000); }; return ( diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/last_refreshed.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/last_refreshed.tsx index bc086f67c822b..210170b7e3b8f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/last_refreshed.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/last_refreshed.tsx @@ -9,16 +9,12 @@ import React, { useEffect, useState } from 'react'; import moment from 'moment'; import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useSelector } from 'react-redux'; import { useSyntheticsRefreshContext } from '../../../contexts'; -import { selectRefreshPaused } from '../../../state'; export function LastRefreshed() { - const { lastRefresh: lastRefreshed } = useSyntheticsRefreshContext(); + const { lastRefresh: lastRefreshed, refreshPaused } = useSyntheticsRefreshContext(); const [refresh, setRefresh] = useState(() => Date.now()); - const refreshPaused = useSelector(selectRefreshPaused); - useEffect(() => { const interVal = setInterval(() => { setRefresh(Date.now()); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx index 622dcff46e902..1f2eadb7c09fc 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -16,7 +15,6 @@ import { selectMonitorListState, selectorMonitorDetailsState, selectorError, - selectRefreshInterval, } from '../../../state'; export const useSelectedMonitor = (monId?: string) => { @@ -27,14 +25,13 @@ export const useSelectedMonitor = (monId?: string) => { } const monitorsList = useSelector(selectEncryptedSyntheticsSavedMonitors); const { loading: monitorListLoading } = useSelector(selectMonitorListState); - const refreshInterval = useSelector(selectRefreshInterval); const monitorFromList = useMemo( () => monitorsList.find((monitor) => monitor[ConfigKey.CONFIG_ID] === monitorId) ?? null, [monitorId, monitorsList] ); const error = useSelector(selectorError); - const { lastRefresh } = useSyntheticsRefreshContext(); + const { lastRefresh, refreshInterval } = useSyntheticsRefreshContext(); const { syntheticsMonitor, syntheticsMonitorLoading, syntheticsMonitorDispatchedAt } = useSelector(selectorMonitorDetailsState); const dispatch = useDispatch(); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx index b53620921fdd1..9f3902b8ccaf2 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx @@ -14,15 +14,21 @@ import React, { useState, FC, } from 'react'; -import { useSelector } from 'react-redux'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { useEvent } from 'react-use'; import moment from 'moment'; import { Subject } from 'rxjs'; -import { selectRefreshInterval, selectRefreshPaused } from '../state'; +import { i18n } from '@kbn/i18n'; +import { CLIENT_DEFAULTS_SYNTHETICS } from '../../../../common/constants/synthetics/client_defaults'; +const { AUTOREFRESH_INTERVAL_SECONDS, AUTOREFRESH_IS_PAUSED } = CLIENT_DEFAULTS_SYNTHETICS; interface SyntheticsRefreshContext { lastRefresh: number; refreshApp: () => void; + refreshInterval: number; + refreshPaused: boolean; + setRefreshInterval: (interval: number) => void; + setRefreshPaused: (paused: boolean) => void; } const defaultContext: SyntheticsRefreshContext = { @@ -30,6 +36,22 @@ const defaultContext: SyntheticsRefreshContext = { refreshApp: () => { throw new Error('App refresh was not initialized, set it when you invoke the context'); }, + refreshInterval: AUTOREFRESH_INTERVAL_SECONDS, + refreshPaused: AUTOREFRESH_IS_PAUSED, + setRefreshInterval: () => { + throw new Error( + i18n.translate('xpack.synthetics.refreshContext.intervalNotInitialized', { + defaultMessage: 'Refresh interval was not initialized, set it when you invoke the context', + }) + ); + }, + setRefreshPaused: () => { + throw new Error( + i18n.translate('xpack.synthetics.refreshContext.pausedNotInitialized', { + defaultMessage: 'Refresh paused was not initialized, set it when you invoke the context', + }) + ); + }, }; export const SyntheticsRefreshContext = createContext(defaultContext); @@ -41,8 +63,14 @@ export const SyntheticsRefreshContextProvider: FC< > = ({ children, reload$ }) => { const [lastRefresh, setLastRefresh] = useState(Date.now()); - const refreshPaused = useSelector(selectRefreshPaused); - const refreshInterval = useSelector(selectRefreshInterval); + const [refreshInterval, setRefreshInterval] = useLocalStorage( + 'xpack.synthetics.refreshInterval', + AUTOREFRESH_INTERVAL_SECONDS + ); + const [refreshPaused, setRefreshPaused] = useLocalStorage( + 'xpack.synthetics.refreshPaused', + AUTOREFRESH_IS_PAUSED + ); const refreshApp = useCallback(() => { const refreshTime = Date.now(); @@ -66,13 +94,26 @@ export const SyntheticsRefreshContextProvider: FC< return { lastRefresh, refreshApp, + refreshInterval: refreshInterval ?? AUTOREFRESH_INTERVAL_SECONDS, + refreshPaused: refreshPaused ?? AUTOREFRESH_IS_PAUSED, + setRefreshInterval, + setRefreshPaused, }; - }, [lastRefresh, refreshApp]); + }, [ + lastRefresh, + refreshApp, + refreshInterval, + refreshPaused, + setRefreshInterval, + setRefreshPaused, + ]); useEvent( 'visibilitychange', () => { - const isOutdated = moment().diff(new Date(lastRefresh), 'seconds') > refreshInterval; + const isOutdated = + moment().diff(new Date(lastRefresh), 'seconds') > + (refreshInterval || AUTOREFRESH_INTERVAL_SECONDS); if (document.visibilityState !== 'hidden' && !refreshPaused && isOutdated) { refreshApp(); } @@ -88,7 +129,7 @@ export const SyntheticsRefreshContextProvider: FC< if (document.visibilityState !== 'hidden') { refreshApp(); } - }, refreshInterval * 1000); + }, (refreshInterval || AUTOREFRESH_INTERVAL_SECONDS) * 1000); return () => clearInterval(interval); }, [refreshPaused, refreshApp, refreshInterval]); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts index e3738f3737cf0..06b9506ead191 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/actions.ts @@ -31,5 +31,3 @@ export const toggleIntegrationsPopover = createAction( ); export const setSelectedMonitorId = createAction('[UI] SET MONITOR ID'); -export const setRefreshPausedAction = createAction('[UI] SET REFRESH PAUSED'); -export const setRefreshIntervalAction = createAction('[UI] SET REFRESH INTERVAL'); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts index 6c6ef93bbf3a7..2c7d5e5ce3d4c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/index.ts @@ -11,7 +11,6 @@ import { SYNTHETICS_STATUS_RULE, SYNTHETICS_TLS_RULE, } from '../../../../../common/constants/synthetics_alerts'; -import { CLIENT_DEFAULTS_SYNTHETICS } from '../../../../../common/constants/synthetics/client_defaults'; import { PopoverState, toggleIntegrationsPopover, @@ -20,10 +19,7 @@ import { setAlertFlyoutVisible, setSearchTextAction, setSelectedMonitorId, - setRefreshPausedAction, - setRefreshIntervalAction, } from './actions'; -const { AUTOREFRESH_INTERVAL_SECONDS, AUTOREFRESH_IS_PAUSED } = CLIENT_DEFAULTS_SYNTHETICS; export interface UiState { alertFlyoutVisible: typeof SYNTHETICS_TLS_RULE | typeof SYNTHETICS_STATUS_RULE | null; @@ -32,8 +28,6 @@ export interface UiState { searchText: string; integrationsPopoverOpen: PopoverState | null; monitorId: string; - refreshInterval: number; - refreshPaused: boolean; } const initialState: UiState = { @@ -43,8 +37,6 @@ const initialState: UiState = { searchText: '', integrationsPopoverOpen: null, monitorId: '', - refreshInterval: AUTOREFRESH_INTERVAL_SECONDS, - refreshPaused: AUTOREFRESH_IS_PAUSED, }; export const uiReducer = createReducer(initialState, (builder) => { @@ -66,12 +58,6 @@ export const uiReducer = createReducer(initialState, (builder) => { }) .addCase(setSelectedMonitorId, (state, action) => { state.monitorId = action.payload; - }) - .addCase(setRefreshPausedAction, (state, action) => { - state.refreshPaused = action.payload; - }) - .addCase(setRefreshIntervalAction, (state, action) => { - state.refreshInterval = action.payload; }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts index 4e365d8343555..f02b1fb564c37 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/ui/selectors.ts @@ -14,12 +14,3 @@ export const selectAlertFlyoutVisibility = createSelector( uiStateSelector, ({ alertFlyoutVisible }) => alertFlyoutVisible ); - -export const selectRefreshPaused = createSelector( - uiStateSelector, - ({ refreshPaused }) => refreshPaused -); -export const selectRefreshInterval = createSelector( - uiStateSelector, - ({ refreshInterval }) => refreshInterval -); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index b861fe36b9b96..aa52c54c21b78 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -30,8 +30,6 @@ export const mockState: SyntheticsAppState = { integrationsPopoverOpen: null, searchText: '', monitorId: '', - refreshInterval: 60, - refreshPaused: true, }, serviceLocations: { throttling: DEFAULT_THROTTLING, diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts index efabb2034e434..2a01b9d7aeefb 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts @@ -51,12 +51,7 @@ describe('getSupportedUrlParams', () => { it('returns default values', () => { const { FILTERS, SEARCH, STATUS_FILTER } = CLIENT_DEFAULTS; - const { - DATE_RANGE_START, - DATE_RANGE_END, - AUTOREFRESH_INTERVAL_SECONDS, - AUTOREFRESH_IS_PAUSED, - } = CLIENT_DEFAULTS_SYNTHETICS; + const { DATE_RANGE_START, DATE_RANGE_END } = CLIENT_DEFAULTS_SYNTHETICS; const result = getSupportedUrlParams({}); expect(result).toEqual({ absoluteDateRangeStart: MOCK_DATE_VALUE, @@ -75,8 +70,6 @@ describe('getSupportedUrlParams', () => { projects: [], schedules: [], tags: [], - refreshInterval: AUTOREFRESH_INTERVAL_SECONDS, - refreshPaused: AUTOREFRESH_IS_PAUSED, }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts index ce2eb6f30829f..8b4612b1e0f39 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts @@ -7,8 +7,6 @@ import { MonitorOverviewState } from '../../state'; import { CLIENT_DEFAULTS_SYNTHETICS } from '../../../../../common/constants/synthetics/client_defaults'; -import { parseIsPaused } from './parse_is_paused'; -import { parseUrlInt } from './parse_url_int'; import { CLIENT_DEFAULTS } from '../../../../../common/constants'; import { parseAbsoluteDate } from './parse_absolute_date'; @@ -16,8 +14,6 @@ import { parseAbsoluteDate } from './parse_absolute_date'; export interface SyntheticsUrlParams { absoluteDateRangeStart: number; absoluteDateRangeEnd: number; - refreshInterval: number; - refreshPaused: boolean; dateRangeStart: string; dateRangeEnd: string; pagination?: string; @@ -43,8 +39,7 @@ export interface SyntheticsUrlParams { const { ABSOLUTE_DATE_RANGE_START, ABSOLUTE_DATE_RANGE_END, SEARCH, FILTERS, STATUS_FILTER } = CLIENT_DEFAULTS; -const { DATE_RANGE_START, DATE_RANGE_END, AUTOREFRESH_INTERVAL_SECONDS, AUTOREFRESH_IS_PAUSED } = - CLIENT_DEFAULTS_SYNTHETICS; +const { DATE_RANGE_START, DATE_RANGE_END } = CLIENT_DEFAULTS_SYNTHETICS; /** * Gets the current URL values for the application. If no item is present @@ -76,8 +71,6 @@ export const getSupportedUrlParams = (params: { }); const { - refreshInterval, - refreshPaused, dateRangeStart, dateRangeEnd, filters, @@ -112,8 +105,6 @@ export const getSupportedUrlParams = (params: { ABSOLUTE_DATE_RANGE_END, { roundUp: true } ), - refreshInterval: parseUrlInt(refreshInterval, AUTOREFRESH_INTERVAL_SECONDS), - refreshPaused: parseIsPaused(refreshPaused, AUTOREFRESH_IS_PAUSED), dateRangeStart: dateRangeStart || DATE_RANGE_START, dateRangeEnd: dateRangeEnd || DATE_RANGE_END, filters: filters || FILTERS, diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.test.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.test.ts index 6f9ace8634d64..c8f8649fd56db 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.test.ts @@ -12,8 +12,6 @@ describe('stringifyUrlParams', () => { const result = stringifyUrlParams({ absoluteDateRangeStart: 1000, absoluteDateRangeEnd: 2000, - refreshInterval: 50000, - refreshPaused: false, dateRangeStart: 'now-15m', dateRangeEnd: 'now', filters: 'monitor.id: bar', @@ -22,7 +20,7 @@ describe('stringifyUrlParams', () => { statusFilter: 'up', }); expect(result).toMatchInlineSnapshot( - `"?absoluteDateRangeStart=1000&absoluteDateRangeEnd=2000&refreshInterval=50000&refreshPaused=false&dateRangeStart=now-15m&dateRangeEnd=now&filters=monitor.id%3A%20bar&focusConnectorField=true&search=monitor.id%3A%20foo&statusFilter=up"` + `"?absoluteDateRangeStart=1000&absoluteDateRangeEnd=2000&dateRangeStart=now-15m&dateRangeEnd=now&filters=monitor.id%3A%20bar&focusConnectorField=true&search=monitor.id%3A%20foo&statusFilter=up"` ); }); @@ -31,8 +29,6 @@ describe('stringifyUrlParams', () => { { absoluteDateRangeStart: 1000, absoluteDateRangeEnd: 2000, - refreshInterval: 50000, - refreshPaused: false, dateRangeStart: 'now-15m', dateRangeEnd: 'now', filters: 'monitor.id: bar', @@ -43,9 +39,7 @@ describe('stringifyUrlParams', () => { }, true ); - expect(result).toMatchInlineSnapshot( - `"?refreshInterval=50000&dateRangeStart=now-15m&filters=monitor.id%3A%20bar"` - ); + expect(result).toMatchInlineSnapshot(`"?dateRangeStart=now-15m&filters=monitor.id%3A%20bar"`); expect(result.includes('pagination')).toBeFalsy(); expect(result.includes('search')).toBeFalsy(); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.ts index 7f0dd94237593..7f465e7272dc6 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/url_params/stringify_url_params.ts @@ -12,8 +12,7 @@ import { CLIENT_DEFAULTS } from '../../../../../common/constants'; const { FOCUS_CONNECTOR_FIELD } = CLIENT_DEFAULTS; -const { DATE_RANGE_START, DATE_RANGE_END, AUTOREFRESH_INTERVAL_SECONDS, AUTOREFRESH_IS_PAUSED } = - CLIENT_DEFAULTS_SYNTHETICS; +const { DATE_RANGE_START, DATE_RANGE_END } = CLIENT_DEFAULTS_SYNTHETICS; export const stringifyUrlParams = (params: Partial, ignoreEmpty = false) => { if (ignoreEmpty) { @@ -41,12 +40,6 @@ const replaceDefaults = (params: Partial) => { if (key === 'dateRangeEnd' && val === DATE_RANGE_END) { delete params[key]; } - if (key === 'refreshPaused' && val === AUTOREFRESH_IS_PAUSED) { - delete params[key]; - } - if (key === 'refreshInterval' && val === AUTOREFRESH_INTERVAL_SECONDS) { - delete params[key]; - } if (key === 'focusConnectorField' && val === FOCUS_CONNECTOR_FIELD) { delete params[key]; } From 010a362f34869f01ef39d21c0986d90cffc21f50 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 17 Sep 2024 02:55:22 +1000 Subject: [PATCH 12/16] skip failing test suite (#193036) --- .../test_suites/common/index_management/inference_endpoints.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts b/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts index f5f712fc7d5a1..83ac02779b5f0 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/index_management/inference_endpoints.ts @@ -26,7 +26,8 @@ export default function ({ getService }: FtrProviderContext) { let roleAuthc: RoleCredentials; let internalReqHeader: InternalRequestHeader; - describe('Inference endpoints', function () { + // Failing: See https://github.com/elastic/kibana/issues/193036 + describe.skip('Inference endpoints', function () { before(async () => { roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); internalReqHeader = svlCommonApi.getInternalRequestHeader(); From 6680f35ef94286d34dd5c56e28575b31eab70836 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:19:39 -0400 Subject: [PATCH 13/16] [Security Solution] Test plan for `query` fields diff algorithm (#192529) ## Summary Related ticket: https://github.com/elastic/kibana/issues/187658 Adds test plan for diff algorithm for arrays of scalar values implemented here: https://github.com/elastic/kibana/pull/190179 ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../upgrade_review_algorithms.md | 144 ++++++++++++------ 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade_review_algorithms.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade_review_algorithms.md index 26b01da200903..e65d366e0f44c 100644 --- a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade_review_algorithms.md +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade_review_algorithms.md @@ -29,10 +29,11 @@ Status: `in progress`. - [**Scenario: `ABC` - Rule field is an array of scalar values**](#scenario-abc---rule-field-is-an-array-of-scalar-values) - [**Scenario: `ABC` - Rule field is a solvable `data_source` object**](#scenario-abc---rule-field-is-a-solvable-data_source-object) - [**Scenario: `ABC` - Rule field is a non-solvable `data_source` object**](#scenario-abc---rule-field-is-a-non-solvable-data_source-object) + - [**Scenario: `ABC` - Rule field is a `kql_query`, `eql_query`, or `esql_query` object**](#scenario-abc---rule-field-is-a-kql_query-eql_query-or-esql_query-object) - [Rule field has an update and a custom value that are the same and the rule base version doesn't exist - `-AA`](#rule-field-has-an-update-and-a-custom-value-that-are-the-same-and-the-rule-base-version-doesnt-exist----aa) - [**Scenario: `-AA` - Rule field is any type**](#scenario--aa---rule-field-is-any-type) - [Rule field has an update and a custom value that are NOT the same and the rule base version doesn't exist - `-AB`](#rule-field-has-an-update-and-a-custom-value-that-are-not-the-same-and-the-rule-base-version-doesnt-exist----ab) - - [**Scenario: `-AB` - Rule field is a number or single line string**](#scenario--ab---rule-field-is-a-number-or-single-line-string) + - [**Scenario: `-AB` - Rule field is a number, single line string, multi line string, `data_source` object, `kql_query` object, `eql_query` object, or `esql_query` object**](#scenario--ab---rule-field-is-a-number-single-line-string-multi-line-string-data_source-object-kql_query-object-eql_query-object-or-esql_query-object) - [**Scenario: `-AB` - Rule field is an array of scalar values**](#scenario--ab---rule-field-is-an-array-of-scalar-values) - [**Scenario: `-AB` - Rule field is a solvable `data_source` object**](#scenario--ab---rule-field-is-a-solvable-data_source-object) - [**Scenario: `-AB` - Rule field is a non-solvable `data_source` object**](#scenario--ab---rule-field-is-a-non-solvable-data_source-object) @@ -58,6 +59,9 @@ Status: `in progress`. - **Grouped fields** - `data_source`: an object that contains a `type` field with a value of `data_view_id` or `index_patterns` and another field that's either `data_view_id` of type string OR `index_patterns` of type string array + - `kql_query`: an object that contains a `type` field with a value of `inline_query` or `saved_query` and other fields based on whichever type is defined. If it's `inline_query`, the object contains a `query` string field, a `language` field that's either `kuery` or `lucene`, and a `filters` field which is an array of kibana filters. If the type field is `saved_query`, the object only contains a `saved_query_id` string field. + - `eql_query`: an object that contains a `query` string field, a `language` field that always has the value: `eql`, and a `filters` field that contains an array of kibana filters. + - `esql_query`: an object that contains a `query` string field and a `language` field that always has the value: `esql`. ### Assumptions @@ -70,7 +74,7 @@ Status: `in progress`. #### **Scenario: `AAA` - Rule field is any type** -**Automation**: 6 integration tests with mock rules + a set of unit tests for each algorithm +**Automation**: 10 integration tests with mock rules + a set of unit tests for each algorithm ```Gherkin Given field is not customized by the user (current version == base version) @@ -80,20 +84,24 @@ And field should not be returned from the `upgrade/_review` API end And field should not be shown in the upgrade preview UI Examples: -| algorithm | field_name | base_version | current_version | target_version | merged_version | -| single line string | name | "A" | "A" | "A" | "A" | -| multi line string | description | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My description.\nThis is a second line." | -| number | risk_score | 1 | 1 | 1 | 1 | -| array of scalars | tags | ["one", "two", "three"] | ["one", "three", "two"] | ["three", "one", "two"] | ["one", "three", "two"] | -| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | -| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| single line string | name | "A" | "A" | "A" | "A" | +| multi line string | description | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My description.\nThis is a second line." | +| number | risk_score | 1 | 1 | 1 | 1 | +| array of scalars | tags | ["one", "two", "three"] | ["one", "three", "two"] | ["three", "one", "two"] | ["one", "three", "two"] | +| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | +| kql_query | kql_query | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | +| kql_query | kql_query | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | +| eql_query | eql_query | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | +| esql_query | esql_query | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | ``` ### Rule field doesn't have an update but has a custom value - `ABA` #### **Scenario: `ABA` - Rule field is any type** -**Automation**: 6 integration tests with mock rules + a set of unit tests for each algorithm +**Automation**: 10 integration tests with mock rules + a set of unit tests for each algorithm ```Gherkin Given field is customized by the user (current version != base version) @@ -103,20 +111,24 @@ And field should be returned from the `upgrade/_review` API endpoin And field should be shown in the upgrade preview UI Examples: -| algorithm | field_name | base_version | current_version | target_version | merged_version | -| single line string | name | "A" | "B" | "A" | "B" | -| multi line string | description | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | -| number | risk_score | 1 | 2 | 1 | 2 | -| array of scalars | tags | ["one", "two", "three"] | ["one", "two", "four"] | ["one", "two", "three"] | ["one", "two", "four"] | -| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | -| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| single line string | name | "A" | "B" | "A" | "B" | +| multi line string | description | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | +| number | risk_score | 1 | 2 | 1 | 2 | +| array of scalars | tags | ["one", "two", "three"] | ["one", "two", "four"] | ["one", "two", "three"] | ["one", "two", "four"] | +| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | +| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| kql_query | kql_query | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = false", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = false", language: "kuery", filters: []} | +| kql_query | kql_query | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | +| eql_query | eql_query | {query: "query where true", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | +| esql_query | esql_query | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | ``` ### Rule field has an update and doesn't have a custom value - `AAB` #### **Scenario: `AAB` - Rule field is any type** -**Automation**: 6 integration tests with mock rules + a set of unit tests for each algorithm +**Automation**: 10 integration tests with mock rules + a set of unit tests for each algorithm ```Gherkin Given field is not customized by the user (current version == base version) @@ -126,20 +138,24 @@ And field should be returned from the `upgrade/_review` API endpoin And field should be shown in the upgrade preview UI Examples: -| algorithm | field_name | base_version | current_version | target_version | merged_version | -| single line string | name | "A" | "A" | "B" | "B" | -| multi line string | description | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | -| number | risk_score | 1 | 1 | 2 | 2 | -| array of scalars | tags | ["one", "two", "three"] | ["one", "two", "three"] | ["one", "two", "four"] | ["one", "two", "four"] | -| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | -| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| single line string | name | "A" | "A" | "B" | "B" | +| multi line string | description | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | +| number | risk_score | 1 | 1 | 2 | 2 | +| array of scalars | tags | ["one", "two", "three"] | ["one", "two", "three"] | ["one", "two", "four"] | ["one", "two", "four"] | +| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | +| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| kql_query | kql_query | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | +| kql_query | kql_query | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | +| eql_query | eql_query | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: [{ field: 'some query' }]} | {query: "query where true", language: "eql", filters: [{ field: 'some query' }]} | +| esql_query | esql_query | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | ``` ### Rule field has an update and a custom value that are the same - `ABB` #### **Scenario: `ABB` - Rule field is any type** -**Automation**: 6 integration tests with mock rules + a set of unit tests for each algorithm +**Automation**: 10 integration tests with mock rules + a set of unit tests for each algorithm ```Gherkin Given field is customized by the user (current version != base version) @@ -150,13 +166,17 @@ And field should be returned from the `upgrade/_review` API endpoin And field should be shown in the upgrade preview UI Examples: -| algorithm | field_name | base_version | current_version | target_version | merged_version | -| single line string | name | "A" | "B" | "B" | "B" | -| multi line string | description | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | -| number | risk_score | 1 | 2 | 2 | 2 | -| array of scalars | tags | ["one", "two", "three"] | ["one", "two", "four"] | ["one", "two", "four"] | ["one", "two", "four"] | -| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | -| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| single line string | name | "A" | "B" | "B" | "B" | +| multi line string | description | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | +| number | risk_score | 1 | 2 | 2 | 2 | +| array of scalars | tags | ["one", "two", "three"] | ["one", "two", "four"] | ["one", "two", "four"] | ["one", "two", "four"] | +| data_source | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | +| data_source | data_source | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| kql_query | kql_query | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "lucene", filters: []} | {type: "inline_query", query: "query string = true", language: "lucene", filters: []} | {type: "inline_query", query: "query string = true", language: "lucene", filters: []} | +| kql_query | kql_query | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | +| eql_query | eql_query | {query: "query where true", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | +| esql_query | esql_query | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | ``` ### Rule field has an update and a custom value that are NOT the same - `ABC` @@ -284,11 +304,31 @@ Examples: | data_source | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "data_view", "data_view_id": "A"} | {type: "index_patterns", "index_patterns": ["one", "two", "five"]} | {type: "data_view", "data_view_id": "A"} | ``` +#### **Scenario: `ABC` - Rule field is a `kql_query`, `eql_query`, or `esql_query` object** + +**Automation**: 4 integration tests with mock rules + a set of unit tests for the algorithms + +```Gherkin +Given field is customized by the user (current version != base version) +And field is updated by Elastic in this upgrade (target version != base version) +And customized field is different than the Elastic update in this upgrade (current version != target version) +Then for field the diff algorithm should output the current version as the merged one with a non-solvable conflict +And field should be returned from the `upgrade/_review` API endpoint +And field should be shown in the upgrade preview UI + +Examples: +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| kql_query | kql_query | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "inline_query", query: "query string = false", language: "kuery", filters: []} | {type: "saved_query", saved_query_id: 'saved-query-id'} | +| kql_query | kql_query | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | +| eql_query | eql_query | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: [{ field: 'some query' }]} | {query: "query where false", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: [{ field: 'some query' }]} | +| esql_query | esql_query | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | {query: "FROM different query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | +``` + ### Rule field has an update and a custom value that are the same and the rule base version doesn't exist - `-AA` #### **Scenario: `-AA` - Rule field is any type** -**Automation**: 5 integration tests with mock rules + a set of unit tests for each algorithm +**Automation**: 9 integration tests with mock rules + a set of unit tests for each algorithm ```Gherkin Given at least 1 installed prebuilt rule has a new version available @@ -299,20 +339,24 @@ And field should not be returned from the `upgrade/_review` API end And field should not be shown in the upgrade preview UI Examples: -| algorithm | field_name | base_version | current_version | target_version | merged_version | -| single line string | name | N/A | "A" | "A" | "A" | -| multi line string | description | N/A | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My description.\nThis is a second line." | -| number | risk_score | N/A | 1 | 1 | 1 | -| array of scalars | tags | N/A | ["one", "three", "two"] | ["three", "one", "two"] | ["one", "three", "two"] | -| data_source | data_source | N/A | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | -| data_source | data_source | N/A | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| single line string | name | N/A | "A" | "A" | "A" | +| multi line string | description | N/A | "My description.\nThis is a second line." | "My description.\nThis is a second line." | "My description.\nThis is a second line." | +| number | risk_score | N/A | 1 | 1 | 1 | +| array of scalars | tags | N/A | ["one", "three", "two"] | ["three", "one", "two"] | ["one", "three", "two"] | +| data_source | data_source | N/A | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | {type: "index_patterns", "index_patterns": ["one", "two", "three"]} | +| data_source | data_source | N/A | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "A"} | +| kql_query | kql_query | N/A | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | +| kql_query | kql_query | N/A | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'saved-query-id'} | +| eql_query | eql_query | N/A | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | {query: "query where true", language: "eql", filters: []} | +| esql_query | esql_query | N/A | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE true", language: "esql"} | ``` ### Rule field has an update and a custom value that are NOT the same and the rule base version doesn't exist - `-AB` -#### **Scenario: `-AB` - Rule field is a number or single line string** +#### **Scenario: `-AB` - Rule field is a number, single line string, multi line string, `data_source` object, `kql_query` object, `eql_query` object, or `esql_query` object** -**Automation**: 4 integration tests with mock rules + a set of unit tests for the algorithms +**Automation**: 8 integration tests with mock rules + a set of unit tests for the algorithms ```Gherkin Given at least 1 installed prebuilt rule has a new version available @@ -323,11 +367,15 @@ And field should be returned from the `upgrade/_review` API endpoin And field should be shown in the upgrade preview UI Examples: -| algorithm | field_name | base_version | current_version | target_version | merged_version | -| single line string | name | N/A | "B" | "C" | "C" | -| multi line string | description | N/A | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | -| number | risk_score | N/A | 2 | 3 | 3 | -| data_source | data_source | N/A | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "B"} | {type: "data_view", "data_view_id": "B"} | +| algorithm | field_name | base_version | current_version | target_version | merged_version | +| single line string | name | N/A | "B" | "C" | "C" | +| multi line string | description | N/A | "My description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | "My GREAT description.\nThis is a second line." | +| number | risk_score | N/A | 2 | 3 | 3 | +| data_source | data_source | N/A | {type: "data_view", "data_view_id": "A"} | {type: "data_view", "data_view_id": "B"} | {type: "data_view", "data_view_id": "B"} | +| kql_query | kql_query | N/A | {type: "inline_query", query: "query string = true", language: "kuery", filters: []} | {type: "inline_query", query: "query string = false", language: "kuery", filters: []} | {type: "inline_query", query: "query string = false", language: "kuery", filters: []} | +| kql_query | kql_query | N/A | {type: "saved_query", saved_query_id: 'saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | {type: "saved_query", saved_query_id: 'new-saved-query-id'} | +| eql_query | eql_query | N/A | {query: "query where true", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | {query: "query where false", language: "eql", filters: []} | +| esql_query | esql_query | N/A | {query: "FROM query WHERE true", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | {query: "FROM query WHERE false", language: "esql"} | ``` #### **Scenario: `-AB` - Rule field is an array of scalar values** From 0c63a7b73394e9bfe348de8b2d0755c557098eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Mon, 16 Sep 2024 13:45:08 -0400 Subject: [PATCH 14/16] Fix flaky test around search cancellation (#193008) Resolves https://github.com/elastic/kibana/issues/192914 In this PR, I'm fixing the flakiness in tests where sometimes rules fail with a different message on timeout. This is expected as it's a race condition between the Elasticsearch request timing out and the alerting rule getting cancelled. So we can expect one of two messages. Note: Test is not skipped as of PR creation --- .../builtin_alert_types/cancellable/rule.ts | 27 ++++++++++++------- .../builtin_alert_types/long_running/rule.ts | 9 ++++--- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/cancellable/rule.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/cancellable/rule.ts index 41a12004e256a..8a254447b4a4e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/cancellable/rule.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/cancellable/rule.ts @@ -127,9 +127,12 @@ export default function ruleTests({ getService }: FtrProviderContext) { events.filter((event) => event?.event?.action === 'execute'); expect(events[0]?.event?.outcome).to.eql('failure'); expect(events[0]?.kibana?.alerting?.status).to.eql('error'); - expect(events[0]?.error?.message).to.eql( - 'Search has been aborted due to cancelled execution' - ); + // Timeouts will encounter one of the following two messages + const expectedMessages = [ + 'Request timed out', + 'Search has been aborted due to cancelled execution', + ]; + expect(expectedMessages.includes(events[0]?.error?.message || '')).to.be(true); // rule execution status should be in error with reason timeout const { status, body: rule } = await supertest.get( @@ -137,9 +140,12 @@ export default function ruleTests({ getService }: FtrProviderContext) { ); expect(status).to.eql(200); expect(rule.execution_status.status).to.eql('error'); - expect(rule.execution_status.error.message).to.eql( - `test.cancellableRule:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of 3s` - ); + expect( + [ + 'Request timed out', + `test.cancellableRule:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of 3s`, + ].includes(rule.execution_status.error.message) + ).to.eql(true); expect(rule.execution_status.error.reason).to.eql('timeout'); }); @@ -183,9 +189,12 @@ export default function ruleTests({ getService }: FtrProviderContext) { ); expect(status).to.eql(200); expect(rule.execution_status.status).to.eql('error'); - expect(rule.execution_status.error.message).to.eql( - `test.cancellableRule:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of 3s` - ); + expect( + [ + 'Request timed out', + `test.cancellableRule:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of 3s`, + ].includes(rule.execution_status.error.message) + ).to.eql(true); expect(rule.execution_status.error.reason).to.eql('timeout'); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/long_running/rule.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/long_running/rule.ts index 43524a57cb225..effd35d392a3f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/long_running/rule.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/builtin_alert_types/long_running/rule.ts @@ -74,9 +74,12 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(errorStatuses.length).to.be.greaterThan(0); const lastErrorStatus = errorStatuses.pop(); expect(lastErrorStatus?.status).to.eql('error'); - expect(lastErrorStatus?.error.message).to.eql( - `test.patternLongRunning.cancelAlertsOnRuleTimeout:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of 3s` - ); + expect( + [ + 'Request timed out', + `test.patternLongRunning.cancelAlertsOnRuleTimeout:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of 3s`, + ].includes(lastErrorStatus?.error.message || '') + ).to.eql(true); expect(lastErrorStatus?.error.reason).to.eql('timeout'); }); From 7a7cd7b540239cc88a573a4a5260806b68f04288 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 16 Sep 2024 13:51:18 -0400 Subject: [PATCH 15/16] feat(rca): search and filter investigations (#192920) --- .../src/rest_specs/find.ts | 6 +- .../rest_specs/get_all_investigation_tags.ts | 21 +++ .../src/rest_specs/index.ts | 2 + .../src/schema/investigation.ts | 1 + .../src/schema/investigation_item.ts | 1 + .../src/schema/investigation_note.ts | 1 + .../fields/tags_field.tsx | 6 +- .../public/hooks/query_key_factory.ts | 3 +- .../hooks/use_fetch_all_investigation_tags.ts | 59 ++++++++ .../hooks/use_fetch_investigation_list.ts | 11 ++ .../list/components/investigation_list.tsx | 136 ++++++++++++------ .../list/components/investigations_error.tsx | 34 +++++ .../list/components/search_bar/search_bar.tsx | 52 +++++++ .../components/search_bar/status_filter.tsx | 85 +++++++++++ .../components/search_bar/tags_filter.tsx | 86 +++++++++++ ...investigate_app_server_route_repository.ts | 25 +++- .../server/saved_objects/investigation.ts | 1 + .../server/services/create_investigation.ts | 4 +- .../services/create_investigation_item.ts | 4 +- .../services/create_investigation_note.ts | 4 +- .../server/services/find_investigations.ts | 28 +++- .../services/get_all_investigation_tags.ts | 19 +++ .../services/investigation_repository.ts | 41 +++++- .../services/update_investigation_note.ts | 2 +- 24 files changed, 568 insertions(+), 64 deletions(-) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_tags.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_tags.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigations_error.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/search_bar.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/status_filter.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/tags_filter.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_tags.ts diff --git a/packages/kbn-investigation-shared/src/rest_specs/find.ts b/packages/kbn-investigation-shared/src/rest_specs/find.ts index 2a3eab76fbb54..7a938212cfba4 100644 --- a/packages/kbn-investigation-shared/src/rest_specs/find.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/find.ts @@ -15,8 +15,10 @@ const findInvestigationsParamsSchema = z query: z .object({ alertId: z.string(), - page: z.string(), - perPage: z.string(), + search: z.string(), + filter: z.string(), + page: z.coerce.number(), + perPage: z.coerce.number(), }) .partial(), }) diff --git a/packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_tags.ts b/packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_tags.ts new file mode 100644 index 0000000000000..35665b1b3c695 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_tags.ts @@ -0,0 +1,21 @@ +/* + * 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 { z } from '@kbn/zod'; + +const getAllInvestigationTagsParamsSchema = z.object({ + query: z.object({}), +}); + +const getAllInvestigationTagsResponseSchema = z.string().array(); + +type GetAllInvestigationTagsResponse = z.output; + +export { getAllInvestigationTagsParamsSchema, getAllInvestigationTagsResponseSchema }; +export type { GetAllInvestigationTagsResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/index.ts b/packages/kbn-investigation-shared/src/rest_specs/index.ts index eb30920430673..9b81aca896f55 100644 --- a/packages/kbn-investigation-shared/src/rest_specs/index.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/index.ts @@ -17,6 +17,7 @@ export type * from './find'; export type * from './get'; export type * from './get_items'; export type * from './get_notes'; +export type * from './get_all_investigation_tags'; export type * from './investigation'; export type * from './investigation_item'; export type * from './investigation_note'; @@ -34,6 +35,7 @@ export * from './find'; export * from './get'; export * from './get_items'; export * from './get_notes'; +export * from './get_all_investigation_tags'; export * from './investigation'; export * from './investigation_item'; export * from './investigation_note'; diff --git a/packages/kbn-investigation-shared/src/schema/investigation.ts b/packages/kbn-investigation-shared/src/schema/investigation.ts index cd99de702c9e5..47d198665657d 100644 --- a/packages/kbn-investigation-shared/src/schema/investigation.ts +++ b/packages/kbn-investigation-shared/src/schema/investigation.ts @@ -25,6 +25,7 @@ const investigationSchema = z.object({ title: z.string(), createdAt: z.number(), createdBy: z.string(), + updatedAt: z.number(), params: z.object({ timeRange: z.object({ from: z.number(), to: z.number() }), }), diff --git a/packages/kbn-investigation-shared/src/schema/investigation_item.ts b/packages/kbn-investigation-shared/src/schema/investigation_item.ts index e7578977bd254..820db8500e5dc 100644 --- a/packages/kbn-investigation-shared/src/schema/investigation_item.ts +++ b/packages/kbn-investigation-shared/src/schema/investigation_item.ts @@ -20,6 +20,7 @@ const investigationItemSchema = z.intersection( id: z.string(), createdAt: z.number(), createdBy: z.string(), + updatedAt: z.number(), }), itemSchema ); diff --git a/packages/kbn-investigation-shared/src/schema/investigation_note.ts b/packages/kbn-investigation-shared/src/schema/investigation_note.ts index a4ca46158e1bb..ff877ab884127 100644 --- a/packages/kbn-investigation-shared/src/schema/investigation_note.ts +++ b/packages/kbn-investigation-shared/src/schema/investigation_note.ts @@ -13,6 +13,7 @@ const investigationNoteSchema = z.object({ id: z.string(), content: z.string(), createdAt: z.number(), + updatedAt: z.number(), createdBy: z.string(), }); diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/tags_field.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/tags_field.tsx index fb6555de53f34..a912a6d61eb7b 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/tags_field.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/tags_field.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { InvestigationForm } from '../investigation_edit_form'; +import { useFetchAllInvestigationTags } from '../../../hooks/use_fetch_all_investigation_tags'; const I18N_TAGS_LABEL = i18n.translate( 'xpack.investigateApp.investigationEditForm.span.tagsLabel', @@ -18,6 +19,7 @@ const I18N_TAGS_LABEL = i18n.translate( export function TagsField() { const { control, getFieldState } = useFormContext(); + const { isLoading, data: tags } = useFetchAllInvestigationTags(); return ( @@ -32,10 +34,10 @@ export function TagsField() { aria-label={I18N_TAGS_LABEL} placeholder={I18N_TAGS_LABEL} fullWidth - noSuggestions isInvalid={fieldState.invalid} isClearable - options={[]} + isLoading={isLoading} + options={tags?.map((tag) => ({ label: tag, value: tag })) ?? []} selectedOptions={generateTagOptions(field.value)} onChange={(selected) => { if (selected.length) { diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts index 253c38a972fbc..454c77ddf56e0 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts @@ -9,8 +9,9 @@ export const investigationKeys = { all: ['investigations'] as const, + tags: () => [...investigationKeys.all, 'tags'] as const, lists: () => [...investigationKeys.all, 'list'] as const, - list: (params: { page: number; perPage: number }) => + list: (params: { page: number; perPage: number; search?: string; filter?: string }) => [...investigationKeys.lists(), params] as const, details: () => [...investigationKeys.all, 'detail'] as const, detail: (investigationId: string) => [...investigationKeys.details(), investigationId] as const, diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_tags.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_tags.ts new file mode 100644 index 0000000000000..083742f09b685 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_tags.ts @@ -0,0 +1,59 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useQuery } from '@tanstack/react-query'; +import { investigationKeys } from './query_key_factory'; +import { useKibana } from './use_kibana'; + +export interface Response { + isInitialLoading: boolean; + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + data: string[] | undefined; +} + +export function useFetchAllInvestigationTags(): Response { + const { + core: { + http, + notifications: { toasts }, + }, + } = useKibana(); + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ + queryKey: investigationKeys.tags(), + queryFn: async ({ signal }) => { + return await http.get(`/api/observability/investigations/_tags`, { + version: '2023-10-31', + signal, + }); + }, + cacheTime: 600 * 1000, // 10_minutes + staleTime: 0, + refetchOnWindowFocus: false, + retry: false, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.investigateApp.useFetchAllInvestigationTags.errorTitle', { + defaultMessage: 'Something went wrong while fetching the investigation tags', + }), + }); + }, + }); + + return { + data, + isInitialLoading, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_list.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_list.ts index 2423a76e06464..cadd0de89a8e3 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_list.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_list.ts @@ -16,6 +16,8 @@ const DEFAULT_PAGE_SIZE = 25; export interface InvestigationListParams { page?: number; perPage?: number; + search?: string; + filter?: string; } export interface UseFetchInvestigationListResponse { @@ -30,6 +32,8 @@ export interface UseFetchInvestigationListResponse { export function useFetchInvestigationList({ page = 1, perPage = DEFAULT_PAGE_SIZE, + search, + filter, }: InvestigationListParams = {}): UseFetchInvestigationListResponse { const { core: { @@ -42,6 +46,8 @@ export function useFetchInvestigationList({ queryKey: investigationKeys.list({ page, perPage, + search, + filter, }), queryFn: async ({ signal }) => { return await http.get(`/api/observability/investigations`, { @@ -49,12 +55,17 @@ export function useFetchInvestigationList({ query: { ...(page !== undefined && { page }), ...(perPage !== undefined && { perPage }), + ...(!!search && { search }), + ...(!!filter && { filter }), }, signal, }); }, + retry: false, refetchInterval: 60 * 1000, refetchOnWindowFocus: false, + cacheTime: 600 * 1000, // 10 minutes + staleTime: 0, onError: (error: Error) => { toasts.addError(error, { title: i18n.translate('xpack.investigateApp.useFetchInvestigationList.errorTitle', { diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx index ec16e4244d6d1..8e1bc793b545e 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx @@ -9,43 +9,45 @@ import { EuiBadge, EuiBasicTable, EuiBasicTableColumn, + EuiFlexGroup, EuiLink, EuiLoadingSpinner, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { InvestigationResponse } from '@kbn/investigation-shared/src/rest_specs/investigation'; import moment from 'moment'; import React, { useState } from 'react'; import { paths } from '../../../../common/paths'; -import { InvestigationNotFound } from '../../../components/investigation_not_found/investigation_not_found'; import { InvestigationStatusBadge } from '../../../components/investigation_status_badge/investigation_status_badge'; import { useFetchInvestigationList } from '../../../hooks/use_fetch_investigation_list'; import { useKibana } from '../../../hooks/use_kibana'; import { InvestigationListActions } from './investigation_list_actions'; +import { InvestigationsError } from './investigations_error'; +import { SearchBar } from './search_bar/search_bar'; export function InvestigationList() { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); const { core: { http: { basePath }, uiSettings, }, } = useKibana(); - const { data, isLoading, isError } = useFetchInvestigationList({ - page: pageIndex + 1, - perPage: pageSize, - }); const dateFormat = uiSettings.get('dateFormat'); const tz = uiSettings.get('dateFormat:tz'); - if (isLoading) { - return ; - } + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(undefined); + const [status, setStatus] = useState([]); + const [tags, setTags] = useState([]); - if (isError) { - return ; - } + const { data, isLoading, isError } = useFetchInvestigationList({ + page: pageIndex + 1, + perPage: pageSize, + search, + filter: toFilter(status, tags), + }); const investigations = data?.results ?? []; const totalItemCount = data?.total ?? 0; @@ -74,6 +76,19 @@ export function InvestigationList() { }), truncateText: true, }, + { + field: 'tags', + name: i18n.translate('xpack.investigateApp.investigationList.tagsLabel', { + defaultMessage: 'Tags', + }), + render: (value: InvestigationResponse['tags']) => { + return value.map((tag) => ( + + {tag} + + )); + }, + }, { field: 'notes', name: i18n.translate('xpack.investigateApp.investigationList.notesLabel', { @@ -82,32 +97,22 @@ export function InvestigationList() { render: (notes: InvestigationResponse['notes']) => {notes?.length || 0}, }, { - field: 'createdAt', - name: i18n.translate('xpack.investigateApp.investigationList.createdAtLabel', { - defaultMessage: 'Created at', + field: 'updatedAt', + name: i18n.translate('xpack.investigateApp.investigationList.updatedAtLabel', { + defaultMessage: 'Updated at', }), - render: (createdAt: InvestigationResponse['createdAt']) => ( - {moment(createdAt).tz(tz).format(dateFormat)} + render: (updatedAt: InvestigationResponse['updatedAt']) => ( + {moment(updatedAt).tz(tz).format(dateFormat)} ), }, { field: 'status', name: 'Status', - render: (status: InvestigationResponse['status']) => { - return ; - }, - }, - { - field: 'tags', - name: 'Tags', - render: (tags: InvestigationResponse['tags']) => { - return tags.map((tag) => ( - - {tag} - - )); + render: (s: InvestigationResponse['status']) => { + return ; }, }, + { name: 'Actions', render: (investigation: InvestigationResponse) => ( @@ -120,10 +125,24 @@ export function InvestigationList() { pageIndex, pageSize, totalItemCount, - pageSizeOptions: [10, 50], + pageSizeOptions: [10, 25, 50, 100], showPerPageOptions: true, }; + const resultsCount = + pageSize === 0 + ? i18n.translate('xpack.investigateApp.investigationList.allLabel', { + defaultMessage: 'Showing All', + }) + : i18n.translate('xpack.investigateApp.investigationList.showingLabel', { + defaultMessage: 'Showing {startItem}-{endItem} of {totalItemCount}', + values: { + startItem: pageSize * pageIndex + 1, + endItem: pageSize * pageIndex + pageSize, + totalItemCount, + }, + }); + const onTableChange = ({ page }: Criteria) => { if (page) { const { index, size } = page; @@ -133,15 +152,48 @@ export function InvestigationList() { }; return ( - + + setSearch(value)} + onStatusFilterChange={(selected) => setStatus(selected)} + onTagsFilterChange={(selected) => setTags(selected)} + /> + + {isLoading && } + {isError && } + {!isLoading && !isError && ( + <> + {resultsCount} + + + )} + + ); } + +function toFilter(status: string[], tags: string[]) { + const statusFitler = status.map((s) => `investigation.attributes.status:${s}`).join(' OR '); + const tagsFilter = tags.map((tag) => `investigation.attributes.tags:${tag}`).join(' OR '); + + if (statusFitler && tagsFilter) { + return `(${statusFitler}) AND (${tagsFilter})`; + } + if (statusFitler) { + return statusFitler; + } + + if (tagsFilter) { + return tagsFilter; + } +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigations_error.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigations_error.tsx new file mode 100644 index 0000000000000..232dc7a417e93 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigations_error.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +export function InvestigationsError() { + return ( + + {i18n.translate('xpack.investigateApp.InvestigationsNotFound.title', { + defaultMessage: 'Unable to load investigations', + })} + + } + body={ +

+ {i18n.translate('xpack.investigateApp.InvestigationsNotFound.body', { + defaultMessage: + 'There was an error loading the investigations. Contact your administrator for help.', + })} +

+ } + /> + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/search_bar.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/search_bar.tsx new file mode 100644 index 0000000000000..6c89df8532b71 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/search_bar.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { StatusFilter } from './status_filter'; +import { TagsFilter } from './tags_filter'; + +interface Props { + isLoading: boolean; + onSearch: (value: string) => void; + onStatusFilterChange: (status: string[]) => void; + onTagsFilterChange: (tags: string[]) => void; +} + +const SEARCH_LABEL = i18n.translate('xpack.investigateApp.investigationList.searchField.label', { + defaultMessage: 'Search...', +}); + +export function SearchBar({ + onSearch, + onStatusFilterChange, + onTagsFilterChange, + isLoading, +}: Props) { + return ( + + + onSearch(value)} + isLoading={isLoading} + /> + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/status_filter.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/status_filter.tsx new file mode 100644 index 0000000000000..df65845595905 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/status_filter.tsx @@ -0,0 +1,85 @@ +/* + * 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 { + useGeneratedHtmlId, + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiSelectable, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; + +const STATUS_LABEL = i18n.translate('xpack.investigateApp.searchBar.statusFilterButtonLabel', { + defaultMessage: 'Status', +}); + +interface Props { + isLoading: boolean; + onChange: (status: string[]) => void; +} + +export function StatusFilter({ isLoading, onChange }: Props) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const filterStatusPopoverId = useGeneratedHtmlId({ + prefix: 'filterStatusPopover', + }); + + const [items, setItems] = useState>([ + { label: 'triage' }, + { label: 'active' }, + { label: 'mitigated' }, + { label: 'resolved' }, + { label: 'cancelled' }, + ]); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + isSelected={isPopoverOpen} + numFilters={items.length} + hasActiveFilters={!!items.find((item) => item.checked === 'on')} + numActiveFilters={items.filter((item) => item.checked === 'on').length} + > + {STATUS_LABEL} + + ); + return ( + + setIsPopoverOpen(false)} + panelPaddingSize="none" + > + { + setItems(newOptions); + onChange(newOptions.filter((item) => item.checked === 'on').map((item) => item.label)); + }} + isLoading={isLoading} + > + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+
+
+ ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/tags_filter.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/tags_filter.tsx new file mode 100644 index 0000000000000..5a82f84a47fe1 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/search_bar/tags_filter.tsx @@ -0,0 +1,86 @@ +/* + * 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 { + useGeneratedHtmlId, + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiSelectable, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { useFetchAllInvestigationTags } from '../../../../hooks/use_fetch_all_investigation_tags'; + +const TAGS_LABEL = i18n.translate('xpack.investigateApp.searchBar.tagsFilterButtonLabel', { + defaultMessage: 'Tags', +}); + +interface Props { + isLoading: boolean; + onChange: (tags: string[]) => void; +} + +export function TagsFilter({ isLoading, onChange }: Props) { + const { isLoading: isTagsLoading, data: tags } = useFetchAllInvestigationTags(); + const [items, setItems] = useState>([]); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const filterTagsPopoverId = useGeneratedHtmlId({ + prefix: 'filterTagsPopover', + }); + + useEffect(() => { + if (tags) { + setItems(tags.map((tag) => ({ label: tag }))); + } + }, [tags]); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + isSelected={isPopoverOpen} + numFilters={items.length} + hasActiveFilters={!!items.find((item) => item.checked === 'on')} + numActiveFilters={items.filter((item) => item.checked === 'on').length} + > + {TAGS_LABEL} + + ); + return ( + + setIsPopoverOpen(false)} + panelPaddingSize="none" + > + { + setItems(newOptions); + onChange(newOptions.filter((item) => item.checked === 'on').map((item) => item.label)); + }} + isLoading={isLoading || isTagsLoading} + > + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+
+
+ ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts index 1755d283b3763..5d62b745e0a73 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts @@ -13,6 +13,7 @@ import { deleteInvestigationNoteParamsSchema, deleteInvestigationParamsSchema, findInvestigationsParamsSchema, + getAllInvestigationTagsParamsSchema, getInvestigationItemsParamsSchema, getInvestigationNotesParamsSchema, getInvestigationParamsSchema, @@ -27,14 +28,15 @@ import { deleteInvestigation } from '../services/delete_investigation'; import { deleteInvestigationItem } from '../services/delete_investigation_item'; import { deleteInvestigationNote } from '../services/delete_investigation_note'; import { findInvestigations } from '../services/find_investigations'; +import { getAllInvestigationTags } from '../services/get_all_investigation_tags'; import { getInvestigation } from '../services/get_investigation'; +import { getInvestigationItems } from '../services/get_investigation_items'; import { getInvestigationNotes } from '../services/get_investigation_notes'; import { investigationRepositoryFactory } from '../services/investigation_repository'; -import { createInvestigateAppServerRoute } from './create_investigate_app_server_route'; -import { getInvestigationItems } from '../services/get_investigation_items'; -import { updateInvestigationNote } from '../services/update_investigation_note'; -import { updateInvestigationItem } from '../services/update_investigation_item'; import { updateInvestigation } from '../services/update_investigation'; +import { updateInvestigationItem } from '../services/update_investigation_item'; +import { updateInvestigationNote } from '../services/update_investigation_note'; +import { createInvestigateAppServerRoute } from './create_investigate_app_server_route'; const createInvestigationRoute = createInvestigateAppServerRoute({ endpoint: 'POST /api/observability/investigations 2023-10-31', @@ -138,6 +140,20 @@ const createInvestigationNoteRoute = createInvestigateAppServerRoute({ }, }); +const getAllInvestigationTagsRoute = createInvestigateAppServerRoute({ + endpoint: 'GET /api/observability/investigations/_tags 2023-10-31', + options: { + tags: [], + }, + params: getAllInvestigationTagsParamsSchema, + handler: async ({ params, context, request, logger }) => { + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); + + return await getAllInvestigationTags(repository); + }, +}); + const getInvestigationNotesRoute = createInvestigateAppServerRoute({ endpoint: 'GET /api/observability/investigations/{investigationId}/notes 2023-10-31', options: { @@ -296,6 +312,7 @@ export function getGlobalInvestigateAppServerRouteRepository() { ...deleteInvestigationItemRoute, ...updateInvestigationItemRoute, ...getInvestigationItemsRoute, + ...getAllInvestigationTagsRoute, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/saved_objects/investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/saved_objects/investigation.ts index eeb937fb16cfa..20ed328689050 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/saved_objects/investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/saved_objects/investigation.ts @@ -27,6 +27,7 @@ export const investigation: SavedObjectsType = { }, }, status: { type: 'keyword' }, + tags: { type: 'keyword' }, }, }, management: { diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts index 2aed0baed8923..eb8277d7d6f83 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts @@ -18,9 +18,11 @@ export async function createInvestigation( throw new Error(`Investigation [id=${params.id}] already exists`); } + const now = Date.now(); const investigation: Investigation = { ...params, - createdAt: Date.now(), + updatedAt: now, + createdAt: now, createdBy: user.username, status: 'triage', notes: [], diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts index 1ed6f1289280b..cf77887aab0a3 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts @@ -20,10 +20,12 @@ export async function createInvestigationItem( ): Promise { const investigation = await repository.findById(investigationId); + const now = Date.now(); const investigationItem = { id: v4(), createdBy: user.username, - createdAt: Date.now(), + createdAt: now, + updatedAt: now, ...params, }; investigation.items.push(investigationItem); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts index 9ce727c0f2e08..2f74123b6f269 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts @@ -20,11 +20,13 @@ export async function createInvestigationNote( ): Promise { const investigation = await repository.findById(investigationId); + const now = Date.now(); const investigationNote = { id: v4(), content: params.content, createdBy: user.username, - createdAt: Date.now(), + updatedAt: now, + createdAt: now, }; investigation.notes.push(investigationNote); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/find_investigations.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/find_investigations.ts index 7530b3c768610..c3d4606645764 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/find_investigations.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/find_investigations.ts @@ -10,14 +10,18 @@ import { FindInvestigationsResponse, findInvestigationsResponseSchema, } from '@kbn/investigation-shared'; -import { InvestigationRepository } from './investigation_repository'; +import { InvestigationRepository, Search } from './investigation_repository'; import { InvestigationStatus } from '../models/investigation'; export async function findInvestigations( params: FindInvestigationsParams, repository: InvestigationRepository ): Promise { - const investigations = await repository.search(toFilter(params), toPagination(params)); + const investigations = await repository.search({ + search: toSearch(params), + filter: toFilter(params), + pagination: toPagination(params), + }); return findInvestigationsResponseSchema.parse(investigations); } @@ -26,16 +30,28 @@ function toPagination(params: FindInvestigationsParams) { const DEFAULT_PER_PAGE = 10; const DEFAULT_PAGE = 1; return { - page: params?.page ? parseInt(params.page, 10) : DEFAULT_PAGE, - perPage: params?.perPage ? parseInt(params.perPage, 10) : DEFAULT_PER_PAGE, + page: params?.page && params.page >= 1 ? params.page : DEFAULT_PAGE, + perPage: + params?.perPage && params.perPage > 0 && params.perPage <= 100 + ? params.perPage + : DEFAULT_PER_PAGE, }; } -function toFilter(params: FindInvestigationsParams) { +function toSearch(params: FindInvestigationsParams): Search | undefined { + if (params?.search) { + return { search: params.search }; + } +} + +function toFilter(params: FindInvestigationsParams): string | undefined { if (params?.alertId) { const activeStatus: InvestigationStatus = 'active'; const triageStatus: InvestigationStatus = 'triage'; return `investigation.attributes.origin.id:(${params.alertId}) AND (investigation.attributes.status: ${activeStatus} OR investigation.attributes.status: ${triageStatus})`; } - return ''; + + if (params?.filter) { + return params.filter; + } } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_tags.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_tags.ts new file mode 100644 index 0000000000000..48b1624a434d7 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_tags.ts @@ -0,0 +1,19 @@ +/* + * 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 { + GetAllInvestigationTagsResponse, + getAllInvestigationTagsResponseSchema, +} from '@kbn/investigation-shared'; +import { InvestigationRepository } from './investigation_repository'; + +export async function getAllInvestigationTags( + repository: InvestigationRepository +): Promise { + const tags = await repository.findAllTags(); + return getAllInvestigationTagsResponseSchema.parse(tags); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts index 73c9136cb3673..b7de0b96949da 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts @@ -11,11 +11,23 @@ import { Investigation, StoredInvestigation } from '../models/investigation'; import { Paginated, Pagination } from '../models/pagination'; import { SO_INVESTIGATION_TYPE } from '../saved_objects/investigation'; +export interface Search { + search: string; +} export interface InvestigationRepository { save(investigation: Investigation): Promise; findById(id: string): Promise; deleteById(id: string): Promise; - search(filter: string, pagination: Pagination): Promise>; + search({ + search, + filter, + pagination, + }: { + search?: Search; + filter?: string; + pagination: Pagination; + }): Promise>; + findAllTags(): Promise; } export function investigationRepositoryFactory({ @@ -89,12 +101,15 @@ export function investigationRepositoryFactory({ await soClient.delete(SO_INVESTIGATION_TYPE, response.saved_objects[0].id); }, - async search(filter: string, pagination: Pagination): Promise> { + async search({ search, filter, pagination }): Promise> { const response = await soClient.find({ type: SO_INVESTIGATION_TYPE, page: pagination.page, perPage: pagination.perPage, - filter, + sortField: 'updated_at', + sortOrder: 'desc', + ...(filter && { filter }), + ...(search && { search: search.search }), }); return { @@ -106,5 +121,25 @@ export function investigationRepositoryFactory({ .filter((investigation) => investigation !== undefined) as Investigation[], }; }, + + async findAllTags(): Promise { + interface AggsTagsTerms { + tags: { buckets: [{ key: string }] }; + } + + const response = await soClient.find({ + type: SO_INVESTIGATION_TYPE, + aggs: { + tags: { + terms: { + field: 'investigation.attributes.tags', + size: 10000, + }, + }, + }, + }); + + return response.aggregations?.tags?.buckets.map((bucket) => bucket.key) ?? []; + }, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts index dda5ae34f2a71..fc4c5a2c0b1fc 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts @@ -27,7 +27,7 @@ export async function updateInvestigationNote( investigation.notes = investigation.notes.filter((currNote) => { if (currNote.id === noteId) { - currNote.content = params.content; + currNote = Object.assign(currNote, { content: params.content, updatedAt: Date.now() }); } return currNote; From 1199a5ce66ea04c568fb36dfd88aa5a4090a3974 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 17 Sep 2024 04:21:26 +1000 Subject: [PATCH 16/16] skip failing test suite (#193061) --- .../test_suites/common/alerting/summary_actions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts index 2726af585e28f..ec63653bef7c7 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts @@ -39,7 +39,8 @@ export default function ({ getService }: FtrProviderContext) { const alertingApi = getService('alertingApi'); let roleAdmin: RoleCredentials; - describe('Summary actions', function () { + // Failing: See https://github.com/elastic/kibana/issues/193061 + describe.skip('Summary actions', function () { const RULE_TYPE_ID = '.es-query'; const ALERT_ACTION_INDEX = 'alert-action-es-query'; const ALERT_INDEX = '.alerts-stack.alerts-default';