From 113181b6df1f04d6712790a52c48952f608510ba Mon Sep 17 00:00:00 2001
From: Patrick Mueller
Date: Mon, 6 Apr 2020 10:24:02 -0400
Subject: [PATCH 01/27] [Alerting] write event log entries for alert execution
and it's actions (#61706)
resolves https://github.com/elastic/kibana/issues/55636
Writes eventLog events for alert executions, and the actions executed from
that alert execution.
---
x-pack/plugins/alerting/kibana.json | 2 +-
x-pack/plugins/alerting/server/plugin.test.ts | 4 +
x-pack/plugins/alerting/server/plugin.ts | 17 ++
.../create_execution_handler.test.ts | 33 ++++
.../task_runner/create_execution_handler.ts | 43 ++++-
.../server/task_runner/task_runner.test.ts | 163 ++++++++++++++++++
.../server/task_runner/task_runner.ts | 113 ++++++++++--
.../task_runner/task_runner_factory.test.ts | 2 +
.../server/task_runner/task_runner_factory.ts | 2 +
.../plugins/event_log/generated/mappings.json | 14 ++
x-pack/plugins/event_log/generated/schemas.ts | 7 +-
x-pack/plugins/event_log/scripts/mappings.js | 10 ++
12 files changed, 382 insertions(+), 28 deletions(-)
diff --git a/x-pack/plugins/alerting/kibana.json b/x-pack/plugins/alerting/kibana.json
index 02514511e756..59c4bb2221b0 100644
--- a/x-pack/plugins/alerting/kibana.json
+++ b/x-pack/plugins/alerting/kibana.json
@@ -5,6 +5,6 @@
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "alerting"],
- "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions"],
+ "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions", "eventLog"],
"optionalPlugins": ["usageCollection", "spaces", "security"]
}
diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts
index ec0ed4b76120..74a1f2349180 100644
--- a/x-pack/plugins/alerting/server/plugin.test.ts
+++ b/x-pack/plugins/alerting/server/plugin.test.ts
@@ -9,6 +9,7 @@ import { coreMock } from '../../../../src/core/server/mocks';
import { licensingMock } from '../../../plugins/licensing/server/mocks';
import { encryptedSavedObjectsMock } from '../../../plugins/encrypted_saved_objects/server/mocks';
import { taskManagerMock } from '../../task_manager/server/mocks';
+import { eventLogServiceMock } from '../../event_log/server/event_log_service.mock';
describe('Alerting Plugin', () => {
describe('setup()', () => {
@@ -30,6 +31,7 @@ describe('Alerting Plugin', () => {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
+ eventLog: eventLogServiceMock.create(),
} as any
);
@@ -67,6 +69,7 @@ describe('Alerting Plugin', () => {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
+ eventLog: eventLogServiceMock.create(),
} as any
);
@@ -109,6 +112,7 @@ describe('Alerting Plugin', () => {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
+ eventLog: eventLogServiceMock.create(),
} as any
);
diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts
index b0d06d4aeeb7..90e274df3a5e 100644
--- a/x-pack/plugins/alerting/server/plugin.ts
+++ b/x-pack/plugins/alerting/server/plugin.ts
@@ -56,6 +56,15 @@ import {
import { Services } from './types';
import { registerAlertsUsageCollector } from './usage';
import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task';
+import { IEventLogger, IEventLogService } from '../../event_log/server';
+
+const EVENT_LOG_PROVIDER = 'alerting';
+export const EVENT_LOG_ACTIONS = {
+ execute: 'execute',
+ executeAction: 'execute-action',
+ newInstance: 'new-instance',
+ resolvedInstance: 'resolved-instance',
+};
export interface PluginSetupContract {
registerType: AlertTypeRegistry['register'];
@@ -73,6 +82,7 @@ export interface AlertingPluginsSetup {
licensing: LicensingPluginSetup;
spaces?: SpacesPluginSetup;
usageCollection?: UsageCollectionSetup;
+ eventLog: IEventLogService;
}
export interface AlertingPluginsStart {
actions: ActionsPluginStartContract;
@@ -93,6 +103,7 @@ export class AlertingPlugin {
private readonly alertsClientFactory: AlertsClientFactory;
private readonly telemetryLogger: Logger;
private readonly kibanaIndex: Promise;
+ private eventLogger?: IEventLogger;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get('plugins', 'alerting');
@@ -133,6 +144,11 @@ export class AlertingPlugin {
]),
});
+ plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS));
+ this.eventLogger = plugins.eventLog.getLogger({
+ event: { provider: EVENT_LOG_PROVIDER },
+ });
+
const alertTypeRegistry = new AlertTypeRegistry({
taskManager: plugins.taskManager,
taskRunnerFactory: this.taskRunnerFactory,
@@ -211,6 +227,7 @@ export class AlertingPlugin {
actionsPlugin: plugins.actions,
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
getBasePath: this.getBasePath,
+ eventLogger: this.eventLogger!,
});
scheduleAlertingTelemetry(this.telemetryLogger, plugins.taskManager);
diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts
index 5bd8382f0a4b..8d037a1ecee9 100644
--- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts
@@ -8,6 +8,7 @@ import { AlertType } from '../types';
import { createExecutionHandler } from './create_execution_handler';
import { loggingServiceMock } from '../../../../../src/core/server/mocks';
import { actionsMock } from '../../../actions/server/mocks';
+import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
const alertType: AlertType = {
id: 'test',
@@ -31,6 +32,7 @@ const createExecutionHandlerParams = {
getBasePath: jest.fn().mockReturnValue(undefined),
alertType,
logger: loggingServiceMock.create().get(),
+ eventLogger: eventLoggerMock.create(),
actions: [
{
id: '1',
@@ -75,6 +77,37 @@ test('calls actionsPlugin.execute per selected action', async () => {
},
]
`);
+
+ const eventLogger = createExecutionHandlerParams.eventLogger;
+ expect(eventLogger.logEvent).toHaveBeenCalledTimes(1);
+ expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ Object {
+ "event": Object {
+ "action": "execute-action",
+ },
+ "kibana": Object {
+ "alerting": Object {
+ "instance_id": "2",
+ },
+ "namespace": "default",
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ Object {
+ "id": "1",
+ "type": "action",
+ },
+ ],
+ },
+ "message": "alert: test:1: 'name-of-alert' instanceId: '2' scheduled actionGroup: 'default' action: test:1",
+ },
+ ],
+ ]
+ `);
});
test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => {
diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
index 5d14f4adc709..de06c8bbb374 100644
--- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
+++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
@@ -9,6 +9,8 @@ import { AlertAction, State, Context, AlertType } from '../types';
import { Logger } from '../../../../../src/core/server';
import { transformActionParams } from './transform_action_params';
import { PluginStartContract as ActionsPluginStartContract } from '../../../../plugins/actions/server';
+import { IEventLogger, IEvent } from '../../../event_log/server';
+import { EVENT_LOG_ACTIONS } from '../plugin';
interface CreateExecutionHandlerOptions {
alertId: string;
@@ -20,6 +22,7 @@ interface CreateExecutionHandlerOptions {
apiKey: string | null;
alertType: AlertType;
logger: Logger;
+ eventLogger: IEventLogger;
}
interface ExecutionHandlerOptions {
@@ -39,6 +42,7 @@ export function createExecutionHandler({
spaceId,
apiKey,
alertType,
+ eventLogger,
}: CreateExecutionHandlerOptions) {
const alertTypeActionGroups = new Set(pluck(alertType.actionGroups, 'id'));
return async ({ actionGroup, context, state, alertInstanceId }: ExecutionHandlerOptions) => {
@@ -63,19 +67,42 @@ export function createExecutionHandler({
}),
};
});
+
+ const alertLabel = `${alertType.id}:${alertId}: '${alertName}'`;
+
for (const action of actions) {
- if (actionsPlugin.isActionTypeEnabled(action.actionTypeId)) {
- await actionsPlugin.execute({
- id: action.id,
- params: action.params,
- spaceId,
- apiKey,
- });
- } else {
+ if (!actionsPlugin.isActionTypeEnabled(action.actionTypeId)) {
logger.warn(
`Alert "${alertId}" skipped scheduling action "${action.id}" because it is disabled`
);
+ continue;
}
+
+ // TODO would be nice to add the action name here, but it's not available
+ const actionLabel = `${action.actionTypeId}:${action.id}`;
+ await actionsPlugin.execute({
+ id: action.id,
+ params: action.params,
+ spaceId,
+ apiKey,
+ });
+
+ const event: IEvent = {
+ event: { action: EVENT_LOG_ACTIONS.executeAction },
+ kibana: {
+ alerting: {
+ instance_id: alertInstanceId,
+ },
+ namespace: spaceId,
+ saved_objects: [
+ { type: 'alert', id: alertId },
+ { type: 'action', id: action.id },
+ ],
+ },
+ };
+
+ event.message = `alert: ${alertLabel} instanceId: '${alertInstanceId}' scheduled actionGroup: '${actionGroup}' action: ${actionLabel}`;
+ eventLogger.logEvent(event);
}
};
}
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
index 5f4669f64f09..520f8d5c99b1 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
@@ -14,6 +14,8 @@ import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_o
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
import { PluginStartContract as ActionsPluginStart } from '../../../actions/server';
import { actionsMock } from '../../../actions/server/mocks';
+import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
+import { IEventLogger } from '../../../event_log/server';
const alertType = {
id: 'test',
@@ -59,6 +61,7 @@ describe('Task Runner', () => {
const taskRunnerFactoryInitializerParams: jest.Mocked & {
actionsPlugin: jest.Mocked;
+ eventLogger: jest.Mocked;
} = {
getServices: jest.fn().mockReturnValue(services),
actionsPlugin: actionsMock.createStart(),
@@ -66,6 +69,7 @@ describe('Task Runner', () => {
logger: loggingServiceMock.create().get(),
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
getBasePath: jest.fn().mockReturnValue(undefined),
+ eventLogger: eventLoggerMock.create(),
};
const mockedAlertTypeSavedObject = {
@@ -156,6 +160,26 @@ describe('Task Runner', () => {
expect(call.services.alertInstanceFactory).toBeTruthy();
expect(call.services.callCluster).toBeTruthy();
expect(call.services).toBeTruthy();
+
+ const eventLogger = taskRunnerFactoryInitializerParams.eventLogger;
+ expect(eventLogger.logEvent).toHaveBeenCalledTimes(1);
+ expect(eventLogger.logEvent.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Object {
+ "event": Object {
+ "action": "execute",
+ },
+ "kibana": Object {
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ ],
+ },
+ "message": "alert executed: test:1: 'alert-name'",
+ }
+ `);
});
test('actionsPlugin.execute is called per alert instance that is scheduled', async () => {
@@ -194,6 +218,74 @@ describe('Task Runner', () => {
},
]
`);
+
+ const eventLogger = taskRunnerFactoryInitializerParams.eventLogger;
+ expect(eventLogger.logEvent).toHaveBeenCalledTimes(3);
+ expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ Object {
+ "event": Object {
+ "action": "execute",
+ },
+ "kibana": Object {
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ ],
+ },
+ "message": "alert executed: test:1: 'alert-name'",
+ },
+ ],
+ Array [
+ Object {
+ "event": Object {
+ "action": "new-instance",
+ },
+ "kibana": Object {
+ "alerting": Object {
+ "instance_id": "1",
+ },
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ ],
+ },
+ "message": "test:1: 'alert-name' created new instance: '1'",
+ },
+ ],
+ Array [
+ Object {
+ "event": Object {
+ "action": "execute-action",
+ },
+ "kibana": Object {
+ "alerting": Object {
+ "instance_id": "1",
+ },
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ Object {
+ "id": "1",
+ "type": "action",
+ },
+ ],
+ },
+ "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: undefined:1",
+ },
+ ],
+ ]
+ `);
});
test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => {
@@ -241,6 +333,50 @@ describe('Task Runner', () => {
},
}
`);
+
+ const eventLogger = taskRunnerFactoryInitializerParams.eventLogger;
+ expect(eventLogger.logEvent).toHaveBeenCalledTimes(2);
+ expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ Object {
+ "event": Object {
+ "action": "execute",
+ },
+ "kibana": Object {
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ ],
+ },
+ "message": "alert executed: test:1: 'alert-name'",
+ },
+ ],
+ Array [
+ Object {
+ "event": Object {
+ "action": "resolved-instance",
+ },
+ "kibana": Object {
+ "alerting": Object {
+ "instance_id": "2",
+ },
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ ],
+ },
+ "message": "test:1: 'alert-name' resolved instance: '2'",
+ },
+ ],
+ ]
+ `);
});
test('validates params before executing the alert type', async () => {
@@ -410,6 +546,33 @@ describe('Task Runner', () => {
},
}
`);
+
+ const eventLogger = taskRunnerFactoryInitializerParams.eventLogger;
+ expect(eventLogger.logEvent).toHaveBeenCalledTimes(1);
+ expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ Object {
+ "error": Object {
+ "message": "OMG",
+ },
+ "event": Object {
+ "action": "execute",
+ },
+ "kibana": Object {
+ "namespace": undefined,
+ "saved_objects": Array [
+ Object {
+ "id": "1",
+ "type": "alert",
+ },
+ ],
+ },
+ "message": "alert execution failure: test:1: 'alert-name'",
+ },
+ ],
+ ]
+ `);
});
test('recovers gracefully when the Alert Task Runner throws an exception when fetching the encrypted attributes', async () => {
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts
index 42768a80a4cc..2ba56396279e 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { pick, mapValues, omit } from 'lodash';
+import { pick, mapValues, omit, without } from 'lodash';
import { Logger, SavedObject } from '../../../../../src/core/server';
import { TaskRunnerContext } from './task_runner_factory';
import { ConcreteTaskInstance } from '../../../../plugins/task_manager/server';
@@ -24,6 +24,8 @@ import {
import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type';
import { taskInstanceToAlertTaskInstance } from './alert_task_instance';
import { AlertInstances } from '../alert_instance/alert_instance';
+import { EVENT_LOG_ACTIONS } from '../plugin';
+import { IEvent, IEventLogger } from '../../../event_log/server';
const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' };
@@ -124,6 +126,7 @@ export class TaskRunner {
actions: actionsWithIds,
spaceId,
alertType: this.alertType,
+ eventLogger: this.context.eventLogger,
});
}
@@ -165,29 +168,61 @@ export class TaskRunner {
rawAlertInstance => new AlertInstance(rawAlertInstance)
);
- const updatedAlertTypeState = await this.alertType.executor({
- alertId,
- services: {
- ...services,
- alertInstanceFactory: createAlertInstanceFactory(alertInstances),
- },
- params,
- state: alertTypeState,
- startedAt: this.taskInstance.startedAt!,
- previousStartedAt: previousStartedAt ? new Date(previousStartedAt) : null,
- spaceId,
- namespace,
- name,
- tags,
- createdBy,
- updatedBy,
- });
+ const originalAlertInstanceIds = Object.keys(alertInstances);
+ const eventLogger = this.context.eventLogger;
+ const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`;
+ const event: IEvent = {
+ event: { action: EVENT_LOG_ACTIONS.execute },
+ kibana: { namespace, saved_objects: [{ type: 'alert', id: alertId }] },
+ };
+ eventLogger.startTiming(event);
+
+ let updatedAlertTypeState: void | Record;
+ try {
+ updatedAlertTypeState = await this.alertType.executor({
+ alertId,
+ services: {
+ ...services,
+ alertInstanceFactory: createAlertInstanceFactory(alertInstances),
+ },
+ params,
+ state: alertTypeState,
+ startedAt: this.taskInstance.startedAt!,
+ previousStartedAt: previousStartedAt ? new Date(previousStartedAt) : null,
+ spaceId,
+ namespace,
+ name,
+ tags,
+ createdBy,
+ updatedBy,
+ });
+ } catch (err) {
+ eventLogger.stopTiming(event);
+ event.message = `alert execution failure: ${alertLabel}`;
+ event.error = event.error || {};
+ event.error.message = err.message;
+ eventLogger.logEvent(event);
+ throw err;
+ }
+
+ eventLogger.stopTiming(event);
+ event.message = `alert executed: ${alertLabel}`;
+ eventLogger.logEvent(event);
// Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object
const instancesWithScheduledActions = pick(
alertInstances,
(alertInstance: AlertInstance) => alertInstance.hasScheduledActions()
);
+ const currentAlertInstanceIds = Object.keys(instancesWithScheduledActions);
+ generateNewAndResolvedInstanceEvents({
+ eventLogger,
+ originalAlertInstanceIds,
+ currentAlertInstanceIds,
+ alertId,
+ alertLabel,
+ namespace,
+ });
if (!muteAll) {
const enabledAlertInstances = omit(
@@ -313,6 +348,48 @@ export class TaskRunner {
}
}
+interface GenerateNewAndResolvedInstanceEventsParams {
+ eventLogger: IEventLogger;
+ originalAlertInstanceIds: string[];
+ currentAlertInstanceIds: string[];
+ alertId: string;
+ alertLabel: string;
+ namespace: string | undefined;
+}
+
+function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInstanceEventsParams) {
+ const { currentAlertInstanceIds, originalAlertInstanceIds } = params;
+ const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds);
+ const resolvedIds = without(originalAlertInstanceIds, ...currentAlertInstanceIds);
+
+ for (const id of newIds) {
+ const message = `${params.alertLabel} created new instance: '${id}'`;
+ logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message);
+ }
+
+ for (const id of resolvedIds) {
+ const message = `${params.alertLabel} resolved instance: '${id}'`;
+ logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message);
+ }
+
+ function logInstanceEvent(id: string, action: string, message: string) {
+ const event: IEvent = {
+ event: {
+ action,
+ },
+ kibana: {
+ namespace: params.namespace,
+ alerting: {
+ instance_id: id,
+ },
+ saved_objects: [{ type: 'alert', id: params.alertId }],
+ },
+ message,
+ };
+ params.eventLogger.logEvent(event);
+ }
+}
+
/**
* If an error is thrown, wrap it in an AlertTaskRunResult
* so that we can treat each field independantly
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts
index fc34cacba281..1d220f97f127 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts
@@ -10,6 +10,7 @@ import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory';
import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks';
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
import { actionsMock } from '../../../actions/server/mocks';
+import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
const alertType = {
id: 'test',
@@ -62,6 +63,7 @@ describe('Task Runner Factory', () => {
logger: loggingServiceMock.create().get(),
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
getBasePath: jest.fn().mockReturnValue(undefined),
+ eventLogger: eventLoggerMock.create(),
};
beforeEach(() => {
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts
index 3bad4e475ff4..b58db8c74f7b 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts
@@ -14,11 +14,13 @@ import {
SpaceIdToNamespaceFunction,
} from '../types';
import { TaskRunner } from './task_runner';
+import { IEventLogger } from '../../../event_log/server';
export interface TaskRunnerContext {
logger: Logger;
getServices: GetServicesFunction;
actionsPlugin: ActionsPluginStartContract;
+ eventLogger: IEventLogger;
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart;
spaceIdToNamespace: SpaceIdToNamespaceFunction;
getBasePath: GetBasePathFunction;
diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json
index d0e4652c2828..ab1b4096d17f 100644
--- a/x-pack/plugins/event_log/generated/mappings.json
+++ b/x-pack/plugins/event_log/generated/mappings.json
@@ -55,6 +55,12 @@
"user": {
"properties": {
"name": {
+ "fields": {
+ "text": {
+ "norms": false,
+ "type": "text"
+ }
+ },
"ignore_above": 1024,
"type": "keyword"
}
@@ -70,6 +76,14 @@
"type": "keyword",
"ignore_above": 1024
},
+ "alerting": {
+ "properties": {
+ "instance_id": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
"saved_objects": {
"properties": {
"store": {
diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts
index a040ede891bf..b731093b33b0 100644
--- a/x-pack/plugins/event_log/generated/schemas.ts
+++ b/x-pack/plugins/event_log/generated/schemas.ts
@@ -18,7 +18,7 @@ type DeepPartial = {
[P in keyof T]?: T[P] extends Array ? Array> : DeepPartial;
};
-export const ECS_VERSION = '1.3.1';
+export const ECS_VERSION = '1.5.0';
// types and config-schema describing the es structures
export type IValidatedEvent = TypeOf;
@@ -57,6 +57,11 @@ export const EventSchema = schema.maybe(
schema.object({
server_uuid: ecsString(),
namespace: ecsString(),
+ alerting: schema.maybe(
+ schema.object({
+ instance_id: ecsString(),
+ })
+ ),
saved_objects: schema.maybe(
schema.arrayOf(
schema.object({
diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js
index 43fd0c78183a..9e721b06ec33 100644
--- a/x-pack/plugins/event_log/scripts/mappings.js
+++ b/x-pack/plugins/event_log/scripts/mappings.js
@@ -11,6 +11,15 @@ exports.EcsKibanaExtensionsMappings = {
type: 'keyword',
ignore_above: 1024,
},
+ // alerting specific fields
+ alerting: {
+ properties: {
+ instance_id: {
+ type: 'keyword',
+ ignore_above: 1024,
+ },
+ },
+ },
// relevant kibana space
namespace: {
type: 'keyword',
@@ -53,6 +62,7 @@ exports.EcsEventLogProperties = [
'user.name',
'kibana.server_uuid',
'kibana.namespace',
+ 'kibana.alerting.instance_id',
'kibana.saved_objects.store',
'kibana.saved_objects.id',
'kibana.saved_objects.name',
From fa661c0f9a39df75ca7a865854b7e099de7c4932 Mon Sep 17 00:00:00 2001
From: Alexey Antonov
Date: Mon, 6 Apr 2020 17:51:27 +0300
Subject: [PATCH 02/27] match_all query disappears when typed into Lucene query
bar (#62194)
* match_all query disappears when typed into Lucene query bar
Closes: #52115
* add migrations for searh savedobject type
Co-authored-by: Elastic Machine
---
.../kibana/migrations/migrations.js | 6 +-
.../public/dashboard/migrations/index.ts | 1 +
.../migrate_match_all_query.test.ts | 52 +++++++++++++++++
.../migrations/migrate_match_all_query.ts | 56 +++++++++++++++++++
.../data/public/query/lib/to_user.test.ts | 14 ++---
src/plugins/data/public/query/lib/to_user.ts | 3 -
.../saved_objects/search_migrations.test.ts | 32 +++++++++++
.../server/saved_objects/search_migrations.ts | 36 ++++++++++++
.../visualization_migrations.test.ts | 26 +++++++++
.../saved_objects/visualization_migrations.ts | 37 +++++++++++-
10 files changed, 251 insertions(+), 12 deletions(-)
create mode 100644 src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.test.ts
create mode 100644 src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.ts
diff --git a/src/legacy/core_plugins/kibana/migrations/migrations.js b/src/legacy/core_plugins/kibana/migrations/migrations.js
index d37887c640b9..029dbde555a4 100644
--- a/src/legacy/core_plugins/kibana/migrations/migrations.js
+++ b/src/legacy/core_plugins/kibana/migrations/migrations.js
@@ -18,7 +18,10 @@
*/
import { get } from 'lodash';
-import { migrations730 as dashboardMigrations730 } from '../public/dashboard/migrations';
+import {
+ migrateMatchAllQuery,
+ migrations730 as dashboardMigrations730,
+} from '../public/dashboard/migrations';
function migrateIndexPattern(doc) {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
@@ -60,6 +63,7 @@ function migrateIndexPattern(doc) {
export const migrations = {
dashboard: {
+ '6.7.2': migrateMatchAllQuery,
'7.0.0': doc => {
// Set new "references" attribute
doc.references = doc.references || [];
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/index.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/index.ts
index da2542e854c3..f333ce97d120 100644
--- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/index.ts
+++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/index.ts
@@ -18,3 +18,4 @@
*/
export { migrations730 } from './migrations_730';
+export { migrateMatchAllQuery } from './migrate_match_all_query';
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.test.ts
new file mode 100644
index 000000000000..8a91c422eed3
--- /dev/null
+++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.test.ts
@@ -0,0 +1,52 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { migrateMatchAllQuery } from './migrate_match_all_query';
+import { SavedObjectMigrationContext, SavedObjectMigrationFn } from 'kibana/server';
+
+const savedObjectMigrationContext = (null as unknown) as SavedObjectMigrationContext;
+
+describe('migrate match_all query', () => {
+ test('should migrate obsolete match_all query', () => {
+ const migratedDoc = migrateMatchAllQuery(
+ {
+ attributes: {
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: JSON.stringify({
+ query: {
+ match_all: {},
+ },
+ }),
+ },
+ },
+ } as Parameters[0],
+ savedObjectMigrationContext
+ );
+
+ const migratedSearchSource = JSON.parse(
+ migratedDoc.attributes.kibanaSavedObjectMeta.searchSourceJSON
+ );
+
+ expect(migratedSearchSource).toEqual({
+ query: {
+ query: '',
+ language: 'kuery',
+ },
+ });
+ });
+});
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.ts
new file mode 100644
index 000000000000..707aae9e5d4a
--- /dev/null
+++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_match_all_query.ts
@@ -0,0 +1,56 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SavedObjectMigrationFn } from 'kibana/server';
+import { get } from 'lodash';
+import { DEFAULT_QUERY_LANGUAGE } from '../../../../../../plugins/data/common';
+
+export const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
+ const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
+
+ if (searchSourceJSON) {
+ let searchSource: any;
+
+ try {
+ searchSource = JSON.parse(searchSourceJSON);
+ } catch (e) {
+ // Let it go, the data is invalid and we'll leave it as is
+ }
+
+ if (searchSource.query?.match_all) {
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: JSON.stringify({
+ ...searchSource,
+ query: {
+ query: '',
+ language: DEFAULT_QUERY_LANGUAGE,
+ },
+ }),
+ },
+ },
+ };
+ }
+ }
+
+ return doc;
+};
diff --git a/src/plugins/data/public/query/lib/to_user.test.ts b/src/plugins/data/public/query/lib/to_user.test.ts
index d13afa251ecb..74373ca0d7de 100644
--- a/src/plugins/data/public/query/lib/to_user.test.ts
+++ b/src/plugins/data/public/query/lib/to_user.test.ts
@@ -19,27 +19,27 @@
import { toUser } from '../';
-describe('user input helpers', function() {
- describe('model presentation formatter', function() {
- it('should present objects as strings', function() {
+describe('user input helpers', () => {
+ describe('model presentation formatter', () => {
+ test('should present objects as strings', () => {
expect(toUser({ foo: 'bar' })).toBe('{"foo":"bar"}');
});
- it('should present query_string queries as strings', function() {
+ test('should present query_string queries as strings', () => {
expect(toUser({ query_string: { query: 'lucene query string' } })).toBe(
'lucene query string'
);
});
- it('should present query_string queries without a query as an empty string', function() {
+ test('should present query_string queries without a query as an empty string', () => {
expect(toUser({ query_string: {} })).toBe('');
});
- it('should present string as strings', function() {
+ test('should present string as strings', () => {
expect(toUser('foo')).toBe('foo');
});
- it('should present numbers as strings', function() {
+ test('should present numbers as strings', () => {
expect(toUser(400)).toBe('400');
});
});
diff --git a/src/plugins/data/public/query/lib/to_user.ts b/src/plugins/data/public/query/lib/to_user.ts
index 1fdb2d8ed03d..1a364534d93f 100644
--- a/src/plugins/data/public/query/lib/to_user.ts
+++ b/src/plugins/data/public/query/lib/to_user.ts
@@ -27,9 +27,6 @@ export function toUser(text: { [key: string]: any } | string | number): string {
return '';
}
if (typeof text === 'object') {
- if (text.match_all) {
- return '';
- }
if (text.query_string) {
return toUser(text.query_string.query);
}
diff --git a/src/plugins/data/server/saved_objects/search_migrations.test.ts b/src/plugins/data/server/saved_objects/search_migrations.test.ts
index 7fdf2e14aefe..f9b4af7d6d2b 100644
--- a/src/plugins/data/server/saved_objects/search_migrations.test.ts
+++ b/src/plugins/data/server/saved_objects/search_migrations.test.ts
@@ -23,6 +23,38 @@ import { searchSavedObjectTypeMigrations } from './search_migrations';
const savedObjectMigrationContext = (null as unknown) as SavedObjectMigrationContext;
describe('migration search', () => {
+ describe('6.7.2', () => {
+ const migrationFn = searchSavedObjectTypeMigrations['6.7.2'];
+
+ it('should migrate obsolete match_all query', () => {
+ const migratedDoc = migrationFn(
+ {
+ type: 'search',
+ attributes: {
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: JSON.stringify({
+ query: {
+ match_all: {},
+ },
+ }),
+ },
+ },
+ },
+ savedObjectMigrationContext
+ );
+ const migratedSearchSource = JSON.parse(
+ migratedDoc.attributes.kibanaSavedObjectMeta.searchSourceJSON
+ );
+
+ expect(migratedSearchSource).toEqual({
+ query: {
+ query: '',
+ language: 'kuery',
+ },
+ });
+ });
+ });
+
describe('7.0.0', () => {
const migrationFn = searchSavedObjectTypeMigrations['7.0.0'];
diff --git a/src/plugins/data/server/saved_objects/search_migrations.ts b/src/plugins/data/server/saved_objects/search_migrations.ts
index db545e52ce17..45fa5e11e2a3 100644
--- a/src/plugins/data/server/saved_objects/search_migrations.ts
+++ b/src/plugins/data/server/saved_objects/search_migrations.ts
@@ -19,6 +19,41 @@
import { flow, get } from 'lodash';
import { SavedObjectMigrationFn } from 'kibana/server';
+import { DEFAULT_QUERY_LANGUAGE } from '../../common';
+
+const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
+ const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
+
+ if (searchSourceJSON) {
+ let searchSource: any;
+
+ try {
+ searchSource = JSON.parse(searchSourceJSON);
+ } catch (e) {
+ // Let it go, the data is invalid and we'll leave it as is
+ }
+
+ if (searchSource.query?.match_all) {
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: JSON.stringify({
+ ...searchSource,
+ query: {
+ query: '',
+ language: DEFAULT_QUERY_LANGUAGE,
+ },
+ }),
+ },
+ },
+ };
+ }
+ }
+
+ return doc;
+};
const migrateIndexPattern: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
@@ -87,6 +122,7 @@ const migrateSearchSortToNestedArray: SavedObjectMigrationFn = doc => {
};
export const searchSavedObjectTypeMigrations = {
+ '6.7.2': flow(migrateMatchAllQuery),
'7.0.0': flow(setNewReferences),
'7.4.0': flow(migrateSearchSortToNestedArray),
};
diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts
index 02c114bad4e7..c7f245e59551 100644
--- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts
+++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts
@@ -150,6 +150,32 @@ describe('migration visualization', () => {
expect(aggs[3]).not.toHaveProperty('params.customBucket.params.time_zone');
expect(aggs[2]).not.toHaveProperty('params.time_zone');
});
+
+ it('should migrate obsolete match_all query', () => {
+ const migratedDoc = migrate({
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: JSON.stringify({
+ query: {
+ match_all: {},
+ },
+ }),
+ },
+ },
+ });
+ const migratedSearchSource = JSON.parse(
+ migratedDoc.attributes.kibanaSavedObjectMeta.searchSourceJSON
+ );
+
+ expect(migratedSearchSource).toEqual({
+ query: {
+ query: '',
+ language: 'kuery',
+ },
+ });
+ });
});
});
diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
index 9ee355cbb23c..db87006dde3e 100644
--- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
+++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
@@ -19,6 +19,7 @@
import { SavedObjectMigrationFn } from 'kibana/server';
import { cloneDeep, get, omit, has, flow } from 'lodash';
+import { DEFAULT_QUERY_LANGUAGE } from '../../../data/common';
const migrateIndexPattern: SavedObjectMigrationFn = doc => {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
@@ -539,6 +540,40 @@ const migrateTableSplits: SavedObjectMigrationFn = doc => {
}
};
+const migrateMatchAllQuery: SavedObjectMigrationFn = doc => {
+ const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
+
+ if (searchSourceJSON) {
+ let searchSource: any;
+
+ try {
+ searchSource = JSON.parse(searchSourceJSON);
+ } catch (e) {
+ // Let it go, the data is invalid and we'll leave it as is
+ }
+
+ if (searchSource.query?.match_all) {
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: JSON.stringify({
+ ...searchSource,
+ query: {
+ query: '',
+ language: DEFAULT_QUERY_LANGUAGE,
+ },
+ }),
+ },
+ },
+ };
+ }
+ }
+
+ return doc;
+};
+
export const visualizationSavedObjectTypeMigrations = {
/**
* We need to have this migration twice, once with a version prior to 7.0.0 once with a version
@@ -550,7 +585,7 @@ export const visualizationSavedObjectTypeMigrations = {
* in that version. So we apply this twice, once with 6.7.2 and once with 7.0.1 while the backport to 6.7
* only contained the 6.7.2 migration and not the 7.0.1 migration.
*/
- '6.7.2': flow(removeDateHistogramTimeZones),
+ '6.7.2': flow(migrateMatchAllQuery, removeDateHistogramTimeZones),
'7.0.0': flow(
addDocReferences,
migrateIndexPattern,
From ba446f39002cc28c01964af67723a00194bd4176 Mon Sep 17 00:00:00 2001
From: Justin Kambic
Date: Mon, 6 Apr 2020 11:11:25 -0400
Subject: [PATCH 03/27] [Uptime] Default uptime alert type and disable changing
type (#62028)
* Default uptime alert type and disable changing type.
* Update functional test to handle new UI flow.
* Fix type error.
Co-authored-by: Elastic Machine
---
x-pack/legacy/plugins/uptime/public/uptime_app.tsx | 5 ++++-
x-pack/test/functional/page_objects/uptime_page.ts | 6 +++++-
x-pack/test/functional/services/uptime/alerts.ts | 4 ++--
3 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx
index fa2998532d14..dafb20dc9c32 100644
--- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx
+++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx
@@ -104,7 +104,10 @@ const Application = (props: UptimeAppProps) => {
-
+
diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts
index fcf2b77dbd62..39c3c46adddb 100644
--- a/x-pack/test/functional/page_objects/uptime_page.ts
+++ b/x-pack/test/functional/page_objects/uptime_page.ts
@@ -105,6 +105,7 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
alertTags,
alertThrottleInterval,
alertTimerangeSelection,
+ alertType,
filters,
}: {
alertName: string;
@@ -113,11 +114,14 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
alertThrottleInterval: string;
alertNumTimes: string;
alertTimerangeSelection: string;
+ alertType?: string;
filters?: string;
}) {
const { setKueryBarText } = commonService;
await alerts.openFlyout();
- await alerts.openMonitorStatusAlertType();
+ if (alertType) {
+ await alerts.openMonitorStatusAlertType(alertType);
+ }
await alerts.setAlertName(alertName);
await alerts.setAlertTags(alertTags);
await alerts.setAlertInterval(alertInterval);
diff --git a/x-pack/test/functional/services/uptime/alerts.ts b/x-pack/test/functional/services/uptime/alerts.ts
index 5ee444adec82..3a8193ff3d32 100644
--- a/x-pack/test/functional/services/uptime/alerts.ts
+++ b/x-pack/test/functional/services/uptime/alerts.ts
@@ -15,8 +15,8 @@ export function UptimeAlertsProvider({ getService }: FtrProviderContext) {
await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000);
await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000);
},
- async openMonitorStatusAlertType() {
- return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000);
+ async openMonitorStatusAlertType(alertType: string) {
+ return testSubjects.click(`xpack.uptime.alerts.${alertType}-SelectOption`, 5000);
},
async setAlertTags(tags: string[]) {
for (let i = 0; i < tags.length; i += 1) {
From cde0b73cb12c0f19fd2e7753711f946b7829aeaf Mon Sep 17 00:00:00 2001
From: Shahzad
Date: Mon, 6 Apr 2020 17:24:21 +0200
Subject: [PATCH 04/27] [Uptime] Removed unnecessary filter from Monitor List
Fetch (#61958)
* removed unnecessary filter
* update condition
* added a unit test for mix state
* fix types
* fix type
* updated test
* update
* updates test
* updates test
Co-authored-by: Elastic Machine
---
.../search/refine_potential_matches.ts | 3 +-
.../{get_all_pings.js => get_all_pings.ts} | 3 +-
.../uptime/graphql/{index.js => index.ts} | 4 +-
.../apis/uptime/graphql/monitor_states.ts | 66 ++++++++++++++++++-
.../apis/uptime/{index.js => index.ts} | 4 +-
..._collectors.js => telemetry_collectors.ts} | 4 +-
6 files changed, 78 insertions(+), 6 deletions(-)
rename x-pack/test/api_integration/apis/uptime/{get_all_pings.js => get_all_pings.ts} (95%)
rename x-pack/test/api_integration/apis/uptime/graphql/{index.js => index.ts} (82%)
rename x-pack/test/api_integration/apis/uptime/{index.js => index.ts} (81%)
rename x-pack/test/api_integration/apis/uptime/{telemetry_collectors.js => telemetry_collectors.ts} (83%)
diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts
index 7d69ff6751f0..218eb2f121a8 100644
--- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts
+++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts
@@ -68,7 +68,8 @@ const fullyMatchingIds = async (
const status = topSource.summary.down > 0 ? 'down' : 'up';
// This monitor doesn't match, so just skip ahead and don't add it to the output
- if (queryContext.statusFilter && queryContext.statusFilter !== status) {
+ // Only skip in case of up statusFilter, for a monitor to be up, all checks should be up
+ if (queryContext?.statusFilter === 'up' && queryContext.statusFilter !== status) {
continue MonitorLoop;
}
diff --git a/x-pack/test/api_integration/apis/uptime/get_all_pings.js b/x-pack/test/api_integration/apis/uptime/get_all_pings.ts
similarity index 95%
rename from x-pack/test/api_integration/apis/uptime/get_all_pings.js
rename to x-pack/test/api_integration/apis/uptime/get_all_pings.ts
index dcbc9389b1da..666986e7008b 100644
--- a/x-pack/test/api_integration/apis/uptime/get_all_pings.js
+++ b/x-pack/test/api_integration/apis/uptime/get_all_pings.ts
@@ -7,8 +7,9 @@
import moment from 'moment';
import expect from '@kbn/expect';
import { PINGS_DATE_RANGE_START, PINGS_DATE_RANGE_END } from './constants';
+import { FtrProviderContext } from '../../ftr_provider_context';
-export default function({ getService }) {
+export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
diff --git a/x-pack/test/api_integration/apis/uptime/graphql/index.js b/x-pack/test/api_integration/apis/uptime/graphql/index.ts
similarity index 82%
rename from x-pack/test/api_integration/apis/uptime/graphql/index.js
rename to x-pack/test/api_integration/apis/uptime/graphql/index.ts
index ee22974d4717..2e0b5e2eea2a 100644
--- a/x-pack/test/api_integration/apis/uptime/graphql/index.js
+++ b/x-pack/test/api_integration/apis/uptime/graphql/index.ts
@@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export default function({ loadTestFile }) {
+import { FtrProviderContext } from '../../../ftr_provider_context';
+
+export default function({ loadTestFile }: FtrProviderContext) {
describe('graphql', () => {
// each of these test files imports a GQL query from
// the uptime app and runs it against the live HTTP server,
diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts
index a293426195d2..216560583249 100644
--- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts
+++ b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts
@@ -5,10 +5,11 @@
*/
import expect from '@kbn/expect';
-import { monitorStatesQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_states_query';
import { expectFixtureEql } from './helpers/expect_fixture_eql';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { makeChecksWithStatus } from './helpers/make_checks';
+import { monitorStatesQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_states_query';
+import { MonitorSummary } from '../../../../../legacy/plugins/uptime/common/graphql/types';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@@ -116,6 +117,7 @@ export default function({ getService }: FtrProviderContext) {
}
return d;
});
+
dateRangeEnd = new Date().toISOString();
nonSummaryIp = checks[0][0].monitor.ip;
});
@@ -177,5 +179,67 @@ export default function({ getService }: FtrProviderContext) {
});
});
});
+
+ describe(' test status filter', async () => {
+ const upMonitorId = 'up-test-id';
+ const downMonitorId = 'down-test-id';
+ const mixMonitorId = 'mix-test-id';
+ before('generate three monitors with up, down, mix state', async () => {
+ await getService('esArchiver').load('uptime/blank');
+
+ const es = getService('legacyEs');
+
+ const observer = {
+ geo: {
+ name: 'US-East',
+ location: '40.7128, -74.0060',
+ },
+ };
+
+ // Generating three monitors each with two geo locations,
+ // One in a down state ,
+ // One in an up state,
+ // One in a mix state
+
+ dateRangeStart = new Date().toISOString();
+
+ await makeChecksWithStatus(es, upMonitorId, 1, 4, 1, {}, 'up');
+ await makeChecksWithStatus(es, upMonitorId, 1, 4, 1, { observer }, 'up');
+
+ await makeChecksWithStatus(es, downMonitorId, 1, 4, 1, {}, 'down');
+ await makeChecksWithStatus(es, downMonitorId, 1, 4, 1, { observer }, 'down');
+
+ await makeChecksWithStatus(es, mixMonitorId, 1, 4, 1, {}, 'up');
+ await makeChecksWithStatus(es, mixMonitorId, 1, 4, 1, { observer }, 'down');
+
+ dateRangeEnd = new Date().toISOString();
+ });
+
+ after('unload heartbeat index', () => getService('esArchiver').unload('uptime/blank'));
+
+ it('should return all monitor when no status filter', async () => {
+ const { monitorStates } = await getMonitorStates({});
+ expect(monitorStates.summaries.length).to.eql(3);
+ // Summaries are by default sorted by monitor names
+ expect(
+ monitorStates.summaries.map((summary: MonitorSummary) => summary.monitor_id)
+ ).to.eql([downMonitorId, mixMonitorId, upMonitorId]);
+ });
+
+ it('should return a monitor with mix state if check status filter is down', async () => {
+ const { monitorStates } = await getMonitorStates({ statusFilter: 'down' });
+ expect(monitorStates.summaries.length).to.eql(2);
+ monitorStates.summaries.forEach((summary: MonitorSummary) => {
+ expect(summary.monitor_id).to.not.eql(upMonitorId);
+ });
+ });
+
+ it('should not return a monitor with mix state if check status filter is up', async () => {
+ const { monitorStates } = await getMonitorStates({ statusFilter: 'up' });
+
+ expect(monitorStates.summaries.length).to.eql(1);
+ expect(monitorStates.summaries[0].monitor_id).to.eql(upMonitorId);
+ });
+ });
});
}
diff --git a/x-pack/test/api_integration/apis/uptime/index.js b/x-pack/test/api_integration/apis/uptime/index.ts
similarity index 81%
rename from x-pack/test/api_integration/apis/uptime/index.js
rename to x-pack/test/api_integration/apis/uptime/index.ts
index 0b18f14cd7da..a21db08d58c4 100644
--- a/x-pack/test/api_integration/apis/uptime/index.js
+++ b/x-pack/test/api_integration/apis/uptime/index.ts
@@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export default function({ getService, loadTestFile }) {
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function({ getService, loadTestFile }: FtrProviderContext) {
const es = getService('legacyEs');
describe('uptime', () => {
diff --git a/x-pack/test/api_integration/apis/uptime/telemetry_collectors.js b/x-pack/test/api_integration/apis/uptime/telemetry_collectors.ts
similarity index 83%
rename from x-pack/test/api_integration/apis/uptime/telemetry_collectors.js
rename to x-pack/test/api_integration/apis/uptime/telemetry_collectors.ts
index eb4e3dc00290..e33c6120557b 100644
--- a/x-pack/test/api_integration/apis/uptime/telemetry_collectors.js
+++ b/x-pack/test/api_integration/apis/uptime/telemetry_collectors.ts
@@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export default function({ getService }) {
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('telemetry collectors', () => {
From 6da7c00b5db79df491cea9fdb5a17f79f167a2ea Mon Sep 17 00:00:00 2001
From: Corey Robertson
Date: Mon, 6 Apr 2020 11:49:34 -0400
Subject: [PATCH 05/27] Remove the action_value_click action in canvas (#62215)
Co-authored-by: Elastic Machine
---
.../plugins/canvas/public/application.tsx | 34 ++++++++++++++++++-
x-pack/legacy/plugins/canvas/public/legacy.ts | 1 +
.../legacy/plugins/canvas/public/plugin.tsx | 4 ++-
3 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/x-pack/legacy/plugins/canvas/public/application.tsx b/x-pack/legacy/plugins/canvas/public/application.tsx
index e26157aadebc..79b3918fef99 100644
--- a/x-pack/legacy/plugins/canvas/public/application.tsx
+++ b/x-pack/legacy/plugins/canvas/public/application.tsx
@@ -26,9 +26,24 @@ import { getDocumentationLinks } from './lib/documentation_links';
import { HelpMenu } from './components/help_menu/help_menu';
import { createStore } from './store';
+import { VALUE_CLICK_TRIGGER, ActionByType } from '../../../../../src/plugins/ui_actions/public';
+/* eslint-disable */
+import { ACTION_VALUE_CLICK } from '../../../../../src/plugins/data/public/actions/value_click_action';
+/* eslint-enable */
+
import { CapabilitiesStrings } from '../i18n';
const { ReadOnlyBadge: strings } = CapabilitiesStrings;
+let restoreAction: ActionByType | undefined;
+const emptyAction = {
+ id: 'empty-action',
+ type: '',
+ getDisplayName: () => 'empty action',
+ getIconType: () => undefined,
+ isCompatible: async () => true,
+ execute: async () => undefined,
+} as ActionByType;
+
export const renderApp = (
coreStart: CoreStart,
plugins: CanvasStartDeps,
@@ -94,13 +109,30 @@ export const initializeCanvas = async (
},
});
+ // TODO: We need this to disable the filtering modal from popping up in lens embeds until
+ // they honor the disableTriggers parameter
+ const action = startPlugins.uiActions.getAction(ACTION_VALUE_CLICK);
+
+ if (action) {
+ restoreAction = action;
+
+ startPlugins.uiActions.detachAction(VALUE_CLICK_TRIGGER, action.id);
+ startPlugins.uiActions.attachAction(VALUE_CLICK_TRIGGER, emptyAction);
+ }
+
return canvasStore;
};
-export const teardownCanvas = (coreStart: CoreStart) => {
+export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDeps) => {
destroyRegistries();
resetInterpreter();
+ startPlugins.uiActions.detachAction(VALUE_CLICK_TRIGGER, emptyAction.id);
+ if (restoreAction) {
+ startPlugins.uiActions.attachAction(VALUE_CLICK_TRIGGER, restoreAction);
+ restoreAction = undefined;
+ }
+
coreStart.chrome.setBadge(undefined);
coreStart.chrome.setHelpExtension(undefined);
};
diff --git a/x-pack/legacy/plugins/canvas/public/legacy.ts b/x-pack/legacy/plugins/canvas/public/legacy.ts
index 9bccc958f726..a6caa1985325 100644
--- a/x-pack/legacy/plugins/canvas/public/legacy.ts
+++ b/x-pack/legacy/plugins/canvas/public/legacy.ts
@@ -27,6 +27,7 @@ const shimSetupPlugins: CanvasSetupDeps = {
const shimStartPlugins: CanvasStartDeps = {
...npStart.plugins,
expressions: npStart.plugins.expressions,
+ uiActions: npStart.plugins.uiActions,
__LEGACY: {
// ToDo: Copy directly into canvas
absoluteToParsedUrl,
diff --git a/x-pack/legacy/plugins/canvas/public/plugin.tsx b/x-pack/legacy/plugins/canvas/public/plugin.tsx
index f4a3aed28a0a..d9e5e6b4b084 100644
--- a/x-pack/legacy/plugins/canvas/public/plugin.tsx
+++ b/x-pack/legacy/plugins/canvas/public/plugin.tsx
@@ -10,6 +10,7 @@ import { HomePublicPluginSetup } from '../../../../../src/plugins/home/public';
import { initLoadingIndicator } from './lib/loading_indicator';
import { featureCatalogueEntry } from './feature_catalogue_entry';
import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public';
+import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
// @ts-ignore untyped local
import { argTypeSpecs } from './expression_types/arg_types';
import { transitions } from './transitions';
@@ -31,6 +32,7 @@ export interface CanvasSetupDeps {
export interface CanvasStartDeps {
expressions: ExpressionsStart;
+ uiActions: UiActionsStart;
__LEGACY: {
absoluteToParsedUrl: (url: string, basePath: string) => any;
formatMsg: any;
@@ -70,7 +72,7 @@ export class CanvasPlugin
return () => {
unmount();
- teardownCanvas(coreStart);
+ teardownCanvas(coreStart, depsStart);
};
},
});
From 1e92dcf1f33ddb9af5ea794a4612a0c940edee49 Mon Sep 17 00:00:00 2001
From: Joe Reuter
Date: Mon, 6 Apr 2020 17:55:27 +0200
Subject: [PATCH 06/27] migrate saved object, reset validation counter and fix
typo attribute (#62442)
---
.../core_plugins/vis_type_timeseries/index.ts | 14 -
.../validation_telemetry/saved_object_type.ts | 45 ++
.../validation_telemetry_service.ts | 2 +
.../visualization_migrations.test.ts | 539 ++++++++++--------
.../saved_objects/visualization_migrations.ts | 33 ++
5 files changed, 390 insertions(+), 243 deletions(-)
create mode 100644 src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts
diff --git a/src/legacy/core_plugins/vis_type_timeseries/index.ts b/src/legacy/core_plugins/vis_type_timeseries/index.ts
index 3ad8ba3a31c1..596fd5b581a7 100644
--- a/src/legacy/core_plugins/vis_type_timeseries/index.ts
+++ b/src/legacy/core_plugins/vis_type_timeseries/index.ts
@@ -31,20 +31,6 @@ const metricsPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPlu
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
hacks: [resolve(__dirname, 'public/legacy')],
injectDefaultVars: server => ({}),
- mappings: {
- 'tsvb-validation-telemetry': {
- properties: {
- failedRequests: {
- type: 'long',
- },
- },
- },
- },
- savedObjectSchemas: {
- 'tsvb-validation-telemetry': {
- isNamespaceAgnostic: true,
- },
- },
},
config(Joi: any) {
return Joi.object({
diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts
new file mode 100644
index 000000000000..77b49e824334
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts
@@ -0,0 +1,45 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { flow } from 'lodash';
+import { SavedObjectMigrationFn, SavedObjectsType } from 'kibana/server';
+
+const resetCount: SavedObjectMigrationFn = doc => ({
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ failedRequests: 0,
+ },
+});
+
+export const tsvbTelemetrySavedObjectType: SavedObjectsType = {
+ name: 'tsvb-validation-telemetry',
+ hidden: false,
+ namespaceAgnostic: true,
+ mappings: {
+ properties: {
+ failedRequests: {
+ type: 'long',
+ },
+ },
+ },
+ migrations: {
+ '7.7.0': flow(resetCount),
+ },
+};
diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts
index e49664265b8b..779d9441df2f 100644
--- a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts
+++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts
@@ -19,6 +19,7 @@
import { APICaller, CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
import { UsageCollectionSetup } from '../../../usage_collection/server';
+import { tsvbTelemetrySavedObjectType } from './saved_object_type';
export interface ValidationTelemetryServiceSetup {
logFailedValidation: () => void;
@@ -36,6 +37,7 @@ export class ValidationTelemetryService implements Plugin {
this.kibanaIndex = config.kibana.index;
});
diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts
index c7f245e59551..26f8278cd3d4 100644
--- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts
+++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts
@@ -207,13 +207,13 @@ describe('migration visualization', () => {
},
});
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "visState": "{}",
- },
- "references": Array [],
-}
-`);
+ Object {
+ "attributes": Object {
+ "visState": "{}",
+ },
+ "references": Array [],
+ }
+ `);
});
it('skips errors when searchSourceJSON is null', () => {
@@ -231,25 +231,25 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": null,
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": null,
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('skips errors when searchSourceJSON is undefined', () => {
@@ -267,25 +267,25 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": undefined,
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": undefined,
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('skips error when searchSourceJSON is not a string', () => {
@@ -302,25 +302,25 @@ Object {
};
expect(migrate(doc)).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": 123,
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": 123,
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('skips error when searchSourceJSON is invalid json', () => {
@@ -337,25 +337,25 @@ Object {
};
expect(migrate(doc)).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{abc123}",
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{abc123}",
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('skips error when "index" and "filter" is missing from searchSourceJSON', () => {
@@ -373,25 +373,25 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{\\"bar\\":true}",
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{\\"bar\\":true}",
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('extracts "index" attribute from doc', () => {
@@ -409,30 +409,30 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "pattern*",
- "name": "kibanaSavedObjectMeta.searchSourceJSON.index",
- "type": "index-pattern",
- },
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "pattern*",
+ "name": "kibanaSavedObjectMeta.searchSourceJSON.index",
+ "type": "index-pattern",
+ },
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('extracts index patterns from the filter', () => {
@@ -457,30 +457,30 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{\\"bar\\":true,\\"filter\\":[{\\"meta\\":{\\"foo\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}",
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "my-index",
- "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index",
- "type": "index-pattern",
- },
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{\\"bar\\":true,\\"filter\\":[{\\"meta\\":{\\"foo\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}",
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "my-index",
+ "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index",
+ "type": "index-pattern",
+ },
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('extracts index patterns from controls', () => {
@@ -508,22 +508,22 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "foo": true,
- "visState": "{\\"bar\\":false,\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"foo\\":true}]}}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "pattern*",
- "name": "control_0_index_pattern",
- "type": "index-pattern",
- },
- ],
- "type": "visualization",
-}
-`);
+ Object {
+ "attributes": Object {
+ "foo": true,
+ "visState": "{\\"bar\\":false,\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"foo\\":true}]}}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "pattern*",
+ "name": "control_0_index_pattern",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "visualization",
+ }
+ `);
});
it('skips extracting savedSearchId when missing', () => {
@@ -539,17 +539,17 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{}",
- },
- "visState": "{}",
- },
- "id": "1",
- "references": Array [],
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{}",
+ },
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [],
+ }
+ `);
});
it('extract savedSearchId from doc', () => {
@@ -566,24 +566,24 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{}",
- },
- "savedSearchRefName": "search_0",
- "visState": "{}",
- },
- "id": "1",
- "references": Array [
- Object {
- "id": "123",
- "name": "search_0",
- "type": "search",
- },
- ],
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{}",
+ },
+ "savedSearchRefName": "search_0",
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [
+ Object {
+ "id": "123",
+ "name": "search_0",
+ "type": "search",
+ },
+ ],
+ }
+ `);
});
it('delete savedSearchId when empty string in doc', () => {
@@ -600,17 +600,17 @@ Object {
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "kibanaSavedObjectMeta": Object {
- "searchSourceJSON": "{}",
- },
- "visState": "{}",
- },
- "id": "1",
- "references": Array [],
-}
-`);
+ Object {
+ "attributes": Object {
+ "kibanaSavedObjectMeta": Object {
+ "searchSourceJSON": "{}",
+ },
+ "visState": "{}",
+ },
+ "id": "1",
+ "references": Array [],
+ }
+ `);
});
it('should return a new object if vis is table and has multiple split aggs', () => {
@@ -930,12 +930,12 @@ Object {
},
});
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "visState": "{\\"type\\":\\"gauge\\",\\"params\\":{\\"gauge\\":{\\"alignment\\":\\"horizontal\\"}}}",
- },
-}
-`);
+ Object {
+ "attributes": Object {
+ "visState": "{\\"type\\":\\"gauge\\",\\"params\\":{\\"gauge\\":{\\"alignment\\":\\"horizontal\\"}}}",
+ },
+ }
+ `);
});
it('migrates type = gauge verticalSplit: false to alignment: horizontal', () => {
@@ -946,12 +946,12 @@ Object {
});
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "visState": "{\\"type\\":\\"gauge\\",\\"params\\":{\\"gauge\\":{\\"alignment\\":\\"vertical\\"}}}",
- },
-}
-`);
+ Object {
+ "attributes": Object {
+ "visState": "{\\"type\\":\\"gauge\\",\\"params\\":{\\"gauge\\":{\\"alignment\\":\\"vertical\\"}}}",
+ },
+ }
+ `);
});
it('doesnt migrate type = gauge containing invalid visState object, adds message to log', () => {
@@ -962,18 +962,18 @@ Object {
});
expect(migratedDoc).toMatchInlineSnapshot(`
-Object {
- "attributes": Object {
- "visState": "{\\"type\\":\\"gauge\\"}",
- },
-}
-`);
+ Object {
+ "attributes": Object {
+ "visState": "{\\"type\\":\\"gauge\\"}",
+ },
+ }
+ `);
expect(logMsgArr).toMatchInlineSnapshot(`
-Array [
- "Exception @ migrateGaugeVerticalSplitToAlignment! TypeError: Cannot read property 'gauge' of undefined",
- "Exception @ migrateGaugeVerticalSplitToAlignment! Payload: {\\"type\\":\\"gauge\\"}",
-]
-`);
+ Array [
+ "Exception @ migrateGaugeVerticalSplitToAlignment! TypeError: Cannot read property 'gauge' of undefined",
+ "Exception @ migrateGaugeVerticalSplitToAlignment! Payload: {\\"type\\":\\"gauge\\"}",
+ ]
+ `);
});
describe('filters agg query migration', () => {
@@ -1379,4 +1379,85 @@ Array [
expect(timeSeriesParams.series[0].split_filters[0].filter.language).toEqual('lucene');
});
});
+
+ describe('7.7.0 tsvb opperator typo migration', () => {
+ const migrate = (doc: any) =>
+ visualizationSavedObjectTypeMigrations['7.7.0'](
+ doc as Parameters[0],
+ savedObjectMigrationContext
+ );
+ const generateDoc = (params: any) => ({
+ attributes: {
+ title: 'My Vis',
+ description: 'This is my super cool vis.',
+ visState: JSON.stringify({ params }),
+ uiStateJSON: '{}',
+ version: 1,
+ kibanaSavedObjectMeta: {
+ searchSourceJSON: '{}',
+ },
+ },
+ });
+
+ it('should remove the misspelled opperator key if it exists', () => {
+ const params = {
+ type: 'timeseries',
+ filter: {
+ query: 'bytes:>1000',
+ language: 'lucene',
+ },
+ series: [],
+ gauge_color_rules: [
+ {
+ value: 0,
+ id: '020e3d50-75a6-11ea-8f61-71579ff7f64d',
+ gauge: 'rgba(69,39,217,1)',
+ opperator: 'lt',
+ },
+ ],
+ };
+ const timeSeriesDoc = generateDoc(params);
+ const migratedtimeSeriesDoc = migrate(timeSeriesDoc);
+ const migratedParams = JSON.parse(migratedtimeSeriesDoc.attributes.visState).params;
+
+ expect(migratedParams.gauge_color_rules[0]).toMatchInlineSnapshot(`
+ Object {
+ "gauge": "rgba(69,39,217,1)",
+ "id": "020e3d50-75a6-11ea-8f61-71579ff7f64d",
+ "opperator": "lt",
+ "value": 0,
+ }
+ `);
+ });
+
+ it('should not change color rules with the correct spelling', () => {
+ const params = {
+ type: 'timeseries',
+ filter: {
+ query: 'bytes:>1000',
+ language: 'lucene',
+ },
+ series: [],
+ gauge_color_rules: [
+ {
+ value: 0,
+ id: '020e3d50-75a6-11ea-8f61-71579ff7f64d',
+ gauge: 'rgba(69,39,217,1)',
+ opperator: 'lt',
+ },
+ {
+ value: 0,
+ id: '020e3d50-75a6-11ea-8f61-71579ff7f64d',
+ gauge: 'rgba(69,39,217,1)',
+ operator: 'lt',
+ },
+ ],
+ };
+ const timeSeriesDoc = generateDoc(params);
+ const migratedtimeSeriesDoc = migrate(timeSeriesDoc);
+ const migratedParams = JSON.parse(migratedtimeSeriesDoc.attributes.visState).params;
+
+ expect(migratedParams.gauge_color_rules[1]).toEqual(params.gauge_color_rules[1]);
+ });
+ });
});
diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
index db87006dde3e..80783e41863e 100644
--- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
+++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts
@@ -99,6 +99,38 @@ const migratePercentileRankAggregation: SavedObjectMigrationFn = doc => {
return doc;
};
+// [TSVB] Remove stale opperator key
+const migrateOperatorKeyTypo: SavedObjectMigrationFn = doc => {
+ const visStateJSON = get(doc, 'attributes.visState');
+ let visState;
+
+ if (visStateJSON) {
+ try {
+ visState = JSON.parse(visStateJSON);
+ } catch (e) {
+ // Let it go, the data is invalid and we'll leave it as is
+ }
+ if (visState && visState.type === 'metrics') {
+ const gaugeColorRules: any[] = get(visState, 'params.gauge_color_rules') || [];
+
+ gaugeColorRules.forEach(colorRule => {
+ if (colorRule.opperator) {
+ delete colorRule.opperator;
+ }
+ });
+
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ visState: JSON.stringify(visState),
+ },
+ };
+ }
+ }
+ return doc;
+};
+
// Migrate date histogram aggregation (remove customInterval)
const migrateDateHistogramAggregation: SavedObjectMigrationFn = doc => {
const visStateJSON = get(doc, 'attributes.visState');
@@ -606,4 +638,5 @@ export const visualizationSavedObjectTypeMigrations = {
),
'7.3.1': flow(migrateFiltersAggQueryStringQueries),
'7.4.2': flow(transformSplitFiltersStringToQueryObject),
+ '7.7.0': flow(migrateOperatorKeyTypo),
};
From 70ae8aa6f1418beff1aeb853dce0415814d0a890 Mon Sep 17 00:00:00 2001
From: Michael Marcialis
Date: Mon, 6 Apr 2020 12:10:20 -0400
Subject: [PATCH 07/27] add ownFocus prop to help menu (#62492)
---
src/core/public/chrome/ui/header/header_help_menu.tsx | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx
index 80a45b4c61f0..1023a561a0fe 100644
--- a/src/core/public/chrome/ui/header/header_help_menu.tsx
+++ b/src/core/public/chrome/ui/header/header_help_menu.tsx
@@ -314,13 +314,14 @@ class HeaderHelpMenuUI extends Component {
return (
// @ts-ignore repositionOnScroll doesn't exist in EuiPopover
}>
+ {msgTooltip}}
+ data-test-subj="configure-case-tooltip"
+ >
{configureCaseButton}
) : (
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx
new file mode 100644
index 000000000000..209dce9aedff
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+
+import { ClosureOptions, ClosureOptionsProps } from './closure_options';
+import { TestProviders } from '../../../../mock';
+import { ClosureOptionsRadio } from './closure_options_radio';
+
+describe('ClosureOptions', () => {
+ let wrapper: ReactWrapper;
+ const onChangeClosureType = jest.fn();
+ const props: ClosureOptionsProps = {
+ disabled: false,
+ closureTypeSelected: 'close-by-user',
+ onChangeClosureType,
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it shows the closure options form group', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-closure-options-form-group"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows the closure options form row', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-closure-options-form-row"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows closure options', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-closure-options-radio"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it pass the correct props to child', () => {
+ const closureOptionsRadioComponent = wrapper.find(ClosureOptionsRadio);
+ expect(closureOptionsRadioComponent.props().disabled).toEqual(false);
+ expect(closureOptionsRadioComponent.props().closureTypeSelected).toEqual('close-by-user');
+ expect(closureOptionsRadioComponent.props().onChangeClosureType).toEqual(onChangeClosureType);
+ });
+
+ test('the closure type is changed successfully', () => {
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+
+ expect(onChangeClosureType).toHaveBeenCalled();
+ expect(onChangeClosureType).toHaveBeenCalledWith('close-by-pushing');
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx
index 9879b9149059..6fa97818dd0c 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx
@@ -11,7 +11,7 @@ import { ClosureType } from '../../../../containers/case/configure/types';
import { ClosureOptionsRadio } from './closure_options_radio';
import * as i18n from './translations';
-interface ClosureOptionsProps {
+export interface ClosureOptionsProps {
closureTypeSelected: ClosureType;
disabled: boolean;
onChangeClosureType: (newClosureType: ClosureType) => void;
@@ -27,12 +27,18 @@ const ClosureOptionsComponent: React.FC = ({
fullWidth
title={{i18n.CASE_CLOSURE_OPTIONS_TITLE}
}
description={i18n.CASE_CLOSURE_OPTIONS_DESC}
+ data-test-subj="case-closure-options-form-group"
>
-
+
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.test.tsx
new file mode 100644
index 000000000000..f2ef2c2d55c2
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.test.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { ReactWrapper, mount } from 'enzyme';
+
+import { ClosureOptionsRadio, ClosureOptionsRadioComponentProps } from './closure_options_radio';
+import { TestProviders } from '../../../../mock';
+
+describe('ClosureOptionsRadio', () => {
+ let wrapper: ReactWrapper;
+ const onChangeClosureType = jest.fn();
+ const props: ClosureOptionsRadioComponentProps = {
+ disabled: false,
+ closureTypeSelected: 'close-by-user',
+ onChangeClosureType,
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it renders', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="closure-options-radio-group"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows the correct number of radio buttons', () => {
+ expect(wrapper.find('input[name="closure_options"]')).toHaveLength(2);
+ });
+
+ test('it renders close by user radio button', () => {
+ expect(wrapper.find('input[id="close-by-user"]').exists()).toBeTruthy();
+ });
+
+ test('it renders close by pushing radio button', () => {
+ expect(wrapper.find('input[id="close-by-pushing"]').exists()).toBeTruthy();
+ });
+
+ test('it disables the close by user radio button', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(newWrapper.find('input[id="close-by-user"]').prop('disabled')).toEqual(true);
+ });
+
+ test('it disables correctly the close by pushing radio button', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(newWrapper.find('input[id="close-by-pushing"]').prop('disabled')).toEqual(true);
+ });
+
+ test('it selects the correct radio button', () => {
+ const newWrapper = mount(
+ ,
+ {
+ wrappingComponent: TestProviders,
+ }
+ );
+ expect(newWrapper.find('input[id="close-by-pushing"]').prop('checked')).toEqual(true);
+ });
+
+ test('it calls the onChangeClosureType function', () => {
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+ wrapper.update();
+ expect(onChangeClosureType).toHaveBeenCalled();
+ expect(onChangeClosureType).toHaveBeenCalledWith('close-by-pushing');
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx
index f32f867b2471..d2cdb7ecda7b 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx
@@ -26,7 +26,7 @@ const radios: ClosureRadios[] = [
},
];
-interface ClosureOptionsRadioComponentProps {
+export interface ClosureOptionsRadioComponentProps {
closureTypeSelected: ClosureType;
disabled: boolean;
onChangeClosureType: (newClosureType: ClosureType) => void;
@@ -51,6 +51,7 @@ const ClosureOptionsRadioComponent: React.FC
idSelected={closureTypeSelected}
onChange={onChangeLocal}
name="closure_options"
+ data-test-subj="closure-options-radio-group"
/>
);
};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx
new file mode 100644
index 000000000000..5fb52c374b48
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+
+import { Connectors, Props } from './connectors';
+import { TestProviders } from '../../../../mock';
+import { ConnectorsDropdown } from './connectors_dropdown';
+import { connectors } from './__mock__';
+
+describe('Connectors', () => {
+ let wrapper: ReactWrapper;
+ const onChangeConnector = jest.fn();
+ const handleShowAddFlyout = jest.fn();
+ const props: Props = {
+ disabled: false,
+ connectors,
+ selectedConnector: 'none',
+ isLoading: false,
+ onChangeConnector,
+ handleShowAddFlyout,
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it shows the connectors from group', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-connectors-form-group"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows the connectors form row', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-connectors-form-row"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows the connectors dropdown', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-connectors-dropdown"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it pass the correct props to child', () => {
+ const connectorsDropdownProps = wrapper.find(ConnectorsDropdown).props();
+ expect(connectorsDropdownProps).toMatchObject({
+ disabled: false,
+ isLoading: false,
+ connectors,
+ selectedConnector: 'none',
+ onChange: props.onChangeConnector,
+ });
+ });
+
+ test('the connector is changed successfully', () => {
+ wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
+ wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click');
+
+ expect(onChangeConnector).toHaveBeenCalled();
+ expect(onChangeConnector).toHaveBeenCalledWith('456');
+ });
+
+ test('the connector is changed successfully to none', () => {
+ onChangeConnector.mockClear();
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ newWrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
+ newWrapper.find('button[data-test-subj="dropdown-connector-no-connector"]').simulate('click');
+
+ expect(onChangeConnector).toHaveBeenCalled();
+ expect(onChangeConnector).toHaveBeenCalledWith('none');
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx
index 8fb1cfb1aa6c..de6d5f76cfad 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx
@@ -28,7 +28,7 @@ const EuiFormRowExtended = styled(EuiFormRow)`
}
`;
-interface Props {
+export interface Props {
connectors: Connector[];
disabled: boolean;
isLoading: boolean;
@@ -48,7 +48,11 @@ const ConnectorsComponent: React.FC = ({
{i18n.INCIDENT_MANAGEMENT_SYSTEM_LABEL}
-
+
{i18n.ADD_NEW_CONNECTOR}
@@ -61,14 +65,20 @@ const ConnectorsComponent: React.FC = ({
fullWidth
title={{i18n.INCIDENT_MANAGEMENT_SYSTEM_TITLE}
}
description={i18n.INCIDENT_MANAGEMENT_SYSTEM_DESC}
+ data-test-subj="case-connectors-form-group"
>
-
+
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.tsx
new file mode 100644
index 000000000000..044108962efc
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { EuiSuperSelect } from '@elastic/eui';
+
+import { ConnectorsDropdown, Props } from './connectors_dropdown';
+import { TestProviders } from '../../../../mock';
+import { connectors } from './__mock__';
+
+describe('ConnectorsDropdown', () => {
+ let wrapper: ReactWrapper;
+ const props: Props = {
+ disabled: false,
+ connectors,
+ isLoading: false,
+ onChange: jest.fn(),
+ selectedConnector: 'none',
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it renders', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="dropdown-connectors"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it formats the connectors correctly', () => {
+ const selectProps = wrapper.find(EuiSuperSelect).props();
+
+ expect(selectProps.options).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ value: 'none',
+ 'data-test-subj': 'dropdown-connector-no-connector',
+ }),
+ expect.objectContaining({ value: '123', 'data-test-subj': 'dropdown-connector-123' }),
+ expect.objectContaining({ value: '456', 'data-test-subj': 'dropdown-connector-456' }),
+ ])
+ );
+ });
+
+ test('it disables the dropdown', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="dropdown-connectors"]')
+ .first()
+ .prop('disabled')
+ ).toEqual(true);
+ });
+
+ test('it loading correctly', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="dropdown-connectors"]')
+ .first()
+ .prop('isLoading')
+ ).toEqual(true);
+ });
+
+ test('it selects the correct connector', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(newWrapper.find('button span').text()).toEqual('My Connector');
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx
index a0a0ad6cd3e7..15066e73eee8 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx
@@ -12,7 +12,7 @@ import { Connector } from '../../../../containers/case/configure/types';
import { connectors as connectorsDefinition } from '../../../../lib/connectors/config';
import * as i18n from './translations';
-interface Props {
+export interface Props {
connectors: Connector[];
disabled: boolean;
isLoading: boolean;
@@ -34,7 +34,7 @@ const noConnectorOption = {
{i18n.NO_CONNECTOR}
>
),
- 'data-test-subj': 'no-connector',
+ 'data-test-subj': 'dropdown-connector-no-connector',
};
const ConnectorsDropdownComponent: React.FC = ({
@@ -60,7 +60,7 @@ const ConnectorsDropdownComponent: React.FC = ({
{connector.name}
>
),
- 'data-test-subj': connector.id,
+ 'data-test-subj': `dropdown-connector-${connector.id}`,
},
],
[noConnectorOption]
@@ -76,6 +76,7 @@ const ConnectorsDropdownComponent: React.FC = ({
valueOfSelected={selectedConnector}
fullWidth
onChange={onChange}
+ data-test-subj="dropdown-connectors"
/>
);
};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx
new file mode 100644
index 000000000000..9ab752bb589c
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+
+import { FieldMapping, FieldMappingProps } from './field_mapping';
+import { mapping } from './__mock__';
+import { FieldMappingRow } from './field_mapping_row';
+import { defaultMapping } from '../../../../lib/connectors/config';
+import { TestProviders } from '../../../../mock';
+
+describe('FieldMappingRow', () => {
+ let wrapper: ReactWrapper;
+ const onChangeMapping = jest.fn();
+ const props: FieldMappingProps = {
+ disabled: false,
+ mapping,
+ onChangeMapping,
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it renders', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-field-mapping-cols"]')
+ .first()
+ .exists()
+ ).toBe(true);
+
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-field-mapping-row-wrapper"]')
+ .first()
+ .exists()
+ ).toBe(true);
+
+ expect(wrapper.find(FieldMappingRow).length).toEqual(3);
+ });
+
+ test('it shows the correct number of FieldMappingRow with default mapping', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(newWrapper.find(FieldMappingRow).length).toEqual(3);
+ });
+
+ test('it pass the corrects props to mapping row', () => {
+ const rows = wrapper.find(FieldMappingRow);
+ rows.forEach((row, index) => {
+ expect(row.prop('siemField')).toEqual(mapping[index].source);
+ expect(row.prop('selectedActionType')).toEqual(mapping[index].actionType);
+ expect(row.prop('selectedThirdParty')).toEqual(mapping[index].target);
+ });
+ });
+
+ test('it pass the default mapping when mapping is null', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ const rows = newWrapper.find(FieldMappingRow);
+ rows.forEach((row, index) => {
+ expect(row.prop('siemField')).toEqual(defaultMapping[index].source);
+ expect(row.prop('selectedActionType')).toEqual(defaultMapping[index].actionType);
+ expect(row.prop('selectedThirdParty')).toEqual(defaultMapping[index].target);
+ });
+ });
+
+ test('it should show zero rows on empty array', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(newWrapper.find(FieldMappingRow).length).toEqual(0);
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx
index 0c0dc14f1c21..2934b1056e29 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx
@@ -18,6 +18,7 @@ import { FieldMappingRow } from './field_mapping_row';
import * as i18n from './translations';
import { defaultMapping } from '../../../../lib/connectors/config';
+import { setActionTypeToMapping, setThirdPartyToMapping } from './utils';
const FieldRowWrapper = styled.div`
margin-top: 8px;
@@ -28,22 +29,26 @@ const supportedThirdPartyFields: Array> =
{
value: 'not_mapped',
inputDisplay: {i18n.FIELD_MAPPING_FIELD_NOT_MAPPED},
+ 'data-test-subj': 'third-party-field-not-mapped',
},
{
value: 'short_description',
inputDisplay: {i18n.FIELD_MAPPING_FIELD_SHORT_DESC},
+ 'data-test-subj': 'third-party-field-short-description',
},
{
value: 'comments',
inputDisplay: {i18n.FIELD_MAPPING_FIELD_COMMENTS},
+ 'data-test-subj': 'third-party-field-comments',
},
{
value: 'description',
inputDisplay: {i18n.FIELD_MAPPING_FIELD_DESC},
+ 'data-test-subj': 'third-party-field-description',
},
];
-interface FieldMappingProps {
+export interface FieldMappingProps {
disabled: boolean;
mapping: CasesConfigurationMapping[] | null;
onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void;
@@ -57,14 +62,7 @@ const FieldMappingComponent: React.FC = ({
const onChangeActionType = useCallback(
(caseField: CaseField, newActionType: ActionType) => {
const myMapping = mapping ?? defaultMapping;
- const findItemIndex = myMapping.findIndex(item => item.source === caseField);
- if (findItemIndex >= 0) {
- onChangeMapping([
- ...myMapping.slice(0, findItemIndex),
- { ...myMapping[findItemIndex], actionType: newActionType },
- ...myMapping.slice(findItemIndex + 1),
- ]);
- }
+ onChangeMapping(setActionTypeToMapping(caseField, newActionType, myMapping));
},
[mapping]
);
@@ -72,22 +70,13 @@ const FieldMappingComponent: React.FC = ({
const onChangeThirdParty = useCallback(
(caseField: CaseField, newThirdPartyField: ThirdPartyField) => {
const myMapping = mapping ?? defaultMapping;
- onChangeMapping(
- myMapping.map(item => {
- if (item.source !== caseField && item.target === newThirdPartyField) {
- return { ...item, target: 'not_mapped' };
- } else if (item.source === caseField) {
- return { ...item, target: newThirdPartyField };
- }
- return item;
- })
- );
+ onChangeMapping(setThirdPartyToMapping(caseField, newThirdPartyField, myMapping));
},
[mapping]
);
return (
<>
-
+
{i18n.FIELD_MAPPING_FIRST_COL}
@@ -100,7 +89,7 @@ const FieldMappingComponent: React.FC = ({
-
+
{(mapping ?? defaultMapping).map(item => (
> = [
+ {
+ value: 'short_description',
+ inputDisplay: {'Short Description'},
+ 'data-test-subj': 'third-party-short-desc',
+ },
+ {
+ value: 'description',
+ inputDisplay: {'Description'},
+ 'data-test-subj': 'third-party-desc',
+ },
+];
+
+describe('FieldMappingRow', () => {
+ let wrapper: ReactWrapper;
+ const onChangeActionType = jest.fn();
+ const onChangeThirdParty = jest.fn();
+
+ const props: RowProps = {
+ disabled: false,
+ siemField: 'title',
+ thirdPartyOptions,
+ onChangeActionType,
+ onChangeThirdParty,
+ selectedActionType: 'nothing',
+ selectedThirdParty: 'short_description',
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it renders', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-third-party-select"]')
+ .first()
+ .exists()
+ ).toBe(true);
+
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-action-type-select"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it passes thirdPartyOptions correctly', () => {
+ const selectProps = wrapper
+ .find(EuiSuperSelect)
+ .first()
+ .props();
+
+ expect(selectProps.options).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ value: 'short_description',
+ 'data-test-subj': 'third-party-short-desc',
+ }),
+ expect.objectContaining({
+ value: 'description',
+ 'data-test-subj': 'third-party-desc',
+ }),
+ ])
+ );
+ });
+
+ test('it passes the correct actionTypeOptions', () => {
+ const selectProps = wrapper
+ .find(EuiSuperSelect)
+ .at(1)
+ .props();
+
+ expect(selectProps.options).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ value: 'nothing',
+ 'data-test-subj': 'edit-update-option-nothing',
+ }),
+ expect.objectContaining({
+ value: 'overwrite',
+ 'data-test-subj': 'edit-update-option-overwrite',
+ }),
+ expect.objectContaining({
+ value: 'append',
+ 'data-test-subj': 'edit-update-option-append',
+ }),
+ ])
+ );
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx
index 62e43c86af8d..732a11a58d35 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx
@@ -21,7 +21,7 @@ import {
ThirdPartyField,
} from '../../../../containers/case/configure/types';
-interface RowProps {
+export interface RowProps {
disabled: boolean;
siemField: CaseField;
thirdPartyOptions: Array>;
@@ -77,6 +77,7 @@ const FieldMappingRowComponent: React.FC = ({
options={thirdPartyOptions}
valueOfSelected={selectedThirdParty}
onChange={onChangeThirdParty.bind(null, siemField)}
+ data-test-subj={'case-configure-third-party-select'}
/>
@@ -85,6 +86,7 @@ const FieldMappingRowComponent: React.FC = ({
options={actionTypeOptions}
valueOfSelected={selectedActionType}
onChange={onChangeActionType.bind(null, siemField)}
+ data-test-subj={'case-configure-action-type-select'}
/>
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx
new file mode 100644
index 000000000000..5ea3f500c034
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx
@@ -0,0 +1,748 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useEffect } from 'react';
+import { ReactWrapper, mount } from 'enzyme';
+
+import { useKibana } from '../../../../lib/kibana';
+import { useConnectors } from '../../../../containers/case/configure/use_connectors';
+import { useCaseConfigure } from '../../../../containers/case/configure/use_configure';
+import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search';
+
+import {
+ connectors,
+ searchURL,
+ useCaseConfigureResponse,
+ useConnectorsResponse,
+ kibanaMockImplementationArgs,
+} from './__mock__';
+
+jest.mock('../../../../lib/kibana');
+jest.mock('../../../../containers/case/configure/use_connectors');
+jest.mock('../../../../containers/case/configure/use_configure');
+jest.mock('../../../../components/navigation/use_get_url_search');
+
+const useKibanaMock = useKibana as jest.Mock;
+const useConnectorsMock = useConnectors as jest.Mock;
+const useCaseConfigureMock = useCaseConfigure as jest.Mock;
+const useGetUrlSearchMock = useGetUrlSearch as jest.Mock;
+
+import { ConfigureCases } from './';
+import { TestProviders } from '../../../../mock';
+import { Connectors } from './connectors';
+import { ClosureOptions } from './closure_options';
+import { Mapping } from './mapping';
+import {
+ ActionsConnectorsContextProvider,
+ ConnectorAddFlyout,
+ ConnectorEditFlyout,
+} from '../../../../../../../../plugins/triggers_actions_ui/public';
+import { EuiBottomBar } from '@elastic/eui';
+
+describe('rendering', () => {
+ let wrapper: ReactWrapper;
+ beforeEach(() => {
+ jest.resetAllMocks();
+ useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
+ useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] }));
+ useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs);
+ useGetUrlSearchMock.mockImplementation(() => searchURL);
+
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it renders the Connectors', () => {
+ expect(wrapper.find('[data-test-subj="case-connectors-form-group"]').exists()).toBeTruthy();
+ });
+
+ test('it renders the ClosureType', () => {
+ expect(
+ wrapper.find('[data-test-subj="case-closure-options-form-group"]').exists()
+ ).toBeTruthy();
+ });
+
+ test('it renders the Mapping', () => {
+ expect(wrapper.find('[data-test-subj="case-mapping-form-group"]').exists()).toBeTruthy();
+ });
+
+ test('it renders the ActionsConnectorsContextProvider', () => {
+ // Components from triggers_actions_ui do not have a data-test-subj
+ expect(wrapper.find(ActionsConnectorsContextProvider).exists()).toBeTruthy();
+ });
+
+ test('it renders the ConnectorAddFlyout', () => {
+ // Components from triggers_actions_ui do not have a data-test-subj
+ expect(wrapper.find(ConnectorAddFlyout).exists()).toBeTruthy();
+ });
+
+ test('it does NOT render the ConnectorEditFlyout', () => {
+ // Components from triggers_actions_ui do not have a data-test-subj
+ expect(wrapper.find(ConnectorEditFlyout).exists()).toBeFalsy();
+ });
+
+ test('it does NOT render the EuiCallOut', () => {
+ expect(wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists()).toBeFalsy();
+ });
+
+ test('it does NOT render the EuiBottomBar', () => {
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeFalsy();
+ });
+});
+
+describe('ConfigureCases - Unhappy path', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
+ useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] }));
+ useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs);
+ useGetUrlSearchMock.mockImplementation(() => searchURL);
+ });
+
+ test('it shows the warning callout when configuration is invalid', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('not-id'), []);
+ return useCaseConfigureResponse;
+ }
+ );
+
+ const wrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(
+ wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists()
+ ).toBeTruthy();
+ });
+});
+
+describe('ConfigureCases - Happy path', () => {
+ let wrapper: ReactWrapper;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('123'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return useCaseConfigureResponse;
+ }
+ );
+ useConnectorsMock.mockImplementation(() => useConnectorsResponse);
+ useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs);
+ useGetUrlSearchMock.mockImplementation(() => searchURL);
+
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it renders the ConnectorEditFlyout', () => {
+ expect(wrapper.find(ConnectorEditFlyout).exists()).toBeTruthy();
+ });
+
+ test('it renders with correct props', () => {
+ // Connector
+ expect(wrapper.find(Connectors).prop('connectors')).toEqual(connectors);
+ expect(wrapper.find(Connectors).prop('disabled')).toBe(false);
+ expect(wrapper.find(Connectors).prop('isLoading')).toBe(false);
+ expect(wrapper.find(Connectors).prop('selectedConnector')).toBe('123');
+
+ // ClosureOptions
+ expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(false);
+ expect(wrapper.find(ClosureOptions).prop('closureTypeSelected')).toBe('close-by-user');
+
+ // Mapping
+ expect(wrapper.find(Mapping).prop('disabled')).toBe(true);
+ expect(wrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(false);
+ expect(wrapper.find(Mapping).prop('mapping')).toEqual(
+ connectors[0].config.casesConfiguration.mapping
+ );
+
+ // Flyouts
+ expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false);
+ expect(wrapper.find(ConnectorAddFlyout).prop('actionTypes')).toEqual([
+ {
+ id: '.servicenow',
+ name: 'ServiceNow',
+ enabled: true,
+ enabledInConfig: true,
+ enabledInLicense: true,
+ minimumLicenseRequired: 'platinum',
+ },
+ ]);
+
+ expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false);
+ expect(wrapper.find(ConnectorEditFlyout).prop('initialConnector')).toEqual(connectors[0]);
+ });
+
+ test('it disables correctly when the user cannot crud', () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(newWrapper.find(Connectors).prop('disabled')).toBe(true);
+ expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true);
+ expect(newWrapper.find(Mapping).prop('disabled')).toBe(true);
+ expect(newWrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(true);
+ });
+
+ test('it disables correctly Connector when loading connectors', () => {
+ useConnectorsMock.mockImplementation(() => ({
+ ...useConnectorsResponse,
+ loading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Connectors).prop('disabled')).toBe(true);
+ });
+
+ test('it disables correctly Connector when saving configuration', () => {
+ useCaseConfigureMock.mockImplementation(() => ({
+ ...useCaseConfigureResponse,
+ persistLoading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Connectors).prop('disabled')).toBe(true);
+ });
+
+ test('it pass the correct value to isLoading attribute on Connector', () => {
+ useConnectorsMock.mockImplementation(() => ({
+ ...useConnectorsResponse,
+ loading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Connectors).prop('isLoading')).toBe(true);
+ });
+
+ test('it set correctly the selected connector', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ return useCaseConfigureResponse;
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Connectors).prop('selectedConnector')).toBe('456');
+ });
+
+ test('it show the add flyout when pressing the add connector button', () => {
+ wrapper.find('button[data-test-subj="case-configure-add-connector-button"]').simulate('click');
+ wrapper.update();
+
+ expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(true);
+ expect(wrapper.find(EuiBottomBar).exists()).toBeFalsy();
+ });
+
+ test('it disables correctly ClosureOptions when loading connectors', () => {
+ useConnectorsMock.mockImplementation(() => ({
+ ...useConnectorsResponse,
+ loading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true);
+ });
+
+ test('it disables correctly ClosureOptions when saving configuration', () => {
+ useCaseConfigureMock.mockImplementation(() => ({
+ ...useCaseConfigureResponse,
+ persistLoading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true);
+ });
+
+ test('it disables correctly ClosureOptions when the connector is set to none', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('none'), []);
+ return useCaseConfigureResponse;
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true);
+ });
+
+ test('it disables the mapping permanently', () => {
+ expect(wrapper.find(Mapping).prop('disabled')).toBe(true);
+ });
+
+ test('it disables the update connector button when loading the connectors', () => {
+ useConnectorsMock.mockImplementation(() => ({
+ ...useConnectorsResponse,
+ loading: true,
+ }));
+
+ expect(wrapper.find(Mapping).prop('disabled')).toBe(true);
+ });
+
+ test('it disables the update connector button when loading the configuration', () => {
+ useCaseConfigureMock.mockImplementation(() => ({
+ ...useCaseConfigureResponse,
+ loading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Mapping).prop('disabled')).toBe(true);
+ });
+
+ test('it disables the update connector button when saving the configuration', () => {
+ useCaseConfigureMock.mockImplementation(() => ({
+ ...useCaseConfigureResponse,
+ persistLoading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Mapping).prop('disabled')).toBe(true);
+ });
+
+ test('it disables the update connector button when the connectorId is invalid', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('not-id'), []);
+ return useCaseConfigureResponse;
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Mapping).prop('disabled')).toBe(true);
+ });
+
+ test('it disables the update connector button when the connectorId is set to none', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('none'), []);
+ return useCaseConfigureResponse;
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(newWrapper.find(Mapping).prop('disabled')).toBe(true);
+ });
+
+ test('it show the edit flyout when pressing the update connector button', () => {
+ wrapper.find('button[data-test-subj="case-mapping-update-connector-button"]').simulate('click');
+ wrapper.update();
+
+ expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(true);
+ expect(wrapper.find(EuiBottomBar).exists()).toBeFalsy();
+ });
+
+ test('it sets the mapping of a connector correctly', () => {
+ expect(wrapper.find(Mapping).prop('mapping')).toEqual(
+ connectors[0].config.casesConfiguration.mapping
+ );
+ });
+
+ // TODO: When mapping is enabled the test.todo should be implemented.
+ test.todo('the mapping is changed successfully when changing the third party');
+ test.todo('the mapping is changed successfully when changing the action type');
+
+ test('it does not shows the action bar when there is no change', () => {
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeFalsy();
+ });
+
+ test('it shows the action bar when the connector is changed', () => {
+ wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
+ wrapper.update();
+ wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeTruthy();
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]')
+ .first()
+ .text()
+ ).toBe('1 unsaved changes');
+ });
+
+ test('it shows the action bar when the closure type is changed', () => {
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeTruthy();
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]')
+ .first()
+ .text()
+ ).toBe('1 unsaved changes');
+ });
+
+ test('it tracks the changes successfully', () => {
+ wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
+ wrapper.update();
+ wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click');
+ wrapper.update();
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeTruthy();
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]')
+ .first()
+ .text()
+ ).toBe('2 unsaved changes');
+ });
+
+ test('it tracks and reverts the changes successfully ', () => {
+ // change settings
+ wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
+ wrapper.update();
+ wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click');
+ wrapper.update();
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+ wrapper.update();
+
+ // revert back to initial settings
+ wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
+ wrapper.update();
+ wrapper.find('button[data-test-subj="dropdown-connector-123"]').simulate('click');
+ wrapper.update();
+ wrapper.find('input[id="close-by-user"]').simulate('change');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeFalsy();
+ });
+
+ test('it close and restores the action bar when the add connector button is pressed', () => {
+ // Change closure type
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+ wrapper.update();
+
+ // Press add connector button
+ wrapper.find('button[data-test-subj="case-configure-add-connector-button"]').simulate('click');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeFalsy();
+
+ expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(true);
+
+ // Close the add flyout
+ wrapper.find('button[data-test-subj="euiFlyoutCloseButton"]').simulate('click');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeTruthy();
+
+ expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false);
+
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]')
+ .first()
+ .text()
+ ).toBe('1 unsaved changes');
+ });
+
+ test('it close and restores the action bar when the update connector button is pressed', () => {
+ // Change closure type
+ wrapper.find('input[id="close-by-pushing"]').simulate('change');
+ wrapper.update();
+
+ // Press update connector button
+ wrapper.find('button[data-test-subj="case-mapping-update-connector-button"]').simulate('click');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeFalsy();
+
+ expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(true);
+
+ // Close the edit flyout
+ wrapper.find('button[data-test-subj="euiFlyoutCloseButton"]').simulate('click');
+ wrapper.update();
+
+ expect(
+ wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeTruthy();
+
+ expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false);
+
+ expect(
+ wrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]')
+ .first()
+ .text()
+ ).toBe('1 unsaved changes');
+ });
+
+ test('it disables the buttons of action bar when loading connectors', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return useCaseConfigureResponse;
+ }
+ );
+
+ useConnectorsMock.mockImplementation(() => ({
+ ...useConnectorsResponse,
+ loading: true,
+ }));
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]')
+ .first()
+ .prop('isDisabled')
+ ).toBe(true);
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]')
+ .first()
+ .prop('isDisabled')
+ ).toBe(true);
+ });
+
+ test('it disables the buttons of action bar when loading configuration', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return { ...useCaseConfigureResponse, loading: true };
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]')
+ .first()
+ .prop('isDisabled')
+ ).toBe(true);
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]')
+ .first()
+ .prop('isDisabled')
+ ).toBe(true);
+ });
+
+ test('it disables the buttons of action bar when saving configuration', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return { ...useCaseConfigureResponse, persistLoading: true };
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]')
+ .first()
+ .prop('isDisabled')
+ ).toBe(true);
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]')
+ .first()
+ .prop('isDisabled')
+ ).toBe(true);
+ });
+
+ test('it shows the loading spinner when saving configuration', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return { ...useCaseConfigureResponse, persistLoading: true };
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]')
+ .first()
+ .prop('isLoading')
+ ).toBe(true);
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]')
+ .first()
+ .prop('isLoading')
+ ).toBe(true);
+ });
+
+ test('it closes the action bar when pressing save', () => {
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return useCaseConfigureResponse;
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]')
+ .first()
+ .simulate('click');
+
+ newWrapper.update();
+
+ expect(
+ newWrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists()
+ ).toBeFalsy();
+ });
+
+ test('it submits the configuration correctly', () => {
+ const persistCaseConfigure = jest.fn();
+
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-pushing',
+ }),
+ []
+ );
+ return { ...useCaseConfigureResponse, persistCaseConfigure };
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]')
+ .first()
+ .simulate('click');
+
+ newWrapper.update();
+
+ expect(persistCaseConfigure).toHaveBeenCalled();
+ expect(persistCaseConfigure).toHaveBeenCalledWith({
+ connectorId: '456',
+ connectorName: 'My Connector 2',
+ closureType: 'close-by-user',
+ });
+ });
+
+ test('it has the correct url on cancel button', () => {
+ const persistCaseConfigure = jest.fn();
+
+ useCaseConfigureMock.mockImplementation(
+ ({ setConnector, setClosureType, setCurrentConfiguration }) => {
+ useEffect(() => setConnector('456'), []);
+ useEffect(() => setClosureType('close-by-user'), []);
+ useEffect(
+ () =>
+ setCurrentConfiguration({
+ connectorId: '123',
+ closureType: 'close-by-user',
+ }),
+ []
+ );
+ return { ...useCaseConfigureResponse, persistCaseConfigure };
+ }
+ );
+
+ const newWrapper = mount(, { wrappingComponent: TestProviders });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]')
+ .first()
+ .prop('href')
+ ).toBe(`#/link-to/case${searchURL}`);
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
index b8cf5a388080..241dcef14a14 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
@@ -140,6 +140,7 @@ const ConfigureCasesComponent: React.FC = ({ userC
setClosureType,
setCurrentConfiguration,
});
+
const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors();
// ActionsConnectorsContextProvider reloadConnectors prop expects a Promise.
@@ -251,7 +252,12 @@ const ConfigureCasesComponent: React.FC = ({ userC
{!connectorIsValid && (
-
+
{i18n.WARNING_NO_CONNECTOR_MESSAGE}
@@ -283,11 +289,13 @@ const ConfigureCasesComponent: React.FC = ({ userC
/>
{actionBarVisible && (
-
+
- {i18n.UNSAVED_CHANGES(totalConfigurationChanges)}
+
+ {i18n.UNSAVED_CHANGES(totalConfigurationChanges)}
+
@@ -300,6 +308,7 @@ const ConfigureCasesComponent: React.FC = ({ userC
isLoading={persistLoading}
aria-label={i18n.CANCEL}
href={getCaseUrl(search)}
+ data-test-subj="case-configure-action-bottom-bar-cancel-button"
>
{i18n.CANCEL}
@@ -313,6 +322,7 @@ const ConfigureCasesComponent: React.FC = ({ userC
isDisabled={isLoadingAny}
isLoading={persistLoading}
onClick={handleSubmit}
+ data-test-subj="case-configure-action-bottom-bar-save-button"
>
{i18n.SAVE_CHANGES}
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.test.tsx
new file mode 100644
index 000000000000..fefcb2ca8cf6
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.test.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+
+import { TestProviders } from '../../../../mock';
+import { Mapping, MappingProps } from './mapping';
+import { mapping } from './__mock__';
+
+describe('Mapping', () => {
+ let wrapper: ReactWrapper;
+ const onChangeMapping = jest.fn();
+ const setEditFlyoutVisibility = jest.fn();
+ const props: MappingProps = {
+ disabled: false,
+ mapping,
+ updateConnectorDisabled: false,
+ onChangeMapping,
+ setEditFlyoutVisibility,
+ };
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ test('it shows mapping form group', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-mapping-form-group"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows mapping form row', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-mapping-form-row"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows the update button', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-mapping-update-connector-button"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+
+ test('it shows the field mapping', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="case-mapping-field"]')
+ .first()
+ .exists()
+ ).toBe(true);
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx
index 8cba73d1249d..7340a49f6d0b 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx
@@ -20,7 +20,7 @@ import * as i18n from './translations';
import { FieldMapping } from './field_mapping';
import { CasesConfigurationMapping } from '../../../../containers/case/configure/types';
-interface MappingProps {
+export interface MappingProps {
disabled: boolean;
updateConnectorDisabled: boolean;
mapping: CasesConfigurationMapping[] | null;
@@ -45,20 +45,27 @@ const MappingComponent: React.FC = ({
fullWidth
title={{i18n.FIELD_MAPPING_TITLE}
}
description={i18n.FIELD_MAPPING_DESC}
+ data-test-subj="case-mapping-form-group"
>
-
+
{i18n.UPDATE_CONNECTOR}
-
+
);
};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.test.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.test.ts
new file mode 100644
index 000000000000..df958b75dc6b
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.test.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { configureCasesReducer, Action, State } from './reducer';
+import { initialState, mapping } from './__mock__';
+
+describe('Reducer', () => {
+ let reducer: (state: State, action: Action) => State;
+
+ beforeAll(() => {
+ reducer = configureCasesReducer();
+ });
+
+ test('it should set the correct configuration', () => {
+ const action: Action = {
+ type: 'setCurrentConfiguration',
+ currentConfiguration: { connectorId: '123', closureType: 'close-by-user' },
+ };
+ const state = reducer(initialState, action);
+
+ expect(state).toEqual({
+ ...state,
+ currentConfiguration: action.currentConfiguration,
+ });
+ });
+
+ test('it should set the correct connector id', () => {
+ const action: Action = {
+ type: 'setConnectorId',
+ connectorId: '456',
+ };
+ const state = reducer(initialState, action);
+
+ expect(state).toEqual({
+ ...state,
+ connectorId: action.connectorId,
+ });
+ });
+
+ test('it should set the closure type', () => {
+ const action: Action = {
+ type: 'setClosureType',
+ closureType: 'close-by-pushing',
+ };
+ const state = reducer(initialState, action);
+
+ expect(state).toEqual({
+ ...state,
+ closureType: action.closureType,
+ });
+ });
+
+ test('it should set the mapping', () => {
+ const action: Action = {
+ type: 'setMapping',
+ mapping,
+ };
+ const state = reducer(initialState, action);
+
+ expect(state).toEqual({
+ ...state,
+ mapping: action.mapping,
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/utils.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/utils.test.tsx
new file mode 100644
index 000000000000..1c6fc9b2d405
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/utils.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { mapping } from './__mock__';
+import { setActionTypeToMapping, setThirdPartyToMapping } from './utils';
+import { CasesConfigurationMapping } from '../../../../containers/case/configure/types';
+
+describe('FieldMappingRow', () => {
+ test('it should change the action type', () => {
+ const newMapping = setActionTypeToMapping('title', 'nothing', mapping);
+ expect(newMapping[0].actionType).toBe('nothing');
+ });
+
+ test('it should not change other fields', () => {
+ const [newTitle, description, comments] = setActionTypeToMapping('title', 'nothing', mapping);
+ expect(newTitle).not.toEqual(mapping[0]);
+ expect(description).toEqual(mapping[1]);
+ expect(comments).toEqual(mapping[2]);
+ });
+
+ test('it should return a new array when changing action type', () => {
+ const newMapping = setActionTypeToMapping('title', 'nothing', mapping);
+ expect(newMapping).not.toBe(mapping);
+ });
+
+ test('it should change the third party', () => {
+ const newMapping = setThirdPartyToMapping('title', 'description', mapping);
+ expect(newMapping[0].target).toBe('description');
+ });
+
+ test('it should not change other fields when there is not a conflict', () => {
+ const tempMapping: CasesConfigurationMapping[] = [
+ {
+ source: 'title',
+ target: 'short_description',
+ actionType: 'overwrite',
+ },
+ {
+ source: 'comments',
+ target: 'comments',
+ actionType: 'append',
+ },
+ ];
+
+ const [newTitle, comments] = setThirdPartyToMapping('title', 'description', tempMapping);
+
+ expect(newTitle).not.toEqual(mapping[0]);
+ expect(comments).toEqual(tempMapping[1]);
+ });
+
+ test('it should return a new array when changing third party', () => {
+ const newMapping = setThirdPartyToMapping('title', 'description', mapping);
+ expect(newMapping).not.toBe(mapping);
+ });
+
+ test('it should change the target of the conflicting third party field to not_mapped', () => {
+ const newMapping = setThirdPartyToMapping('title', 'description', mapping);
+ expect(newMapping[1].target).toBe('not_mapped');
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/utils.ts
new file mode 100644
index 000000000000..2ac6cc1a3858
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/utils.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ CaseField,
+ ActionType,
+ CasesConfigurationMapping,
+ ThirdPartyField,
+} from '../../../../containers/case/configure/types';
+
+export const setActionTypeToMapping = (
+ caseField: CaseField,
+ newActionType: ActionType,
+ mapping: CasesConfigurationMapping[]
+): CasesConfigurationMapping[] => {
+ const findItemIndex = mapping.findIndex(item => item.source === caseField);
+
+ if (findItemIndex >= 0) {
+ return [
+ ...mapping.slice(0, findItemIndex),
+ { ...mapping[findItemIndex], actionType: newActionType },
+ ...mapping.slice(findItemIndex + 1),
+ ];
+ }
+
+ return [...mapping];
+};
+
+export const setThirdPartyToMapping = (
+ caseField: CaseField,
+ newThirdPartyField: ThirdPartyField,
+ mapping: CasesConfigurationMapping[]
+): CasesConfigurationMapping[] =>
+ mapping.map(item => {
+ if (item.source !== caseField && item.target === newThirdPartyField) {
+ return { ...item, target: 'not_mapped' };
+ } else if (item.source === caseField) {
+ return { ...item, target: newThirdPartyField };
+ }
+ return item;
+ });
From 0ebfe76b3fa0cb104c6accf8469fe390ba239b40 Mon Sep 17 00:00:00 2001
From: patrykkopycinski
Date: Mon, 6 Apr 2020 19:26:40 +0200
Subject: [PATCH 17/27] [SIEM][Detection Engine] Fix signals count in Rule
notifications (#62311)
---
.../notifications/get_signals_count.ts | 53 +--
.../rules_notification_alert_type.ts | 22 +-
.../schedule_notification_actions.ts | 4 +-
.../detection_engine/notifications/utils.ts | 13 +-
.../signals/search_after_bulk_create.test.ts | 64 ++-
.../signals/search_after_bulk_create.ts | 13 +-
.../signals/signal_rule_alert_type.test.ts | 399 ++++++++++++++++++
.../signals/signal_rule_alert_type.ts | 31 +-
.../signals/single_bulk_create.test.ts | 40 +-
.../signals/single_bulk_create.ts | 8 +-
10 files changed, 567 insertions(+), 80 deletions(-)
create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts
index 33cee6d074b7..7ff6a4e5164b 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts
@@ -4,63 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import moment from 'moment';
-import { getNotificationResultsLink } from './utils';
-import { NotificationExecutorOptions } from './types';
-import { parseScheduleDates } from '../signals/utils';
+import { AlertServices } from '../../../../../../../plugins/alerting/server';
import { buildSignalsSearchQuery } from './build_signals_query';
-interface SignalsCountResults {
- signalsCount: string;
- resultsLink: string;
-}
-
interface GetSignalsCount {
- from: Date | string;
- to: Date | string;
- ruleAlertId: string;
+ from?: string;
+ to?: string;
ruleId: string;
index: string;
- kibanaSiemAppUrl: string | undefined;
- callCluster: NotificationExecutorOptions['services']['callCluster'];
+ callCluster: AlertServices['callCluster'];
+}
+
+interface CountResult {
+ count: number;
}
export const getSignalsCount = async ({
from,
to,
- ruleAlertId,
ruleId,
index,
callCluster,
- kibanaSiemAppUrl = '',
-}: GetSignalsCount): Promise => {
- const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from);
- const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to);
-
- if (!fromMoment || !toMoment) {
- throw new Error(`There was an issue with parsing ${from} or ${to} into Moment object`);
+}: GetSignalsCount): Promise => {
+ if (from == null || to == null) {
+ throw Error('"from" or "to" was not provided to signals count query');
}
- const fromInMs = fromMoment.format('x');
- const toInMs = toMoment.format('x');
-
const query = buildSignalsSearchQuery({
index,
ruleId,
- to: toInMs,
- from: fromInMs,
+ to,
+ from,
});
- const result = await callCluster('count', query);
- const resultsLink = getNotificationResultsLink({
- kibanaSiemAppUrl: `${kibanaSiemAppUrl}`,
- id: ruleAlertId,
- from: fromInMs,
- to: toInMs,
- });
+ const result: CountResult = await callCluster('count', query);
- return {
- signalsCount: result.count,
- resultsLink,
- };
+ return result.count;
};
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts
index e74da583e919..546488caa5ee 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts
@@ -13,6 +13,8 @@ import { getSignalsCount } from './get_signals_count';
import { RuleAlertAttributes } from '../signals/types';
import { siemRuleActionGroups } from '../signals/siem_rule_action_groups';
import { scheduleNotificationActions } from './schedule_notification_actions';
+import { getNotificationResultsLink } from './utils';
+import { parseScheduleDates } from '../signals/utils';
export const rulesNotificationAlertType = ({
logger,
@@ -42,16 +44,26 @@ export const rulesNotificationAlertType = ({
const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes;
const ruleParams = { ...ruleAlertParams, name: ruleName, id: ruleAlertSavedObject.id };
- const { signalsCount, resultsLink } = await getSignalsCount({
- from: previousStartedAt ?? `now-${ruleParams.interval}`,
- to: startedAt,
+ const fromInMs = parseScheduleDates(
+ previousStartedAt ? previousStartedAt.toISOString() : `now-${ruleParams.interval}`
+ )?.format('x');
+ const toInMs = parseScheduleDates(startedAt.toISOString())?.format('x');
+
+ const signalsCount = await getSignalsCount({
+ from: fromInMs,
+ to: toInMs,
index: ruleParams.outputIndex,
ruleId: ruleParams.ruleId!,
- kibanaSiemAppUrl: ruleAlertParams.meta?.kibanaSiemAppUrl as string,
- ruleAlertId: ruleAlertSavedObject.id,
callCluster: services.callCluster,
});
+ const resultsLink = getNotificationResultsLink({
+ from: fromInMs,
+ to: toInMs,
+ id: ruleAlertSavedObject.id,
+ kibanaSiemAppUrl: ruleAlertParams.meta?.kibanaSiemAppUrl as string,
+ });
+
logger.info(
`Found ${signalsCount} signals using signal rule name: "${ruleParams.name}", id: "${params.ruleAlertId}", rule_id: "${ruleParams.ruleId}" in "${ruleParams.outputIndex}" index`
);
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts
index b858b25377ff..749b892ef506 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts
@@ -15,7 +15,7 @@ type NotificationRuleTypeParams = RuleTypeParams & {
interface ScheduleNotificationActions {
alertInstance: AlertInstance;
- signalsCount: string;
+ signalsCount: number;
resultsLink: string;
ruleParams: NotificationRuleTypeParams;
}
@@ -23,7 +23,7 @@ interface ScheduleNotificationActions {
export const scheduleNotificationActions = ({
alertInstance,
signalsCount,
- resultsLink,
+ resultsLink = '',
ruleParams,
}: ScheduleNotificationActions): AlertInstance =>
alertInstance
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts
index b8a3c4199c4f..5dc7e7fc30b7 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts
@@ -5,14 +5,17 @@
*/
export const getNotificationResultsLink = ({
- kibanaSiemAppUrl,
+ kibanaSiemAppUrl = '/app/siem',
id,
from,
to,
}: {
kibanaSiemAppUrl: string;
id: string;
- from: string;
- to: string;
-}) =>
- `${kibanaSiemAppUrl}#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`;
+ from?: string;
+ to?: string;
+}) => {
+ if (from == null || to == null) return '';
+
+ return `${kibanaSiemAppUrl}#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`;
+};
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
index 06652028b374..414270ffcdd5 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
@@ -34,7 +34,7 @@ describe('searchAfterAndBulkCreate', () => {
test('if successful with empty search results', async () => {
const sampleParams = sampleRuleAlertParams();
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: sampleEmptyDocSearchResults(),
ruleParams: sampleParams,
services: mockService,
@@ -57,6 +57,7 @@ describe('searchAfterAndBulkCreate', () => {
});
expect(mockService.callCluster).toHaveBeenCalledTimes(0);
expect(success).toEqual(true);
+ expect(createdSignalsCount).toEqual(0);
});
test('if successful iteration of while loop with maxDocs', async () => {
@@ -70,6 +71,11 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
})
.mockReturnValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(0, 3)))
@@ -80,6 +86,11 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
})
.mockReturnValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(3, 6)))
@@ -90,9 +101,14 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
});
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(3, 1, someGuids.slice(6, 9)),
ruleParams: sampleParams,
services: mockService,
@@ -115,13 +131,14 @@ describe('searchAfterAndBulkCreate', () => {
});
expect(mockService.callCluster).toHaveBeenCalledTimes(5);
expect(success).toEqual(true);
+ expect(createdSignalsCount).toEqual(3);
});
test('if unsuccessful first bulk create', async () => {
const someGuids = Array.from({ length: 4 }).map(x => uuid.v4());
const sampleParams = sampleRuleAlertParams(10);
mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult);
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@@ -144,6 +161,7 @@ describe('searchAfterAndBulkCreate', () => {
});
expect(mockLogger.error).toHaveBeenCalled();
expect(success).toEqual(false);
+ expect(createdSignalsCount).toEqual(1);
});
test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids', async () => {
@@ -155,9 +173,14 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
});
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: sampleDocSearchResultsNoSortId(),
ruleParams: sampleParams,
services: mockService,
@@ -180,6 +203,7 @@ describe('searchAfterAndBulkCreate', () => {
});
expect(mockLogger.error).toHaveBeenCalled();
expect(success).toEqual(false);
+ expect(createdSignalsCount).toEqual(1);
});
test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids and 0 total hits', async () => {
@@ -191,9 +215,14 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
});
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: sampleDocSearchResultsNoSortIdNoHits(),
ruleParams: sampleParams,
services: mockService,
@@ -215,6 +244,7 @@ describe('searchAfterAndBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(true);
+ expect(createdSignalsCount).toEqual(1);
});
test('if successful iteration of while loop with maxDocs and search after returns results with no sort ids', async () => {
@@ -228,10 +258,15 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
})
.mockReturnValueOnce(sampleDocSearchResultsNoSortId());
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@@ -253,6 +288,7 @@ describe('searchAfterAndBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(true);
+ expect(createdSignalsCount).toEqual(1);
});
test('if successful iteration of while loop with maxDocs and search after returns empty results with no sort ids', async () => {
@@ -266,10 +302,15 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
})
.mockReturnValueOnce(sampleEmptyDocSearchResults());
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@@ -291,6 +332,7 @@ describe('searchAfterAndBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(true);
+ expect(createdSignalsCount).toEqual(1);
});
test('if returns false when singleSearchAfter throws an exception', async () => {
@@ -304,12 +346,17 @@ describe('searchAfterAndBulkCreate', () => {
{
fakeItemValue: 'fakeItemKey',
},
+ {
+ create: {
+ status: 201,
+ },
+ },
],
})
.mockImplementation(() => {
throw Error('Fake Error');
});
- const { success } = await searchAfterAndBulkCreate({
+ const { success, createdSignalsCount } = await searchAfterAndBulkCreate({
someResult: repeatedSearchResultsWithSortId(4, 1, someGuids),
ruleParams: sampleParams,
services: mockService,
@@ -331,5 +378,6 @@ describe('searchAfterAndBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(false);
+ expect(createdSignalsCount).toEqual(1);
});
});
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts
index a5d5dd0a7b71..ff81730bc4a7 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts
@@ -39,6 +39,7 @@ export interface SearchAfterAndBulkCreateReturnType {
searchAfterTimes: string[];
bulkCreateTimes: string[];
lastLookBackDate: Date | null | undefined;
+ createdSignalsCount: number;
}
// search_after through documents and re-index using bulk endpoint.
@@ -68,6 +69,7 @@ export const searchAfterAndBulkCreate = async ({
searchAfterTimes: [],
bulkCreateTimes: [],
lastLookBackDate: null,
+ createdSignalsCount: 0,
};
if (someResult.hits.hits.length === 0) {
toReturn.success = true;
@@ -75,7 +77,7 @@ export const searchAfterAndBulkCreate = async ({
}
logger.debug('[+] starting bulk insertion');
- const { bulkCreateDuration } = await singleBulkCreate({
+ const { bulkCreateDuration, createdItemsCount } = await singleBulkCreate({
someResult,
ruleParams,
services,
@@ -97,6 +99,9 @@ export const searchAfterAndBulkCreate = async ({
someResult.hits.hits.length > 0
? new Date(someResult.hits.hits[someResult.hits.hits.length - 1]?._source['@timestamp'])
: null;
+ if (createdItemsCount) {
+ toReturn.createdSignalsCount = createdItemsCount;
+ }
if (bulkCreateDuration) {
toReturn.bulkCreateTimes.push(bulkCreateDuration);
}
@@ -156,7 +161,10 @@ export const searchAfterAndBulkCreate = async ({
}
sortId = sortIds[0];
logger.debug('next bulk index');
- const { bulkCreateDuration: bulkDuration } = await singleBulkCreate({
+ const {
+ bulkCreateDuration: bulkDuration,
+ createdItemsCount: createdCount,
+ } = await singleBulkCreate({
someResult: searchResult,
ruleParams,
services,
@@ -175,6 +183,7 @@ export const searchAfterAndBulkCreate = async ({
throttle,
});
logger.debug('finished next bulk index');
+ toReturn.createdSignalsCount += createdCount;
if (bulkDuration) {
toReturn.bulkCreateTimes.push(bulkDuration);
}
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts
new file mode 100644
index 000000000000..11d31f180544
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts
@@ -0,0 +1,399 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import moment from 'moment';
+import { savedObjectsClientMock } from 'src/core/server/mocks';
+import { loggerMock } from 'src/core/server/logging/logger.mock';
+import { getResult, getMlResult } from '../routes/__mocks__/request_responses';
+import { signalRulesAlertType } from './signal_rule_alert_type';
+import { AlertInstance } from '../../../../../../../plugins/alerting/server';
+import { ruleStatusServiceFactory } from './rule_status_service';
+import { getGapBetweenRuns } from './utils';
+import { RuleExecutorOptions } from './types';
+import { searchAfterAndBulkCreate } from './search_after_bulk_create';
+import { scheduleNotificationActions } from '../notifications/schedule_notification_actions';
+import { RuleAlertType } from '../rules/types';
+import { findMlSignals } from './find_ml_signals';
+import { bulkCreateMlSignals } from './bulk_create_ml_signals';
+
+jest.mock('./rule_status_saved_objects_client');
+jest.mock('./rule_status_service');
+jest.mock('./search_after_bulk_create');
+jest.mock('./get_filter');
+jest.mock('./utils');
+jest.mock('../notifications/schedule_notification_actions');
+jest.mock('./find_ml_signals');
+jest.mock('./bulk_create_ml_signals');
+
+const getPayload = (
+ ruleAlert: RuleAlertType,
+ alertInstanceFactoryMock: () => AlertInstance,
+ savedObjectsClient: ReturnType,
+ callClusterMock: jest.Mock
+) => ({
+ alertId: ruleAlert.id,
+ services: {
+ savedObjectsClient,
+ alertInstanceFactory: alertInstanceFactoryMock,
+ callCluster: callClusterMock,
+ },
+ params: {
+ ...ruleAlert.params,
+ actions: [],
+ enabled: ruleAlert.enabled,
+ interval: ruleAlert.schedule.interval,
+ name: ruleAlert.name,
+ tags: ruleAlert.tags,
+ throttle: ruleAlert.throttle!,
+ scrollSize: 10,
+ scrollLock: '0',
+ },
+ state: {},
+ spaceId: '',
+ name: 'name',
+ tags: [],
+ startedAt: new Date('2019-12-13T16:50:33.400Z'),
+ previousStartedAt: new Date('2019-12-13T16:40:33.400Z'),
+ createdBy: 'elastic',
+ updatedBy: 'elastic',
+});
+
+describe('rules_notification_alert_type', () => {
+ const version = '8.0.0';
+ const jobsSummaryMock = jest.fn();
+ const mlMock = {
+ mlClient: {
+ callAsInternalUser: jest.fn(),
+ close: jest.fn(),
+ asScoped: jest.fn(),
+ },
+ jobServiceProvider: jest.fn().mockReturnValue({
+ jobsSummary: jobsSummaryMock,
+ }),
+ anomalyDetectorsProvider: jest.fn(),
+ mlSystemProvider: jest.fn(),
+ modulesProvider: jest.fn(),
+ resultsServiceProvider: jest.fn(),
+ };
+ let payload: RuleExecutorOptions;
+ let alert: ReturnType;
+ let alertInstanceMock: Record;
+ let alertInstanceFactoryMock: () => AlertInstance;
+ let savedObjectsClient: ReturnType;
+ let logger: ReturnType;
+ let callClusterMock: jest.Mock;
+ let ruleStatusService: Record;
+
+ beforeEach(() => {
+ alertInstanceMock = {
+ scheduleActions: jest.fn(),
+ replaceState: jest.fn(),
+ };
+ alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock);
+ alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock);
+ callClusterMock = jest.fn();
+ savedObjectsClient = savedObjectsClientMock.create();
+ logger = loggerMock.create();
+ ruleStatusService = {
+ success: jest.fn(),
+ find: jest.fn(),
+ goingToRun: jest.fn(),
+ error: jest.fn(),
+ };
+ (ruleStatusServiceFactory as jest.Mock).mockReturnValue(ruleStatusService);
+ (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(0));
+ (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({
+ success: true,
+ searchAfterTimes: [],
+ createdSignalsCount: 10,
+ });
+ callClusterMock.mockResolvedValue({
+ hits: {
+ total: { value: 10 },
+ },
+ });
+ const ruleAlert = getResult();
+ savedObjectsClient.get.mockResolvedValue({
+ id: 'id',
+ type: 'type',
+ references: [],
+ attributes: ruleAlert,
+ });
+
+ payload = getPayload(ruleAlert, alertInstanceFactoryMock, savedObjectsClient, callClusterMock);
+
+ alert = signalRulesAlertType({
+ logger,
+ version,
+ ml: mlMock,
+ });
+ });
+
+ describe('executor', () => {
+ it('should warn about the gap between runs', async () => {
+ (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(1000));
+ await alert.executor(payload);
+ expect(logger.warn).toHaveBeenCalled();
+ expect(logger.warn.mock.calls[0][0]).toContain(
+ 'a few seconds (1000ms) has passed since last rule execution, and signals may have been missed.'
+ );
+ expect(ruleStatusService.error).toHaveBeenCalled();
+ expect(ruleStatusService.error.mock.calls[0][0]).toContain(
+ 'a few seconds (1000ms) has passed since last rule execution, and signals may have been missed.'
+ );
+ expect(ruleStatusService.error.mock.calls[0][1]).toEqual({
+ gap: 'a few seconds',
+ });
+ });
+
+ it('should call scheduleActions if signalsCount was greater than 0 and rule has actions defined', async () => {
+ const ruleAlert = getResult();
+ ruleAlert.actions = [
+ {
+ actionTypeId: '.slack',
+ params: {
+ message:
+ 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
+ },
+ group: 'default',
+ id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
+ },
+ ];
+
+ savedObjectsClient.get.mockResolvedValue({
+ id: 'id',
+ type: 'type',
+ references: [],
+ attributes: ruleAlert,
+ });
+
+ await alert.executor(payload);
+
+ expect(scheduleNotificationActions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ signalsCount: 10,
+ })
+ );
+ });
+
+ describe('ML rule', () => {
+ it('should throw an error if ML plugin was not available', async () => {
+ const ruleAlert = getMlResult();
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ alert = signalRulesAlertType({
+ logger,
+ version,
+ ml: undefined,
+ });
+ await alert.executor(payload);
+ expect(logger.error).toHaveBeenCalled();
+ expect(logger.error.mock.calls[0][0]).toContain(
+ 'ML plugin unavailable during rule execution'
+ );
+ });
+
+ it('should throw an error if machineLearningJobId or anomalyThreshold was not null', async () => {
+ const ruleAlert = getMlResult();
+ ruleAlert.params.anomalyThreshold = undefined;
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ await alert.executor(payload);
+ expect(logger.error).toHaveBeenCalled();
+ expect(logger.error.mock.calls[0][0]).toContain(
+ 'Machine learning rule is missing job id and/or anomaly threshold'
+ );
+ });
+
+ it('should throw an error if Machine learning job summary was null', async () => {
+ const ruleAlert = getMlResult();
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ jobsSummaryMock.mockResolvedValue([]);
+ await alert.executor(payload);
+ expect(logger.warn).toHaveBeenCalled();
+ expect(logger.warn.mock.calls[0][0]).toContain('Machine learning job is not started');
+ expect(ruleStatusService.error).toHaveBeenCalled();
+ expect(ruleStatusService.error.mock.calls[0][0]).toContain(
+ 'Machine learning job is not started'
+ );
+ });
+
+ it('should log an error if Machine learning job was not started', async () => {
+ const ruleAlert = getMlResult();
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ jobsSummaryMock.mockResolvedValue([
+ {
+ id: 'some_job_id',
+ jobState: 'starting',
+ datafeedState: 'started',
+ },
+ ]);
+ (findMlSignals as jest.Mock).mockResolvedValue({
+ hits: {
+ hits: [],
+ },
+ });
+ await alert.executor(payload);
+ expect(logger.warn).toHaveBeenCalled();
+ expect(logger.warn.mock.calls[0][0]).toContain('Machine learning job is not started');
+ expect(ruleStatusService.error).toHaveBeenCalled();
+ expect(ruleStatusService.error.mock.calls[0][0]).toContain(
+ 'Machine learning job is not started'
+ );
+ });
+
+ it('should not call ruleStatusService.success if no anomalies were found', async () => {
+ const ruleAlert = getMlResult();
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ jobsSummaryMock.mockResolvedValue([]);
+ (findMlSignals as jest.Mock).mockResolvedValue({
+ hits: {
+ hits: [],
+ },
+ });
+ (bulkCreateMlSignals as jest.Mock).mockResolvedValue({
+ success: true,
+ bulkCreateDuration: 0,
+ createdItemsCount: 0,
+ });
+ await alert.executor(payload);
+ expect(ruleStatusService.success).not.toHaveBeenCalled();
+ });
+
+ it('should call ruleStatusService.success if signals were created', async () => {
+ const ruleAlert = getMlResult();
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ jobsSummaryMock.mockResolvedValue([
+ {
+ id: 'some_job_id',
+ jobState: 'started',
+ datafeedState: 'started',
+ },
+ ]);
+ (findMlSignals as jest.Mock).mockResolvedValue({
+ hits: {
+ hits: [{}],
+ },
+ });
+ (bulkCreateMlSignals as jest.Mock).mockResolvedValue({
+ success: true,
+ bulkCreateDuration: 1,
+ createdItemsCount: 1,
+ });
+ await alert.executor(payload);
+ expect(ruleStatusService.success).toHaveBeenCalled();
+ });
+
+ it('should call scheduleActions if signalsCount was greater than 0 and rule has actions defined', async () => {
+ const ruleAlert = getMlResult();
+ ruleAlert.actions = [
+ {
+ actionTypeId: '.slack',
+ params: {
+ message:
+ 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
+ },
+ group: 'default',
+ id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
+ },
+ ];
+ payload = getPayload(
+ ruleAlert,
+ alertInstanceFactoryMock,
+ savedObjectsClient,
+ callClusterMock
+ );
+ savedObjectsClient.get.mockResolvedValue({
+ id: 'id',
+ type: 'type',
+ references: [],
+ attributes: ruleAlert,
+ });
+ jobsSummaryMock.mockResolvedValue([]);
+ (findMlSignals as jest.Mock).mockResolvedValue({
+ hits: {
+ hits: [{}],
+ },
+ });
+ (bulkCreateMlSignals as jest.Mock).mockResolvedValue({
+ success: true,
+ bulkCreateDuration: 1,
+ createdItemsCount: 1,
+ });
+
+ await alert.executor(payload);
+
+ expect(scheduleNotificationActions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ signalsCount: 1,
+ })
+ );
+ });
+ });
+ });
+
+ describe('should catch error', () => {
+ it('when bulk indexing failed', async () => {
+ (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({
+ success: false,
+ searchAfterTimes: [],
+ bulkCreateTimes: [],
+ lastLookBackDate: null,
+ createdSignalsCount: 0,
+ });
+ await alert.executor(payload);
+ expect(logger.error).toHaveBeenCalled();
+ expect(logger.error.mock.calls[0][0]).toContain(
+ 'Bulk Indexing of signals failed. Check logs for further details.'
+ );
+ expect(ruleStatusService.error).toHaveBeenCalled();
+ });
+
+ it('when error was thrown', async () => {
+ (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({});
+ await alert.executor(payload);
+ expect(logger.error).toHaveBeenCalled();
+ expect(logger.error.mock.calls[0][0]).toContain('An error occurred during rule execution');
+ expect(ruleStatusService.error).toHaveBeenCalled();
+ });
+
+ it('and call ruleStatusService with the default message', async () => {
+ (searchAfterAndBulkCreate as jest.Mock).mockRejectedValue({});
+ await alert.executor(payload);
+ expect(logger.error).toHaveBeenCalled();
+ expect(logger.error.mock.calls[0][0]).toContain('An error occurred during rule execution');
+ expect(ruleStatusService.error).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts
index 246701e94c99..417fcbbe42a5 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts
@@ -19,16 +19,16 @@ import {
} from './search_after_bulk_create';
import { getFilter } from './get_filter';
import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types';
-import { getGapBetweenRuns, makeFloatString } from './utils';
+import { getGapBetweenRuns, makeFloatString, parseScheduleDates } from './utils';
import { signalParamsSchema } from './signal_params_schema';
import { siemRuleActionGroups } from './siem_rule_action_groups';
import { findMlSignals } from './find_ml_signals';
import { bulkCreateMlSignals } from './bulk_create_ml_signals';
-import { getSignalsCount } from '../notifications/get_signals_count';
import { scheduleNotificationActions } from '../notifications/schedule_notification_actions';
import { ruleStatusServiceFactory } from './rule_status_service';
import { buildRuleMessageFactory } from './rule_messages';
import { ruleStatusSavedObjectsClientFactory } from './rule_status_saved_objects_client';
+import { getNotificationResultsLink } from '../notifications/utils';
export const signalRulesAlertType = ({
logger,
@@ -71,6 +71,7 @@ export const signalRulesAlertType = ({
bulkCreateTimes: [],
searchAfterTimes: [],
lastLookBackDate: null,
+ createdSignalsCount: 0,
};
const ruleStatusClient = ruleStatusSavedObjectsClientFactory(services.savedObjectsClient);
const ruleStatusService = await ruleStatusServiceFactory({
@@ -161,7 +162,7 @@ export const signalRulesAlertType = ({
logger.info(buildRuleMessage(`Found ${anomalyCount} signals from ML anomalies.`));
}
- const { success, bulkCreateDuration } = await bulkCreateMlSignals({
+ const { success, bulkCreateDuration, createdItemsCount } = await bulkCreateMlSignals({
actions,
throttle,
someResult: anomalyResults,
@@ -180,6 +181,7 @@ export const signalRulesAlertType = ({
tags,
});
result.success = success;
+ result.createdSignalsCount = createdItemsCount;
if (bulkCreateDuration) {
result.bulkCreateTimes.push(bulkCreateDuration);
}
@@ -249,23 +251,26 @@ export const signalRulesAlertType = ({
name,
id: savedObject.id,
};
- const { signalsCount, resultsLink } = await getSignalsCount({
- from: `now-${interval}`,
- to: 'now',
- index: ruleParams.outputIndex,
- ruleId: ruleParams.ruleId!,
+
+ const fromInMs = parseScheduleDates(`now-${interval}`)?.format('x');
+ const toInMs = parseScheduleDates('now')?.format('x');
+
+ const resultsLink = getNotificationResultsLink({
+ from: fromInMs,
+ to: toInMs,
+ id: savedObject.id,
kibanaSiemAppUrl: meta?.kibanaSiemAppUrl as string,
- ruleAlertId: savedObject.id,
- callCluster: services.callCluster,
});
- logger.info(buildRuleMessage(`Found ${signalsCount} signals for notification.`));
+ logger.info(
+ buildRuleMessage(`Found ${result.createdSignalsCount} signals for notification.`)
+ );
- if (signalsCount) {
+ if (result.createdSignalsCount) {
const alertInstance = services.alertInstanceFactory(alertId);
scheduleNotificationActions({
alertInstance,
- signalsCount,
+ signalsCount: result.createdSignalsCount,
resultsLink,
ruleParams: notificationRuleParams,
});
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts
index 45b5610e2d3c..56f061cdfa3c 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts
@@ -144,7 +144,7 @@ describe('singleBulkCreate', () => {
},
],
});
- const { success } = await singleBulkCreate({
+ const { success, createdItemsCount } = await singleBulkCreate({
someResult: sampleDocSearchResultsNoSortId(),
ruleParams: sampleParams,
services: mockService,
@@ -163,6 +163,7 @@ describe('singleBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(true);
+ expect(createdItemsCount).toEqual(0);
});
test('create successful bulk create with docs with no versioning', async () => {
@@ -176,7 +177,7 @@ describe('singleBulkCreate', () => {
},
],
});
- const { success } = await singleBulkCreate({
+ const { success, createdItemsCount } = await singleBulkCreate({
someResult: sampleDocSearchResultsNoSortIdNoVersion(),
ruleParams: sampleParams,
services: mockService,
@@ -195,12 +196,13 @@ describe('singleBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(true);
+ expect(createdItemsCount).toEqual(0);
});
test('create unsuccessful bulk create due to empty search results', async () => {
const sampleParams = sampleRuleAlertParams();
mockService.callCluster.mockReturnValue(false);
- const { success } = await singleBulkCreate({
+ const { success, createdItemsCount } = await singleBulkCreate({
someResult: sampleEmptyDocSearchResults(),
ruleParams: sampleParams,
services: mockService,
@@ -219,13 +221,14 @@ describe('singleBulkCreate', () => {
throttle: 'no_actions',
});
expect(success).toEqual(true);
+ expect(createdItemsCount).toEqual(0);
});
test('create successful bulk create when bulk create has duplicate errors', async () => {
const sampleParams = sampleRuleAlertParams();
const sampleSearchResult = sampleDocSearchResultsNoSortId;
mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult);
- const { success } = await singleBulkCreate({
+ const { success, createdItemsCount } = await singleBulkCreate({
someResult: sampleSearchResult(),
ruleParams: sampleParams,
services: mockService,
@@ -246,13 +249,14 @@ describe('singleBulkCreate', () => {
expect(mockLogger.error).not.toHaveBeenCalled();
expect(success).toEqual(true);
+ expect(createdItemsCount).toEqual(1);
});
test('create successful bulk create when bulk create has multiple error statuses', async () => {
const sampleParams = sampleRuleAlertParams();
const sampleSearchResult = sampleDocSearchResultsNoSortId;
mockService.callCluster.mockReturnValue(sampleBulkCreateErrorResult);
- const { success } = await singleBulkCreate({
+ const { success, createdItemsCount } = await singleBulkCreate({
someResult: sampleSearchResult(),
ruleParams: sampleParams,
services: mockService,
@@ -273,6 +277,7 @@ describe('singleBulkCreate', () => {
expect(mockLogger.error).toHaveBeenCalled();
expect(success).toEqual(true);
+ expect(createdItemsCount).toEqual(1);
});
test('filter duplicate rules will return an empty array given an empty array', () => {
@@ -341,4 +346,29 @@ describe('singleBulkCreate', () => {
},
]);
});
+
+ test('create successful and returns proper createdItemsCount', async () => {
+ const sampleParams = sampleRuleAlertParams();
+ mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult);
+ const { success, createdItemsCount } = await singleBulkCreate({
+ someResult: sampleDocSearchResultsNoSortId(),
+ ruleParams: sampleParams,
+ services: mockService,
+ logger: mockLogger,
+ id: sampleRuleGuid,
+ signalsIndex: DEFAULT_SIGNALS_INDEX,
+ actions: [],
+ name: 'rule-name',
+ createdAt: '2020-01-28T15:58:34.810Z',
+ updatedAt: '2020-01-28T15:59:14.004Z',
+ createdBy: 'elastic',
+ updatedBy: 'elastic',
+ interval: '5m',
+ enabled: true,
+ tags: ['some fake tag 1', 'some fake tag 2'],
+ throttle: 'no_actions',
+ });
+ expect(success).toEqual(true);
+ expect(createdItemsCount).toEqual(1);
+ });
});
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts
index ffec40b839bf..6dd8823b57e4 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts
@@ -58,6 +58,7 @@ export const filterDuplicateRules = (
export interface SingleBulkCreateResponse {
success: boolean;
bulkCreateDuration?: string;
+ createdItemsCount: number;
}
// Bulk Index documents.
@@ -81,7 +82,7 @@ export const singleBulkCreate = async ({
}: SingleBulkCreateParams): Promise => {
someResult.hits.hits = filterDuplicateRules(id, someResult);
if (someResult.hits.hits.length === 0) {
- return { success: true };
+ return { success: true, createdItemsCount: 0 };
}
// index documents after creating an ID based on the
// source documents' originating index, and the original
@@ -145,5 +146,8 @@ export const singleBulkCreate = async ({
);
}
}
- return { success: true, bulkCreateDuration: makeFloatString(end - start) };
+
+ const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0;
+
+ return { success: true, bulkCreateDuration: makeFloatString(end - start), createdItemsCount };
};
From dfa083dc6041edbe584ca58618ecb9fe2f81d81e Mon Sep 17 00:00:00 2001
From: Stacey Gammon
Date: Mon, 6 Apr 2020 13:45:46 -0400
Subject: [PATCH 18/27] Prep for embed saved object refactor + helper (#62486)
---
.../list_container/embeddable_list_item.tsx | 64 ---------------
.../public/list_container/list_container.tsx | 19 ++---
.../list_container_component.tsx | 26 ++++--
.../list_container/list_container_factory.ts | 6 +-
.../multi_task_todo_component.tsx | 17 ++--
.../multi_task_todo_embeddable.tsx | 27 +++----
examples/embeddable_examples/public/plugin.ts | 7 +-
.../searchable_list_container.tsx | 16 ++--
.../searchable_list_container_component.tsx | 79 +++++++++++++------
.../searchable_list_container_factory.ts | 6 +-
.../public/todo/todo_component.tsx | 10 ++-
examples/embeddable_explorer/public/app.tsx | 13 +--
.../public/embeddable_panel_example.tsx | 49 ++----------
.../public/list_container_example.tsx | 10 ++-
.../embeddable_child_panel.test.tsx | 2 +-
.../lib/embeddables/with_subscription.tsx | 12 +--
.../lib/panel/embeddable_panel.test.tsx | 4 +-
.../inspect_panel_action.test.tsx | 2 +-
src/plugins/embeddable/public/mocks.ts | 10 ++-
.../public/{plugin.ts => plugin.tsx} | 54 ++++++++++---
.../public/tests/apply_filter_action.test.ts | 2 +-
.../embeddable/public/tests/test_plugin.ts | 11 ++-
test/examples/embeddables/list_container.ts | 9 +--
.../public/np_ready/public/app/app.tsx | 34 ++------
.../app/dashboard_container_example.tsx | 33 ++------
.../public/np_ready/public/plugin.tsx | 15 +---
26 files changed, 234 insertions(+), 303 deletions(-)
delete mode 100644 examples/embeddable_examples/public/list_container/embeddable_list_item.tsx
rename src/plugins/embeddable/public/{plugin.ts => plugin.tsx} (76%)
diff --git a/examples/embeddable_examples/public/list_container/embeddable_list_item.tsx b/examples/embeddable_examples/public/list_container/embeddable_list_item.tsx
deleted file mode 100644
index 2c80cef8a636..000000000000
--- a/examples/embeddable_examples/public/list_container/embeddable_list_item.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import React from 'react';
-import { EuiPanel, EuiLoadingSpinner, EuiFlexItem } from '@elastic/eui';
-import { IEmbeddable } from '../../../../src/plugins/embeddable/public';
-
-interface Props {
- embeddable: IEmbeddable;
-}
-
-export class EmbeddableListItem extends React.Component {
- private embeddableRoot: React.RefObject;
- private rendered = false;
-
- constructor(props: Props) {
- super(props);
- this.embeddableRoot = React.createRef();
- }
-
- public componentDidMount() {
- if (this.embeddableRoot.current && this.props.embeddable) {
- this.props.embeddable.render(this.embeddableRoot.current);
- this.rendered = true;
- }
- }
-
- public componentDidUpdate() {
- if (this.embeddableRoot.current && this.props.embeddable && !this.rendered) {
- this.props.embeddable.render(this.embeddableRoot.current);
- this.rendered = true;
- }
- }
-
- public render() {
- return (
-
-
- {this.props.embeddable ? (
-
- ) : (
-
- )}
-
-
- );
- }
-}
diff --git a/examples/embeddable_examples/public/list_container/list_container.tsx b/examples/embeddable_examples/public/list_container/list_container.tsx
index bbbd0d6e3230..9e7bec7a1c95 100644
--- a/examples/embeddable_examples/public/list_container/list_container.tsx
+++ b/examples/embeddable_examples/public/list_container/list_container.tsx
@@ -31,16 +31,14 @@ export class ListContainer extends Container<{}, ContainerInput> {
public readonly type = LIST_CONTAINER;
private node?: HTMLElement;
- constructor(
- input: ContainerInput,
- getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']
- ) {
- super(input, { embeddableLoaded: {} }, getEmbeddableFactory);
+ constructor(input: ContainerInput, private embeddableServices: EmbeddableStart) {
+ super(input, { embeddableLoaded: {} }, embeddableServices.getEmbeddableFactory);
}
- // This container has no input itself.
- getInheritedInput(id: string) {
- return {};
+ getInheritedInput() {
+ return {
+ viewMode: this.input.viewMode,
+ };
}
public render(node: HTMLElement) {
@@ -48,7 +46,10 @@ export class ListContainer extends Container<{}, ContainerInput> {
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
- ReactDOM.render(, node);
+ ReactDOM.render(
+ ,
+ node
+ );
}
public destroy() {
diff --git a/examples/embeddable_examples/public/list_container/list_container_component.tsx b/examples/embeddable_examples/public/list_container/list_container_component.tsx
index f6e04933ee89..da27889a2760 100644
--- a/examples/embeddable_examples/public/list_container/list_container_component.tsx
+++ b/examples/embeddable_examples/public/list_container/list_container_component.tsx
@@ -24,30 +24,35 @@ import {
withEmbeddableSubscription,
ContainerInput,
ContainerOutput,
+ EmbeddableStart,
} from '../../../../src/plugins/embeddable/public';
-import { EmbeddableListItem } from './embeddable_list_item';
interface Props {
embeddable: IContainer;
input: ContainerInput;
output: ContainerOutput;
+ embeddableServices: EmbeddableStart;
}
-function renderList(embeddable: IContainer, panels: ContainerInput['panels']) {
+function renderList(
+ embeddable: IContainer,
+ panels: ContainerInput['panels'],
+ embeddableServices: EmbeddableStart
+) {
let number = 0;
const list = Object.values(panels).map(panel => {
const child = embeddable.getChild(panel.explicitInput.id);
number++;
return (
-
+
{number}
-
+
@@ -56,12 +61,12 @@ function renderList(embeddable: IContainer, panels: ContainerInput['panels']) {
return list;
}
-export function ListContainerComponentInner(props: Props) {
+export function ListContainerComponentInner({ embeddable, input, embeddableServices }: Props) {
return (
-
{props.embeddable.getTitle()}
+ {embeddable.getTitle()}
- {renderList(props.embeddable, props.input.panels)}
+ {renderList(embeddable, input.panels, embeddableServices)}
);
}
@@ -71,4 +76,9 @@ export function ListContainerComponentInner(props: Props) {
// anything on input or output state changes. If you don't want that to happen (for example
// if you expect something on input or output state to change frequently that your react
// component does not care about, then you should probably hook this up manually).
-export const ListContainerComponent = withEmbeddableSubscription(ListContainerComponentInner);
+export const ListContainerComponent = withEmbeddableSubscription<
+ ContainerInput,
+ ContainerOutput,
+ IContainer,
+ { embeddableServices: EmbeddableStart }
+>(ListContainerComponentInner);
diff --git a/examples/embeddable_examples/public/list_container/list_container_factory.ts b/examples/embeddable_examples/public/list_container/list_container_factory.ts
index 1fde254110c6..02a024b95349 100644
--- a/examples/embeddable_examples/public/list_container/list_container_factory.ts
+++ b/examples/embeddable_examples/public/list_container/list_container_factory.ts
@@ -26,7 +26,7 @@ import {
import { LIST_CONTAINER, ListContainer } from './list_container';
interface StartServices {
- getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
+ embeddableServices: EmbeddableStart;
}
export class ListContainerFactory implements EmbeddableFactoryDefinition {
@@ -40,8 +40,8 @@ export class ListContainerFactory implements EmbeddableFactoryDefinition {
}
public create = async (initialInput: ContainerInput) => {
- const { getEmbeddableFactory } = await this.getStartServices();
- return new ListContainer(initialInput, getEmbeddableFactory);
+ const { embeddableServices } = await this.getStartServices();
+ return new ListContainer(initialInput, embeddableServices);
};
public getDisplayName() {
diff --git a/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx b/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx
index e33dfab0eaf4..b2882c97ef50 100644
--- a/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx
+++ b/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx
@@ -54,7 +54,7 @@ function wrapSearchTerms(task: string, search?: string) {
);
}
-function renderTasks(tasks: MultiTaskTodoOutput['tasks'], search?: string) {
+function renderTasks(tasks: MultiTaskTodoInput['tasks'], search?: string) {
return tasks.map(task => (
+
{icon ? : }
-
+
{wrapSearchTerms(title, search)}
@@ -89,6 +88,8 @@ export function MultiTaskTodoEmbeddableComponentInner({
);
}
-export const MultiTaskTodoEmbeddableComponent = withEmbeddableSubscription(
- MultiTaskTodoEmbeddableComponentInner
-);
+export const MultiTaskTodoEmbeddableComponent = withEmbeddableSubscription<
+ MultiTaskTodoInput,
+ MultiTaskTodoOutput,
+ MultiTaskTodoEmbeddable
+>(MultiTaskTodoEmbeddableComponentInner);
diff --git a/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_embeddable.tsx b/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_embeddable.tsx
index a2197c9c06fe..a9e58c553810 100644
--- a/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_embeddable.tsx
+++ b/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_embeddable.tsx
@@ -36,30 +36,27 @@ export interface MultiTaskTodoInput extends EmbeddableInput {
title: string;
}
-// This embeddable has output! It's the tasks list that is filtered.
-// Output state is something only the embeddable itself can update. It
-// can be something completely internal, or it can be state that is
+// This embeddable has output! Output state is something only the embeddable itself
+// can update. It can be something completely internal, or it can be state that is
// derived from input state and updates when input does.
export interface MultiTaskTodoOutput extends EmbeddableOutput {
- tasks: string[];
+ hasMatch: boolean;
}
-function getFilteredTasks(tasks: string[], search?: string) {
- const filteredTasks: string[] = [];
- if (search === undefined) return tasks;
+function getHasMatch(tasks: string[], title?: string, search?: string) {
+ if (search === undefined || search === '') return false;
- tasks.forEach(task => {
- if (task.match(search)) {
- filteredTasks.push(task);
- }
- });
+ if (title && title.match(search)) return true;
+
+ const match = tasks.find(task => task.match(search));
+ if (match) return true;
- return filteredTasks;
+ return false;
}
function getOutput(input: MultiTaskTodoInput) {
- const tasks = getFilteredTasks(input.tasks, input.search);
- return { tasks, hasMatch: tasks.length > 0 || (input.search && input.title.match(input.search)) };
+ const hasMatch = getHasMatch(input.tasks, input.title, input.search);
+ return { hasMatch };
}
export class MultiTaskTodoEmbeddable extends Embeddable {
diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts
index 5c202d96ceb1..31a3037332dd 100644
--- a/examples/embeddable_examples/public/plugin.ts
+++ b/examples/embeddable_examples/public/plugin.ts
@@ -53,20 +53,17 @@ export class EmbeddableExamplesPlugin
new MultiTaskTodoEmbeddableFactory()
);
- // These are registered in the start method because `getEmbeddableFactory `
- // is only available in start. We could reconsider this I think and make it
- // available in both.
deps.embeddable.registerEmbeddableFactory(
SEARCHABLE_LIST_CONTAINER,
new SearchableListContainerFactory(async () => ({
- getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory,
+ embeddableServices: (await core.getStartServices())[1].embeddable,
}))
);
deps.embeddable.registerEmbeddableFactory(
LIST_CONTAINER,
new ListContainerFactory(async () => ({
- getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory,
+ embeddableServices: (await core.getStartServices())[1].embeddable,
}))
);
diff --git a/examples/embeddable_examples/public/searchable_list_container/searchable_list_container.tsx b/examples/embeddable_examples/public/searchable_list_container/searchable_list_container.tsx
index 06462937c768..f6efb0b722c4 100644
--- a/examples/embeddable_examples/public/searchable_list_container/searchable_list_container.tsx
+++ b/examples/embeddable_examples/public/searchable_list_container/searchable_list_container.tsx
@@ -40,11 +40,8 @@ export class SearchableListContainer extends Container, node);
+ ReactDOM.render(
+ ,
+ node
+ );
}
public destroy() {
diff --git a/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_component.tsx b/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_component.tsx
index b79f86e2a019..49dbce74788b 100644
--- a/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_component.tsx
+++ b/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_component.tsx
@@ -34,14 +34,15 @@ import {
withEmbeddableSubscription,
ContainerOutput,
EmbeddableOutput,
+ EmbeddableStart,
} from '../../../../src/plugins/embeddable/public';
-import { EmbeddableListItem } from '../list_container/embeddable_list_item';
import { SearchableListContainer, SearchableContainerInput } from './searchable_list_container';
interface Props {
embeddable: SearchableListContainer;
input: SearchableContainerInput;
output: ContainerOutput;
+ embeddableServices: EmbeddableStart;
}
interface State {
@@ -111,13 +112,27 @@ export class SearchableListContainerComponentInner extends Component {
+ const { input, embeddable } = this.props;
+ const checked: { [key: string]: boolean } = {};
+ Object.values(input.panels).map(panel => {
+ const child = embeddable.getChild(panel.explicitInput.id);
+ const output = child.getOutput();
+ if (hasHasMatchOutput(output) && output.hasMatch) {
+ checked[panel.explicitInput.id] = true;
+ }
+ });
+ this.setState({ checked });
+ };
+
private toggleCheck = (isChecked: boolean, id: string) => {
this.setState(prevState => ({ checked: { ...prevState.checked, [id]: isChecked } }));
};
public renderControls() {
+ const { input } = this.props;
return (
-
+
this.deleteChecked()}>
@@ -125,6 +140,17 @@ export class SearchableListContainerComponentInner extends Component
+
+
+ this.checkMatching()}
+ >
+ Check matching
+
+
+
- {embeddable.getTitle()}
-
- {this.renderControls()}
-
- {this.renderList()}
-
+
+
+ {embeddable.getTitle()}
+
+ {this.renderControls()}
+
+ {this.renderList()}
+
+
);
}
private renderList() {
+ const { embeddableServices, input, embeddable } = this.props;
let id = 0;
- const list = Object.values(this.props.input.panels).map(panel => {
- const embeddable = this.props.embeddable.getChild(panel.explicitInput.id);
- if (this.props.input.search && !this.state.hasMatch[panel.explicitInput.id]) return;
+ const list = Object.values(input.panels).map(panel => {
+ const childEmbeddable = embeddable.getChild(panel.explicitInput.id);
id++;
- return embeddable ? (
-
-
+ return childEmbeddable ? (
+
+
this.toggleCheck(e.target.checked, embeddable.id)}
+ data-test-subj={`todoCheckBox-${childEmbeddable.id}`}
+ disabled={!childEmbeddable}
+ id={childEmbeddable ? childEmbeddable.id : ''}
+ checked={this.state.checked[childEmbeddable.id]}
+ onChange={e => this.toggleCheck(e.target.checked, childEmbeddable.id)}
/>
-
+
@@ -183,6 +211,9 @@ export class SearchableListContainerComponentInner extends Component(SearchableListContainerComponentInner);
diff --git a/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_factory.ts b/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_factory.ts
index 382bb65e769e..34ea43c29462 100644
--- a/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_factory.ts
+++ b/examples/embeddable_examples/public/searchable_list_container/searchable_list_container_factory.ts
@@ -29,7 +29,7 @@ import {
} from './searchable_list_container';
interface StartServices {
- getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
+ embeddableServices: EmbeddableStart;
}
export class SearchableListContainerFactory implements EmbeddableFactoryDefinition {
@@ -43,8 +43,8 @@ export class SearchableListContainerFactory implements EmbeddableFactoryDefiniti
}
public create = async (initialInput: SearchableContainerInput) => {
- const { getEmbeddableFactory } = await this.getStartServices();
- return new SearchableListContainer(initialInput, getEmbeddableFactory);
+ const { embeddableServices } = await this.getStartServices();
+ return new SearchableListContainer(initialInput, embeddableServices);
};
public getDisplayName() {
diff --git a/examples/embeddable_examples/public/todo/todo_component.tsx b/examples/embeddable_examples/public/todo/todo_component.tsx
index fbebfc98627b..a4593bea3cc5 100644
--- a/examples/embeddable_examples/public/todo/todo_component.tsx
+++ b/examples/embeddable_examples/public/todo/todo_component.tsx
@@ -51,12 +51,12 @@ function wrapSearchTerms(task: string, search?: string) {
export function TodoEmbeddableComponentInner({ input: { icon, title, task, search } }: Props) {
return (
-
+
{icon ? : }
-
+
{wrapSearchTerms(title || '', search)}
@@ -71,4 +71,8 @@ export function TodoEmbeddableComponentInner({ input: { icon, title, task, searc
);
}
-export const TodoEmbeddableComponent = withEmbeddableSubscription(TodoEmbeddableComponentInner);
+export const TodoEmbeddableComponent = withEmbeddableSubscription<
+ TodoInput,
+ EmbeddableOutput,
+ TodoEmbeddable
+>(TodoEmbeddableComponentInner);
diff --git a/examples/embeddable_explorer/public/app.tsx b/examples/embeddable_explorer/public/app.tsx
index 9c8568454855..e18012b4b3d8 100644
--- a/examples/embeddable_explorer/public/app.tsx
+++ b/examples/embeddable_explorer/public/app.tsx
@@ -117,18 +117,7 @@ const EmbeddableExplorerApp = ({
{
title: 'Dynamically adding children to a container',
id: 'embeddablePanelExamplae',
- component: (
-
- ),
+ component: ,
},
];
diff --git a/examples/embeddable_explorer/public/embeddable_panel_example.tsx b/examples/embeddable_explorer/public/embeddable_panel_example.tsx
index b26111bed7ff..54cd7c5b5b2c 100644
--- a/examples/embeddable_explorer/public/embeddable_panel_example.tsx
+++ b/examples/embeddable_explorer/public/embeddable_panel_example.tsx
@@ -29,43 +29,19 @@ import {
EuiText,
} from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
-import { OverlayStart, CoreStart, SavedObjectsStart, IUiSettingsClient } from 'kibana/public';
-import {
- EmbeddablePanel,
- EmbeddableStart,
- IEmbeddable,
-} from '../../../src/plugins/embeddable/public';
+import { EmbeddableStart, IEmbeddable } from '../../../src/plugins/embeddable/public';
import {
HELLO_WORLD_EMBEDDABLE,
TODO_EMBEDDABLE,
MULTI_TASK_TODO_EMBEDDABLE,
SEARCHABLE_LIST_CONTAINER,
} from '../../embeddable_examples/public';
-import { UiActionsStart } from '../../../src/plugins/ui_actions/public';
-import { Start as InspectorStartContract } from '../../../src/plugins/inspector/public';
-import { getSavedObjectFinder } from '../../../src/plugins/saved_objects/public';
interface Props {
- getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories'];
- getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
- uiActionsApi: UiActionsStart;
- overlays: OverlayStart;
- notifications: CoreStart['notifications'];
- inspector: InspectorStartContract;
- savedObject: SavedObjectsStart;
- uiSettingsClient: IUiSettingsClient;
+ embeddableServices: EmbeddableStart;
}
-export function EmbeddablePanelExample({
- inspector,
- notifications,
- overlays,
- getAllEmbeddableFactories,
- getEmbeddableFactory,
- uiActionsApi,
- savedObject,
- uiSettingsClient,
-}: Props) {
+export function EmbeddablePanelExample({ embeddableServices }: Props) {
const searchableInput = {
id: '1',
title: 'My searchable todo list',
@@ -105,7 +81,7 @@ export function EmbeddablePanelExample({
useEffect(() => {
ref.current = true;
if (!embeddable) {
- const factory = getEmbeddableFactory(SEARCHABLE_LIST_CONTAINER);
+ const factory = embeddableServices.getEmbeddableFactory(SEARCHABLE_LIST_CONTAINER);
const promise = factory?.create(searchableInput);
if (promise) {
promise.then(e => {
@@ -134,22 +110,13 @@ export function EmbeddablePanelExample({
You can render your embeddable inside the EmbeddablePanel component. This adds some
extra rendering and offers a context menu with pluggable actions. Using EmbeddablePanel
- to render your embeddable means you get access to the "e;Add panel flyout"e;.
- Now you can see how to add embeddables to your container, and how
- "e;getExplicitInput"e; is used to grab input not provided by the container.
+ to render your embeddable means you get access to the "Add panel flyout". Now
+ you can see how to add embeddables to your container, and how
+ "getExplicitInput" is used to grab input not provided by the container.
{embeddable ? (
-
+
) : (
Loading...
)}
diff --git a/examples/embeddable_explorer/public/list_container_example.tsx b/examples/embeddable_explorer/public/list_container_example.tsx
index 969fdb0ca46d..98ad50418d3f 100644
--- a/examples/embeddable_explorer/public/list_container_example.tsx
+++ b/examples/embeddable_explorer/public/list_container_example.tsx
@@ -29,7 +29,11 @@ import {
EuiText,
} from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
-import { EmbeddableFactoryRenderer, EmbeddableStart } from '../../../src/plugins/embeddable/public';
+import {
+ EmbeddableFactoryRenderer,
+ EmbeddableStart,
+ ViewMode,
+} from '../../../src/plugins/embeddable/public';
import {
HELLO_WORLD_EMBEDDABLE,
TODO_EMBEDDABLE,
@@ -46,6 +50,7 @@ export function ListContainerExample({ getEmbeddableFactory }: Props) {
const listInput = {
id: 'hello',
title: 'My todo list',
+ viewMode: ViewMode.VIEW,
panels: {
'1': {
type: HELLO_WORLD_EMBEDDABLE,
@@ -76,6 +81,7 @@ export function ListContainerExample({ getEmbeddableFactory }: Props) {
const searchableInput = {
id: '1',
title: 'My searchable todo list',
+ viewMode: ViewMode.VIEW,
panels: {
'1': {
type: HELLO_WORLD_EMBEDDABLE,
@@ -150,7 +156,7 @@ export function ListContainerExample({ getEmbeddableFactory }: Props) {
- Check out the "e;Dynamically adding children"e; section, to see how to add
+ Check out the "Dynamically adding children" section, to see how to add
children to this container, and see it rendered inside an `EmbeddablePanel` component.
diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx
index 9e47da5cea03..2a0ffd723850 100644
--- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx
+++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx
@@ -29,7 +29,7 @@ import {
ContactCardEmbeddable,
} from '../test_samples/embeddables/contact_card/contact_card_embeddable';
// eslint-disable-next-line
-import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks';
+import { inspectorPluginMock } from '../../../../inspector/public/mocks';
import { mount } from 'enzyme';
import { embeddablePluginMock } from '../../mocks';
diff --git a/src/plugins/embeddable/public/lib/embeddables/with_subscription.tsx b/src/plugins/embeddable/public/lib/embeddables/with_subscription.tsx
index 47b8001961cf..9bc5889715c7 100644
--- a/src/plugins/embeddable/public/lib/embeddables/with_subscription.tsx
+++ b/src/plugins/embeddable/public/lib/embeddables/with_subscription.tsx
@@ -23,18 +23,19 @@ import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable';
export const withEmbeddableSubscription = <
I extends EmbeddableInput,
O extends EmbeddableOutput,
- E extends IEmbeddable = IEmbeddable
+ E extends IEmbeddable = IEmbeddable,
+ ExtraProps = {}
>(
- WrappedComponent: React.ComponentType<{ input: I; output: O; embeddable: E }>
-): React.ComponentType<{ embeddable: E }> =>
+ WrappedComponent: React.ComponentType<{ input: I; output: O; embeddable: E } & ExtraProps>
+): React.ComponentType<{ embeddable: E } & ExtraProps> =>
class WithEmbeddableSubscription extends React.Component<
- { embeddable: E },
+ { embeddable: E } & ExtraProps,
{ input: I; output: O }
> {
private subscription?: Rx.Subscription;
private mounted: boolean = false;
- constructor(props: { embeddable: E }) {
+ constructor(props: { embeddable: E } & ExtraProps) {
super(props);
this.state = {
input: this.props.embeddable.getInput(),
@@ -71,6 +72,7 @@ export const withEmbeddableSubscription = <
input={this.state.input}
output={this.state.output}
embeddable={this.props.embeddable}
+ {...this.props}
/>
);
}
diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
index 649677dc67c7..1e7cbb2f3daf 100644
--- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
+++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
@@ -25,7 +25,7 @@ import { nextTick } from 'test_utils/enzyme_helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { I18nProvider } from '@kbn/i18n/react';
import { CONTEXT_MENU_TRIGGER } from '../triggers';
-import { Action, UiActionsStart, ActionType } from 'src/plugins/ui_actions/public';
+import { Action, UiActionsStart, ActionType } from '../../../../ui_actions/public';
import { Trigger, ViewMode } from '../types';
import { isErrorEmbeddable } from '../embeddables';
import { EmbeddablePanel } from './embeddable_panel';
@@ -41,7 +41,7 @@ import {
ContactCardEmbeddableOutput,
} from '../test_samples/embeddables/contact_card/contact_card_embeddable';
// eslint-disable-next-line
-import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks';
+import { inspectorPluginMock } from '../../../../inspector/public/mocks';
import { EuiBadge } from '@elastic/eui';
import { embeddablePluginMock } from '../../mocks';
diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.test.tsx
index ee31127cb5a4..491eaad9faef 100644
--- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.test.tsx
+++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.test.tsx
@@ -27,7 +27,7 @@ import {
ContactCardEmbeddable,
} from '../../../test_samples';
// eslint-disable-next-line
-import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks';
+import { inspectorPluginMock } from '../../../../../../../plugins/inspector/public/mocks';
import { EmbeddableOutput, isErrorEmbeddable, ErrorEmbeddable } from '../../../embeddables';
import { of } from '../../../../tests/helpers';
import { esFilters } from '../../../../../../../plugins/data/public';
diff --git a/src/plugins/embeddable/public/mocks.ts b/src/plugins/embeddable/public/mocks.ts
index 2ee05d8316ac..65b15f3a7614 100644
--- a/src/plugins/embeddable/public/mocks.ts
+++ b/src/plugins/embeddable/public/mocks.ts
@@ -16,11 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-
import { EmbeddableStart, EmbeddableSetup } from '.';
import { EmbeddablePublicPlugin } from './plugin';
import { coreMock } from '../../../core/public/mocks';
+// eslint-disable-next-line
+import { inspectorPluginMock } from '../../inspector/public/mocks';
// eslint-disable-next-line
import { uiActionsPluginMock } from '../../ui_actions/public/mocks';
@@ -39,6 +40,7 @@ const createStartContract = (): Start => {
const startContract: Start = {
getEmbeddableFactories: jest.fn(),
getEmbeddableFactory: jest.fn(),
+ EmbeddablePanel: jest.fn(),
};
return startContract;
};
@@ -48,7 +50,11 @@ const createInstance = () => {
const setup = plugin.setup(coreMock.createSetup(), {
uiActions: uiActionsPluginMock.createSetupContract(),
});
- const doStart = () => plugin.start(coreMock.createStart());
+ const doStart = () =>
+ plugin.start(coreMock.createStart(), {
+ uiActions: uiActionsPluginMock.createStartContract(),
+ inspector: inspectorPluginMock.createStartContract(),
+ });
return {
plugin,
setup,
diff --git a/src/plugins/embeddable/public/plugin.ts b/src/plugins/embeddable/public/plugin.tsx
similarity index 76%
rename from src/plugins/embeddable/public/plugin.ts
rename to src/plugins/embeddable/public/plugin.tsx
index a483f90f76dd..01fbf52c8018 100644
--- a/src/plugins/embeddable/public/plugin.ts
+++ b/src/plugins/embeddable/public/plugin.tsx
@@ -16,7 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { UiActionsSetup } from 'src/plugins/ui_actions/public';
+import React from 'react';
+import { getSavedObjectFinder } from '../../saved_objects/public';
+import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public';
+import { Start as InspectorStart } from '../../inspector/public';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public';
import { EmbeddableFactoryRegistry, EmbeddableFactoryProvider } from './types';
import { bootstrap } from './bootstrap';
@@ -26,6 +29,7 @@ import {
EmbeddableOutput,
defaultEmbeddableFactoryProvider,
IEmbeddable,
+ EmbeddablePanel,
} from './lib';
import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition';
@@ -33,6 +37,11 @@ export interface EmbeddableSetupDependencies {
uiActions: UiActionsSetup;
}
+export interface EmbeddableStartDependencies {
+ uiActions: UiActionsStart;
+ inspector: InspectorStart;
+}
+
export interface EmbeddableSetup {
registerEmbeddableFactory: (
id: string,
@@ -50,6 +59,7 @@ export interface EmbeddableStart {
embeddableFactoryId: string
) => EmbeddableFactory | undefined;
getEmbeddableFactories: () => IterableIterator;
+ EmbeddablePanel: React.FC<{ embeddable: IEmbeddable; hideHeader?: boolean }>;
}
export class EmbeddablePublicPlugin implements Plugin {
@@ -78,7 +88,10 @@ export class EmbeddablePublicPlugin implements Plugin {
this.embeddableFactories.set(
def.type,
@@ -89,15 +102,36 @@ export class EmbeddablePublicPlugin implements Plugin {
- this.ensureFactoriesExist();
- return this.embeddableFactories.values();
- },
+ getEmbeddableFactories: this.getEmbeddableFactories,
+ EmbeddablePanel: ({
+ embeddable,
+ hideHeader,
+ }: {
+ embeddable: IEmbeddable;
+ hideHeader?: boolean;
+ }) => (
+
+ ),
};
}
public stop() {}
+ private getEmbeddableFactories = () => {
+ this.ensureFactoriesExist();
+ return this.embeddableFactories.values();
+ };
+
private registerEmbeddableFactory = (
embeddableFactoryId: string,
factory: EmbeddableFactoryDefinition
@@ -130,11 +164,11 @@ export class EmbeddablePublicPlugin implements Plugin {
this.embeddableFactoryDefinitions.forEach(def => this.ensureFactoryExists(def.type));
- }
+ };
- private ensureFactoryExists(type: string) {
+ private ensureFactoryExists = (type: string) => {
if (!this.embeddableFactories.get(type)) {
const def = this.embeddableFactoryDefinitions.get(type);
if (!def) return;
@@ -145,5 +179,5 @@ export class EmbeddablePublicPlugin implements Plugin {
diff --git a/src/plugins/embeddable/public/tests/test_plugin.ts b/src/plugins/embeddable/public/tests/test_plugin.ts
index e199ef193aa1..e13a906e3033 100644
--- a/src/plugins/embeddable/public/tests/test_plugin.ts
+++ b/src/plugins/embeddable/public/tests/test_plugin.ts
@@ -18,9 +18,11 @@
*/
import { CoreSetup, CoreStart } from 'src/core/public';
+import { UiActionsStart } from '../../../ui_actions/public';
// eslint-disable-next-line
-import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks';
-import { UiActionsStart } from 'src/plugins/ui_actions/public';
+import { uiActionsPluginMock } from '../../../ui_actions/public/mocks';
+// eslint-disable-next-line
+import { inspectorPluginMock } from '../../../inspector/public/mocks';
import { coreMock } from '../../../../core/public/mocks';
import { EmbeddablePublicPlugin, EmbeddableSetup, EmbeddableStart } from '../plugin';
@@ -48,7 +50,10 @@ export const testPlugin = (
coreStart,
setup,
doStart: (anotherCoreStart: CoreStart = coreStart) => {
- const start = plugin.start(anotherCoreStart);
+ const start = plugin.start(anotherCoreStart, {
+ uiActions: uiActionsPluginMock.createStartContract(),
+ inspector: inspectorPluginMock.createStartContract(),
+ });
return start;
},
uiActions: uiActions.doStart(coreStart),
diff --git a/test/examples/embeddables/list_container.ts b/test/examples/embeddables/list_container.ts
index b1b91ad2c37f..9e93d479471e 100644
--- a/test/examples/embeddables/list_container.ts
+++ b/test/examples/embeddables/list_container.ts
@@ -57,13 +57,12 @@ export default function({ getService }: PluginFunctionalProviderContext) {
expect(text).to.eql(['HELLO WORLD!']);
});
- it('searchable container filters multi-task children', async () => {
+ it('searchable container finds matches in multi-task children', async () => {
await testSubjects.setValue('filterTodos', 'earth');
+ await testSubjects.click('checkMatchingTodos');
+ await testSubjects.click('deleteCheckedTodos');
- await retry.try(async () => {
- const tasks = await testSubjects.getVisibleTextAll('multiTaskTodoTask');
- expect(tasks).to.eql(['Watch planet earth']);
- });
+ await testSubjects.missingOrFail('multiTaskTodoTask');
});
});
}
diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx
index 54d13efe4d79..2ecde823dc4d 100644
--- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx
+++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx
@@ -18,21 +18,11 @@
*/
import { EuiTab } from '@elastic/eui';
import React, { Component } from 'react';
-import { CoreStart } from 'src/core/public';
import { EmbeddableStart } from 'src/plugins/embeddable/public';
-import { UiActionsService } from '../../../../../../../../src/plugins/ui_actions/public';
import { DashboardContainerExample } from './dashboard_container_example';
-import { Start as InspectorStartContract } from '../../../../../../../../src/plugins/inspector/public';
export interface AppProps {
- getActions: UiActionsService['getTriggerCompatibleActions'];
- getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
- getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories'];
- overlays: CoreStart['overlays'];
- notifications: CoreStart['notifications'];
- inspector: InspectorStartContract;
- SavedObjectFinder: React.ComponentType;
- I18nContext: CoreStart['i18n']['Context'];
+ embeddableServices: EmbeddableStart;
}
export class App extends Component {
@@ -72,29 +62,17 @@ export class App extends Component {
public render() {
return (
-
-
-
{this.renderTabs()}
- {this.getContentsForTab()}
-
-
+
+
{this.renderTabs()}
+ {this.getContentsForTab()}
+
);
}
private getContentsForTab() {
switch (this.state.selectedTabId) {
case 'dashboardContainer': {
- return (
-
- );
+ return ;
}
}
}
diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx
index fd07416cadbc..16c2840d6a32 100644
--- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx
+++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx
@@ -19,32 +19,17 @@
import React from 'react';
import { EuiButton, EuiLoadingChart } from '@elastic/eui';
import { ContainerOutput } from 'src/plugins/embeddable/public';
-import {
- ErrorEmbeddable,
- ViewMode,
- isErrorEmbeddable,
- EmbeddablePanel,
- EmbeddableStart,
-} from '../embeddable_api';
+import { ErrorEmbeddable, ViewMode, isErrorEmbeddable, EmbeddableStart } from '../embeddable_api';
import {
DASHBOARD_CONTAINER_TYPE,
DashboardContainer,
DashboardContainerInput,
} from '../../../../../../../../src/plugins/dashboard/public';
-import { CoreStart } from '../../../../../../../../src/core/public';
import { dashboardInput } from './dashboard_input';
-import { Start as InspectorStartContract } from '../../../../../../../../src/plugins/inspector/public';
-import { UiActionsService } from '../../../../../../../../src/plugins/ui_actions/public';
interface Props {
- getActions: UiActionsService['getTriggerCompatibleActions'];
- getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
- getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories'];
- overlays: CoreStart['overlays'];
- notifications: CoreStart['notifications'];
- inspector: InspectorStartContract;
- SavedObjectFinder: React.ComponentType;
+ embeddableServices: EmbeddableStart;
}
interface State {
@@ -67,7 +52,7 @@ export class DashboardContainerExample extends React.Component {
public async componentDidMount() {
this.mounted = true;
- const dashboardFactory = this.props.getEmbeddableFactory<
+ const dashboardFactory = this.props.embeddableServices.getEmbeddableFactory<
DashboardContainerInput,
ContainerOutput,
DashboardContainer
@@ -99,6 +84,7 @@ export class DashboardContainerExample extends React.Component {
};
public render() {
+ const { embeddableServices } = this.props;
return (
Dashboard Container
@@ -108,16 +94,7 @@ export class DashboardContainerExample extends React.Component
{
{!this.state.loaded || !this.container ? (
) : (
-
+
)}
);
diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx
index 18ceec652392..e5f5faa6ac36 100644
--- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx
+++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx
@@ -33,7 +33,6 @@ const REACT_ROOT_ID = 'embeddableExplorerRoot';
import { SayHelloAction, createSendMessageAction } from './embeddable_api';
import { App } from './app';
-import { getSavedObjectFinder } from '../../../../../../../src/plugins/saved_objects/public';
import {
EmbeddableStart,
EmbeddableSetup,
@@ -78,19 +77,7 @@ export class EmbeddableExplorerPublicPlugin
plugins.__LEGACY.onRenderComplete(() => {
const root = document.getElementById(REACT_ROOT_ID);
- ReactDOM.render(
- ,
- root
- );
+ ReactDOM.render(, root);
});
}
From 29abe5b81bddd17dcdd671cf3456f99bfe7b08a0 Mon Sep 17 00:00:00 2001
From: nnamdifrankie <56440728+nnamdifrankie@users.noreply.github.com>
Date: Mon, 6 Apr 2020 13:54:21 -0400
Subject: [PATCH 19/27] [Ingest] EMT-146: agent status impl preparation
(#62557)
[Ingest] EMT-146: very light refactor a precursor for endpoint status change
---
x-pack/plugins/endpoint/server/plugin.ts | 2 +-
.../endpoint/server/routes/alerts/details/handlers.ts | 2 +-
.../server/routes/{metadata.ts => metadata/index.ts} | 9 +++------
.../server/routes/{ => metadata}/metadata.test.ts | 10 +++++-----
.../metadata/query_builders.test.ts} | 5 +----
.../metadata/query_builders.ts} | 4 ++--
6 files changed, 13 insertions(+), 19 deletions(-)
rename x-pack/plugins/endpoint/server/routes/{metadata.ts => metadata/index.ts} (93%)
rename x-pack/plugins/endpoint/server/routes/{ => metadata}/metadata.test.ts (96%)
rename x-pack/plugins/endpoint/server/{services/endpoint/metadata_query_builders.test.ts => routes/metadata/query_builders.test.ts} (97%)
rename x-pack/plugins/endpoint/server/{services/endpoint/metadata_query_builders.ts => routes/metadata/query_builders.ts} (100%)
diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts
index 6d2e9e510551..d3a399124124 100644
--- a/x-pack/plugins/endpoint/server/plugin.ts
+++ b/x-pack/plugins/endpoint/server/plugin.ts
@@ -9,9 +9,9 @@ import { PluginSetupContract as FeaturesPluginSetupContract } from '../../featur
import { createConfig$, EndpointConfigType } from './config';
import { EndpointAppContext } from './types';
-import { registerEndpointRoutes } from './routes/metadata';
import { registerAlertRoutes } from './routes/alerts';
import { registerResolverRoutes } from './routes/resolver';
+import { registerEndpointRoutes } from './routes/metadata';
export type EndpointPluginStart = void;
export type EndpointPluginSetup = void;
diff --git a/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts b/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts
index b95c1aaf87c1..725e362f91ec 100644
--- a/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts
+++ b/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts
@@ -9,7 +9,7 @@ import { AlertEvent, EndpointAppConstants } from '../../../../common/types';
import { EndpointAppContext } from '../../../types';
import { AlertDetailsRequestParams } from '../types';
import { AlertDetailsPagination } from './lib';
-import { getHostData } from '../../../routes/metadata';
+import { getHostData } from '../../metadata';
export const alertDetailsHandlerWrapper = function(
endpointAppContext: EndpointAppContext
diff --git a/x-pack/plugins/endpoint/server/routes/metadata.ts b/x-pack/plugins/endpoint/server/routes/metadata/index.ts
similarity index 93%
rename from x-pack/plugins/endpoint/server/routes/metadata.ts
rename to x-pack/plugins/endpoint/server/routes/metadata/index.ts
index 787ffe58a537..ef01db9af98c 100644
--- a/x-pack/plugins/endpoint/server/routes/metadata.ts
+++ b/x-pack/plugins/endpoint/server/routes/metadata/index.ts
@@ -8,12 +8,9 @@ import { IRouter, RequestHandlerContext } from 'kibana/server';
import { SearchResponse } from 'elasticsearch';
import { schema } from '@kbn/config-schema';
-import {
- kibanaRequestToMetadataListESQuery,
- getESQueryHostMetadataByID,
-} from '../services/endpoint/metadata_query_builders';
-import { HostMetadata, HostResultList } from '../../common/types';
-import { EndpointAppContext } from '../types';
+import { HostMetadata, HostResultList } from '../../../common/types';
+import { EndpointAppContext } from '../../types';
+import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders';
interface HitSource {
_source: HostMetadata;
diff --git a/x-pack/plugins/endpoint/server/routes/metadata.test.ts b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts
similarity index 96%
rename from x-pack/plugins/endpoint/server/routes/metadata.test.ts
rename to x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts
index 65e07edbcde2..e0fd11e737e7 100644
--- a/x-pack/plugins/endpoint/server/routes/metadata.test.ts
+++ b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts
@@ -17,12 +17,12 @@ import {
httpServerMock,
httpServiceMock,
loggingServiceMock,
-} from '../../../../../src/core/server/mocks';
-import { HostMetadata, HostResultList } from '../../common/types';
+} from '../../../../../../src/core/server/mocks';
+import { HostMetadata, HostResultList } from '../../../common/types';
import { SearchResponse } from 'elasticsearch';
-import { registerEndpointRoutes } from './metadata';
-import { EndpointConfigSchema } from '../config';
-import * as data from '../test_data/all_metadata_data.json';
+import { EndpointConfigSchema } from '../../config';
+import * as data from '../../test_data/all_metadata_data.json';
+import { registerEndpointRoutes } from './index';
describe('test endpoint route', () => {
let routerMock: jest.Mocked;
diff --git a/x-pack/plugins/endpoint/server/services/endpoint/metadata_query_builders.test.ts b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts
similarity index 97%
rename from x-pack/plugins/endpoint/server/services/endpoint/metadata_query_builders.test.ts
rename to x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts
index 0966b52c79f7..2514d5aa8581 100644
--- a/x-pack/plugins/endpoint/server/services/endpoint/metadata_query_builders.test.ts
+++ b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts
@@ -5,10 +5,7 @@
*/
import { httpServerMock, loggingServiceMock } from '../../../../../../src/core/server/mocks';
import { EndpointConfigSchema } from '../../config';
-import {
- kibanaRequestToMetadataListESQuery,
- getESQueryHostMetadataByID,
-} from './metadata_query_builders';
+import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID } from './query_builders';
import { EndpointAppConstants } from '../../../common/types';
describe('query builder', () => {
diff --git a/x-pack/plugins/endpoint/server/services/endpoint/metadata_query_builders.ts b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts
similarity index 100%
rename from x-pack/plugins/endpoint/server/services/endpoint/metadata_query_builders.ts
rename to x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts
index 57b0a4ef1051..bd07604fe9ad 100644
--- a/x-pack/plugins/endpoint/server/services/endpoint/metadata_query_builders.ts
+++ b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts
@@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaRequest } from 'kibana/server';
-import { EndpointAppConstants } from '../../../common/types';
-import { EndpointAppContext } from '../../types';
import { esKuery } from '../../../../../../src/plugins/data/server';
+import { EndpointAppContext } from '../../types';
+import { EndpointAppConstants } from '../../../common/types';
export const kibanaRequestToMetadataListESQuery = async (
request: KibanaRequest,
From 42d7bb0c8154e7e7c01805254b9d726bcdbc5102 Mon Sep 17 00:00:00 2001
From: Lisa Cawley
Date: Mon, 6 Apr 2020 11:11:56 -0700
Subject: [PATCH 20/27] [DOCS] Fixes nesting in APM and spaces API (#62659)
---
.../resolve_copy_saved_objects_conflicts.asciidoc | 2 +-
docs/apm/api.asciidoc | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc
index 7f35dc3834f0..565d12513815 100644
--- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc
+++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc
@@ -103,7 +103,7 @@ Execute the <>, w
.Properties of `error`
[%collapsible%open]
=======
- `type`:::::
+ `type`::::
(string) The type of error. For example, `unsupported_type`, `missing_references`, or `unknown`.
=======
======
diff --git a/docs/apm/api.asciidoc b/docs/apm/api.asciidoc
index 76d898ba0cb1..a8f4f4bf0baa 100644
--- a/docs/apm/api.asciidoc
+++ b/docs/apm/api.asciidoc
@@ -44,7 +44,7 @@ The following Agent configuration APIs are available:
`service`::
(required, object) Service identifying the configuration to create or update.
-
++
.Properties of `service`
[%collapsible%open]
======
@@ -100,7 +100,7 @@ PUT /api/apm/settings/agent-configuration
===== Request body
`service`::
(required, object) Service identifying the configuration to delete
-
++
.Properties of `service`
[%collapsible%open]
======
@@ -217,7 +217,7 @@ GET /api/apm/settings/agent-configuration
`service`::
(required, object) Service identifying the configuration.
-
++
.Properties of `service`
[%collapsible%open]
======
From 0da20fea6a1ec391113d30797be749a653b0f42f Mon Sep 17 00:00:00 2001
From: Nicolas Chaulet
Date: Mon, 6 Apr 2020 15:21:39 -0400
Subject: [PATCH 21/27] [Fleet] Move actions to their own saved objects
(#62137)
---
.../ingest_manager/common/constants/agent.ts | 2 +-
.../common/types/models/agent.ts | 9 +-
.../ingest_manager/server/constants/index.ts | 3 +-
.../routes/agent/actions_handlers.test.ts | 2 +-
.../server/routes/agent/actions_handlers.ts | 10 +-
.../server/routes/agent/handlers.ts | 3 +-
.../server/routes/agent/index.ts | 2 +-
.../ingest_manager/server/saved_objects.ts | 20 +-
.../server/services/agents/acks.test.ts | 96 +-
.../server/services/agents/acks.ts | 107 +-
.../server/services/agents/actions.test.ts | 68 +-
.../server/services/agents/actions.ts | 60 +-
.../server/services/agents/checkin.test.ts | 91 +-
.../server/services/agents/checkin.ts | 26 +-
.../server/services/agents/enroll.ts | 1 -
.../server/services/agents/saved_objects.ts | 18 +-
.../ingest_manager/server/types/index.tsx | 1 +
.../server/types/models/agent.ts | 2 +-
.../api_integration/apis/fleet/agents/acks.ts | 2 +-
.../apis/fleet/agents/actions.ts | 21 +-
.../es_archives/fleet/agents/data.json | 102 +-
.../es_archives/fleet/agents/mappings.json | 1771 +++++++++++++++--
22 files changed, 1962 insertions(+), 455 deletions(-)
diff --git a/x-pack/plugins/ingest_manager/common/constants/agent.ts b/x-pack/plugins/ingest_manager/common/constants/agent.ts
index fe6f7f57e289..0b462fb4c031 100644
--- a/x-pack/plugins/ingest_manager/common/constants/agent.ts
+++ b/x-pack/plugins/ingest_manager/common/constants/agent.ts
@@ -5,8 +5,8 @@
*/
export const AGENT_SAVED_OBJECT_TYPE = 'agents';
-
export const AGENT_EVENT_SAVED_OBJECT_TYPE = 'agent_events';
+export const AGENT_ACTION_SAVED_OBJECT_TYPE = 'agent_actions';
export const AGENT_TYPE_PERMANENT = 'PERMANENT';
export const AGENT_TYPE_EPHEMERAL = 'EPHEMERAL';
diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts
index aa5729a101e1..4d03a30f9a59 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts
@@ -16,15 +16,21 @@ export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning
export interface NewAgentAction {
type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE';
- data?: string;
+ data?: any;
sent_at?: string;
}
export type AgentAction = NewAgentAction & {
id: string;
+ agent_id: string;
created_at: string;
} & SavedObjectAttributes;
+export interface AgentActionSOAttributes extends NewAgentAction, SavedObjectAttributes {
+ created_at: string;
+ agent_id: string;
+}
+
export interface AgentEvent {
type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION';
subtype: // State
@@ -62,7 +68,6 @@ interface AgentBase {
config_revision?: number;
config_newest_revision?: number;
last_checkin?: string;
- actions: AgentAction[];
}
export interface Agent extends AgentBase {
diff --git a/x-pack/plugins/ingest_manager/server/constants/index.ts b/x-pack/plugins/ingest_manager/server/constants/index.ts
index f6ee475614c5..6ac92ca5d2a9 100644
--- a/x-pack/plugins/ingest_manager/server/constants/index.ts
+++ b/x-pack/plugins/ingest_manager/server/constants/index.ts
@@ -20,8 +20,9 @@ export {
INSTALL_SCRIPT_API_ROUTES,
SETUP_API_ROUTE,
// Saved object types
- AGENT_EVENT_SAVED_OBJECT_TYPE,
AGENT_SAVED_OBJECT_TYPE,
+ AGENT_EVENT_SAVED_OBJECT_TYPE,
+ AGENT_ACTION_SAVED_OBJECT_TYPE,
AGENT_CONFIG_SAVED_OBJECT_TYPE,
DATASOURCE_SAVED_OBJECT_TYPE,
OUTPUT_SAVED_OBJECT_TYPE,
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts
index a20ba4a88053..76247c338a24 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts
@@ -78,7 +78,7 @@ describe('test actions handlers', () => {
getAgent: jest.fn().mockReturnValueOnce({
id: 'agent',
}),
- updateAgentActions: jest.fn().mockReturnValueOnce(agentAction),
+ createAgentAction: jest.fn().mockReturnValueOnce(agentAction),
} as jest.Mocked;
const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService);
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts
index 2b9c23080359..8eb427e5739b 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts
@@ -28,11 +28,11 @@ export const postNewAgentActionHandlerBuilder = function(
const newAgentAction = request.body.action as NewAgentAction;
- const savedAgentAction = await actionsService.updateAgentActions(
- soClient,
- agent,
- newAgentAction
- );
+ const savedAgentAction = await actionsService.createAgentAction(soClient, {
+ created_at: new Date().toISOString(),
+ ...newAgentAction,
+ agent_id: agent.id,
+ });
const body: PostNewAgentActionResponse = {
success: true,
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts
index adff1fda1120..89c827abe30e 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts
@@ -187,8 +187,9 @@ export const postAgentCheckinHandler: RequestHandler<
action: 'checkin',
success: true,
actions: actions.map(a => ({
+ agent_id: agent.id,
type: a.type,
- data: a.data ? JSON.parse(a.data) : a.data,
+ data: a.data,
id: a.id,
created_at: a.created_at,
})),
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts
index d46102701784..ac27e47db155 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts
@@ -122,7 +122,7 @@ export const registerRoutes = (router: IRouter) => {
},
postNewAgentActionHandlerBuilder({
getAgent: AgentService.getAgent,
- updateAgentActions: AgentService.updateAgentActions,
+ createAgentAction: AgentService.createAgentAction,
})
);
diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts
index 9f3035e1aac1..13f84e4efa79 100644
--- a/x-pack/plugins/ingest_manager/server/saved_objects.ts
+++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts
@@ -10,6 +10,7 @@ import {
PACKAGES_SAVED_OBJECT_TYPE,
AGENT_SAVED_OBJECT_TYPE,
AGENT_EVENT_SAVED_OBJECT_TYPE,
+ AGENT_ACTION_SAVED_OBJECT_TYPE,
ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE,
} from './constants';
@@ -38,17 +39,16 @@ export const savedObjectMappings = {
default_api_key: { type: 'keyword' },
updated_at: { type: 'date' },
current_error_events: { type: 'text' },
+ },
+ },
+ [AGENT_ACTION_SAVED_OBJECT_TYPE]: {
+ properties: {
+ agent_id: { type: 'keyword' },
+ type: { type: 'keyword' },
// FIXME_INGEST https://github.com/elastic/kibana/issues/56554
- actions: {
- type: 'nested',
- properties: {
- id: { type: 'keyword' },
- type: { type: 'keyword' },
- data: { type: 'text' },
- sent_at: { type: 'date' },
- created_at: { type: 'date' },
- },
- },
+ data: { type: 'flattened' },
+ sent_at: { type: 'date' },
+ created_at: { type: 'date' },
},
},
[AGENT_EVENT_SAVED_OBJECT_TYPE]: {
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts
index 3c07463e3af5..b4c1f09015a6 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts
@@ -3,29 +3,46 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+import Boom from 'boom';
+import { SavedObjectsBulkResponse } from 'kibana/server';
import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock';
-import { Agent, AgentAction, AgentEvent } from '../../../common/types/models';
+import {
+ Agent,
+ AgentAction,
+ AgentActionSOAttributes,
+ AgentEvent,
+} from '../../../common/types/models';
import { AGENT_TYPE_PERMANENT } from '../../../common/constants';
import { acknowledgeAgentActions } from './acks';
-import { isBoom } from 'boom';
describe('test agent acks services', () => {
it('should succeed on valid and matched actions', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
+
+ mockSavedObjectsClient.bulkGet.mockReturnValue(
+ Promise.resolve({
+ saved_objects: [
+ {
+ id: 'action1',
+ references: [],
+ type: 'agent_actions',
+ attributes: {
+ type: 'CONFIG_CHANGE',
+ agent_id: 'id',
+ sent_at: '2020-03-14T19:45:02.620Z',
+ timestamp: '2019-01-04T14:32:03.36764-05:00',
+ created_at: '2020-03-14T19:45:02.620Z',
+ },
+ },
+ ],
+ } as SavedObjectsBulkResponse)
+ );
+
const agentActions = await acknowledgeAgentActions(
mockSavedObjectsClient,
({
id: 'id',
type: AGENT_TYPE_PERMANENT,
- actions: [
- {
- type: 'CONFIG_CHANGE',
- id: 'action1',
- sent_at: '2020-03-14T19:45:02.620Z',
- timestamp: '2019-01-04T14:32:03.36764-05:00',
- created_at: '2020-03-14T19:45:02.620Z',
- },
- ],
} as unknown) as Agent,
[
{
@@ -41,6 +58,7 @@ describe('test agent acks services', () => {
({
type: 'CONFIG_CHANGE',
id: 'action1',
+ agent_id: 'id',
sent_at: '2020-03-14T19:45:02.620Z',
timestamp: '2019-01-04T14:32:03.36764-05:00',
created_at: '2020-03-14T19:45:02.620Z',
@@ -50,21 +68,26 @@ describe('test agent acks services', () => {
it('should fail for actions that cannot be found on agent actions list', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
+ mockSavedObjectsClient.bulkGet.mockReturnValue(
+ Promise.resolve({
+ saved_objects: [
+ {
+ id: 'action1',
+ error: {
+ message: 'Not found',
+ statusCode: 404,
+ },
+ },
+ ],
+ } as SavedObjectsBulkResponse)
+ );
+
try {
await acknowledgeAgentActions(
mockSavedObjectsClient,
({
id: 'id',
type: AGENT_TYPE_PERMANENT,
- actions: [
- {
- type: 'CONFIG_CHANGE',
- id: 'action1',
- sent_at: '2020-03-14T19:45:02.620Z',
- timestamp: '2019-01-04T14:32:03.36764-05:00',
- created_at: '2020-03-14T19:45:02.620Z',
- },
- ],
} as unknown) as Agent,
[
({
@@ -78,27 +101,38 @@ describe('test agent acks services', () => {
);
expect(true).toBeFalsy();
} catch (e) {
- expect(isBoom(e)).toBeTruthy();
+ expect(Boom.isBoom(e)).toBeTruthy();
}
});
it('should fail for events that have types not in the allowed acknowledgement type list', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
+
+ mockSavedObjectsClient.bulkGet.mockReturnValue(
+ Promise.resolve({
+ saved_objects: [
+ {
+ id: 'action1',
+ references: [],
+ type: 'agent_actions',
+ attributes: {
+ type: 'CONFIG_CHANGE',
+ agent_id: 'id',
+ sent_at: '2020-03-14T19:45:02.620Z',
+ timestamp: '2019-01-04T14:32:03.36764-05:00',
+ created_at: '2020-03-14T19:45:02.620Z',
+ },
+ },
+ ],
+ } as SavedObjectsBulkResponse)
+ );
+
try {
await acknowledgeAgentActions(
mockSavedObjectsClient,
({
id: 'id',
type: AGENT_TYPE_PERMANENT,
- actions: [
- {
- type: 'CONFIG_CHANGE',
- id: 'action1',
- sent_at: '2020-03-14T19:45:02.620Z',
- timestamp: '2019-01-04T14:32:03.36764-05:00',
- created_at: '2020-03-14T19:45:02.620Z',
- },
- ],
} as unknown) as Agent,
[
({
@@ -112,7 +146,7 @@ describe('test agent acks services', () => {
);
expect(true).toBeFalsy();
} catch (e) {
- expect(isBoom(e)).toBeTruthy();
+ expect(Boom.isBoom(e)).toBeTruthy();
}
});
});
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts
index cf9a47979ae8..24c3b322aad7 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts
@@ -17,8 +17,14 @@ import {
AgentEvent,
AgentEventSOAttributes,
AgentSOAttributes,
+ AgentActionSOAttributes,
} from '../../types';
-import { AGENT_EVENT_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE } from '../../constants';
+import {
+ AGENT_EVENT_SAVED_OBJECT_TYPE,
+ AGENT_SAVED_OBJECT_TYPE,
+ AGENT_ACTION_SAVED_OBJECT_TYPE,
+} from '../../constants';
+import { getAgentActionByIds } from './actions';
const ALLOWED_ACKNOWLEDGEMENT_TYPE: string[] = ['ACTION_RESULT'];
@@ -27,50 +33,81 @@ export async function acknowledgeAgentActions(
agent: Agent,
agentEvents: AgentEvent[]
): Promise {
- const now = new Date().toISOString();
-
- const agentActionMap: Map = new Map(
- agent.actions.map(agentAction => [agentAction.id, agentAction])
- );
-
- const matchedUpdatedActions: AgentAction[] = [];
-
- agentEvents.forEach(agentEvent => {
+ for (const agentEvent of agentEvents) {
if (!isAllowedType(agentEvent.type)) {
throw Boom.badRequest(`${agentEvent.type} not allowed for acknowledgment only ACTION_RESULT`);
}
- if (agentActionMap.has(agentEvent.action_id!)) {
- const action = agentActionMap.get(agentEvent.action_id!) as AgentAction;
- if (!action.sent_at) {
- action.sent_at = now;
- }
- matchedUpdatedActions.push(action);
- } else {
- throw Boom.badRequest('all actions should belong to current agent');
+ }
+
+ const actionIds = agentEvents
+ .map(event => event.action_id)
+ .filter(actionId => actionId !== undefined) as string[];
+
+ let actions;
+ try {
+ actions = await getAgentActionByIds(soClient, actionIds);
+ } catch (error) {
+ if (Boom.isBoom(error) && error.output.statusCode === 404) {
+ throw Boom.badRequest(`One or more actions cannot be found`);
+ }
+ throw error;
+ }
+
+ for (const action of actions) {
+ if (action.agent_id !== agent.id) {
+ throw Boom.badRequest(`${action.id} not found`);
}
- });
+ }
+
+ if (actions.length === 0) {
+ return [];
+ }
+ const configRevision = getLatestConfigRevison(agent, actions);
- if (matchedUpdatedActions.length > 0) {
- const configRevision = matchedUpdatedActions.reduce((acc, action) => {
- if (action.type !== 'CONFIG_CHANGE') {
- return acc;
- }
- const data = action.data ? JSON.parse(action.data as string) : {};
+ await soClient.bulkUpdate([
+ buildUpdateAgentConfigRevision(agent.id, configRevision),
+ ...buildUpdateAgentActionSentAt(actionIds),
+ ]);
- if (data?.config?.id !== agent.config_id) {
- return acc;
- }
+ return actions;
+}
- return data?.config?.revision > acc ? data?.config?.revision : acc;
- }, agent.config_revision || 0);
+function getLatestConfigRevison(agent: Agent, actions: AgentAction[]) {
+ return actions.reduce((acc, action) => {
+ if (action.type !== 'CONFIG_CHANGE') {
+ return acc;
+ }
+ const data = action.data || {};
- await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, {
- actions: matchedUpdatedActions,
+ if (data?.config?.id !== agent.config_id) {
+ return acc;
+ }
+
+ return data?.config?.revision > acc ? data?.config?.revision : acc;
+ }, agent.config_revision || 0);
+}
+
+function buildUpdateAgentConfigRevision(agentId: string, configRevision: number) {
+ return {
+ type: AGENT_SAVED_OBJECT_TYPE,
+ id: agentId,
+ attributes: {
config_revision: configRevision,
- });
- }
+ },
+ };
+}
- return matchedUpdatedActions;
+function buildUpdateAgentActionSentAt(
+ actionsIds: string[],
+ sentAt: string = new Date().toISOString()
+) {
+ return actionsIds.map(actionId => ({
+ type: AGENT_ACTION_SAVED_OBJECT_TYPE,
+ id: actionId,
+ attributes: {
+ sent_at: sentAt,
+ },
+ }));
}
function isAllowedType(eventType: string): boolean {
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts
index b500aeb825fe..f2e671c6dbaa 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts
@@ -4,64 +4,34 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { createAgentAction, updateAgentActions } from './actions';
-import { Agent, AgentAction, NewAgentAction } from '../../../common/types/models';
+import { createAgentAction } from './actions';
+import { SavedObject } from 'kibana/server';
+import { AgentAction, AgentActionSOAttributes } from '../../../common/types/models';
import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock';
-import { AGENT_TYPE_PERMANENT } from '../../../common/constants';
-
-interface UpdatedActions {
- actions: AgentAction[];
-}
describe('test agent actions services', () => {
- it('should update agent current actions with new action', async () => {
+ it('should create a new action', async () => {
const mockSavedObjectsClient = savedObjectsClientMock.create();
- const newAgentAction: NewAgentAction = {
+ const newAgentAction: AgentActionSOAttributes = {
+ agent_id: 'agentid',
type: 'CONFIG_CHANGE',
data: 'data',
sent_at: '2020-03-14T19:45:02.620Z',
+ created_at: '2020-03-14T19:45:02.620Z',
};
-
- await updateAgentActions(
- mockSavedObjectsClient,
- ({
- id: 'id',
- type: AGENT_TYPE_PERMANENT,
- actions: [
- {
- type: 'CONFIG_CHANGE',
- id: 'action1',
- sent_at: '2020-03-14T19:45:02.620Z',
- timestamp: '2019-01-04T14:32:03.36764-05:00',
- created_at: '2020-03-14T19:45:02.620Z',
- },
- ],
- } as unknown) as Agent,
- newAgentAction
+ mockSavedObjectsClient.create.mockReturnValue(
+ Promise.resolve({
+ attributes: {},
+ } as SavedObject)
);
-
- const updatedAgentActions = (mockSavedObjectsClient.update.mock
- .calls[0][2] as unknown) as UpdatedActions;
-
- expect(updatedAgentActions.actions.length).toEqual(2);
- const actualAgentAction = updatedAgentActions.actions.find(action => action?.data === 'data');
- expect(actualAgentAction?.type).toEqual(newAgentAction.type);
- expect(actualAgentAction?.data).toEqual(newAgentAction.data);
- expect(actualAgentAction?.sent_at).toEqual(newAgentAction.sent_at);
- });
-
- it('should create agent action from new agent action model', async () => {
- const newAgentAction: NewAgentAction = {
- type: 'CONFIG_CHANGE',
- data: 'data',
- sent_at: '2020-03-14T19:45:02.620Z',
- };
- const now = new Date();
- const agentAction = createAgentAction(now, newAgentAction);
-
- expect(agentAction.type).toEqual(newAgentAction.type);
- expect(agentAction.data).toEqual(newAgentAction.data);
- expect(agentAction.sent_at).toEqual(newAgentAction.sent_at);
+ await createAgentAction(mockSavedObjectsClient, newAgentAction);
+
+ const createdAction = (mockSavedObjectsClient.create.mock
+ .calls[0][1] as unknown) as AgentAction;
+ expect(createdAction).toBeDefined();
+ expect(createdAction?.type).toEqual(newAgentAction.type);
+ expect(createdAction?.data).toEqual(newAgentAction.data);
+ expect(createdAction?.sent_at).toEqual(newAgentAction.sent_at);
});
});
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts
index 2f8ed9f50445..a8ef0820f8d9 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts
@@ -5,46 +5,52 @@
*/
import { SavedObjectsClientContract } from 'kibana/server';
-import uuid from 'uuid';
-import {
- Agent,
- AgentAction,
- AgentSOAttributes,
- NewAgentAction,
-} from '../../../common/types/models';
-import { AGENT_SAVED_OBJECT_TYPE } from '../../../common/constants';
-
-export async function updateAgentActions(
+import { Agent, AgentAction, AgentActionSOAttributes } from '../../../common/types/models';
+import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../../common/constants';
+import { savedObjectToAgentAction } from './saved_objects';
+
+export async function createAgentAction(
soClient: SavedObjectsClientContract,
- agent: Agent,
- newAgentAction: NewAgentAction
+ newAgentAction: AgentActionSOAttributes
): Promise {
- const agentAction = createAgentAction(new Date(), newAgentAction);
+ const so = await soClient.create(AGENT_ACTION_SAVED_OBJECT_TYPE, {
+ ...newAgentAction,
+ });
- agent.actions.push(agentAction);
+ return savedObjectToAgentAction(so);
+}
- await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, {
- actions: agent.actions,
+export async function getAgentActionsForCheckin(
+ soClient: SavedObjectsClientContract,
+ agentId: string
+): Promise {
+ const res = await soClient.find({
+ type: AGENT_ACTION_SAVED_OBJECT_TYPE,
+ filter: `not ${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at: * and ${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.agent_id:${agentId}`,
});
- return agentAction;
+ return res.saved_objects.map(savedObjectToAgentAction);
}
-export function createAgentAction(createdAt: Date, newAgentAction: NewAgentAction): AgentAction {
- const agentAction = {
- id: uuid.v4(),
- created_at: createdAt.toISOString(),
- };
-
- return Object.assign(agentAction, newAgentAction);
+export async function getAgentActionByIds(
+ soClient: SavedObjectsClientContract,
+ actionIds: string[]
+) {
+ const res = await soClient.bulkGet(
+ actionIds.map(actionId => ({
+ id: actionId,
+ type: AGENT_ACTION_SAVED_OBJECT_TYPE,
+ }))
+ );
+
+ return res.saved_objects.map(savedObjectToAgentAction);
}
export interface ActionsService {
getAgent: (soClient: SavedObjectsClientContract, agentId: string) => Promise;
- updateAgentActions: (
+ createAgentAction: (
soClient: SavedObjectsClientContract,
- agent: Agent,
- newAgentAction: NewAgentAction
+ newAgentAction: AgentActionSOAttributes
) => Promise;
}
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin.test.ts
index d3e10fcb6b63..d98052ea87e8 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/checkin.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin.test.ts
@@ -14,13 +14,13 @@ function getAgent(data: Partial) {
describe('Agent checkin service', () => {
describe('shouldCreateConfigAction', () => {
it('should return false if the agent do not have an assigned config', () => {
- const res = shouldCreateConfigAction(getAgent({}));
+ const res = shouldCreateConfigAction(getAgent({}), []);
expect(res).toBeFalsy();
});
it('should return true if this is agent first checkin', () => {
- const res = shouldCreateConfigAction(getAgent({ config_id: 'config1' }));
+ const res = shouldCreateConfigAction(getAgent({ config_id: 'config1' }), []);
expect(res).toBeTruthy();
});
@@ -32,7 +32,8 @@ describe('Agent checkin service', () => {
last_checkin: '2018-01-02T00:00:00',
config_revision: 1,
config_newest_revision: 1,
- })
+ }),
+ []
);
expect(res).toBeFalsy();
@@ -45,20 +46,21 @@ describe('Agent checkin service', () => {
last_checkin: '2018-01-02T00:00:00',
config_revision: 1,
config_newest_revision: 2,
- actions: [
- {
- id: 'action1',
- type: 'CONFIG_CHANGE',
- created_at: new Date().toISOString(),
- data: JSON.stringify({
- config: {
- id: 'config1',
- revision: 2,
- },
- }),
- },
- ],
- })
+ }),
+ [
+ {
+ id: 'action1',
+ agent_id: 'agent1',
+ type: 'CONFIG_CHANGE',
+ created_at: new Date().toISOString(),
+ data: JSON.stringify({
+ config: {
+ id: 'config1',
+ revision: 2,
+ },
+ }),
+ },
+ ]
);
expect(res).toBeFalsy();
@@ -71,31 +73,33 @@ describe('Agent checkin service', () => {
last_checkin: '2018-01-02T00:00:00',
config_revision: 1,
config_newest_revision: 2,
- actions: [
- {
- id: 'action1',
- type: 'CONFIG_CHANGE',
- created_at: new Date().toISOString(),
- data: JSON.stringify({
- config: {
- id: 'config2',
- revision: 2,
- },
- }),
- },
- {
- id: 'action1',
- type: 'CONFIG_CHANGE',
- created_at: new Date().toISOString(),
- data: JSON.stringify({
- config: {
- id: 'config1',
- revision: 1,
- },
- }),
- },
- ],
- })
+ }),
+ [
+ {
+ id: 'action1',
+ agent_id: 'agent1',
+ type: 'CONFIG_CHANGE',
+ created_at: new Date().toISOString(),
+ data: JSON.stringify({
+ config: {
+ id: 'config2',
+ revision: 2,
+ },
+ }),
+ },
+ {
+ id: 'action1',
+ agent_id: 'agent1',
+ type: 'CONFIG_CHANGE',
+ created_at: new Date().toISOString(),
+ data: JSON.stringify({
+ config: {
+ id: 'config1',
+ revision: 1,
+ },
+ }),
+ },
+ ]
);
expect(res).toBeTruthy();
@@ -108,7 +112,8 @@ describe('Agent checkin service', () => {
last_checkin: '2018-01-02T00:00:00',
config_revision: 1,
config_newest_revision: 2,
- })
+ }),
+ []
);
expect(res).toBeTruthy();
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts
index d80fff5d8ece..9a2b3f22b943 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts
@@ -5,7 +5,6 @@
*/
import { SavedObjectsClientContract, SavedObjectsBulkCreateObject } from 'src/core/server';
-import uuid from 'uuid';
import {
Agent,
AgentEvent,
@@ -17,6 +16,7 @@ import {
import { agentConfigService } from '../agent_config';
import * as APIKeysService from '../api_keys';
import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants';
+import { getAgentActionsForCheckin, createAgentAction } from './actions';
export async function agentCheckin(
soClient: SavedObjectsClientContract,
@@ -34,10 +34,10 @@ export async function agentCheckin(
last_checkin: new Date().toISOString(),
};
- const actions = filterActionsForCheckin(agent);
+ const actions = await getAgentActionsForCheckin(soClient, agent.id);
// Generate new agent config if config is updated
- if (agent.config_id && shouldCreateConfigAction(agent)) {
+ if (agent.config_id && shouldCreateConfigAction(agent, actions)) {
const config = await agentConfigService.getFullConfig(soClient, agent.config_id);
if (config) {
// Assign output API keys
@@ -52,18 +52,14 @@ export async function agentCheckin(
// Mutate the config to set the api token for this agent
config.outputs.default.api_key = agent.default_api_key || updateData.default_api_key;
- const configChangeAction: AgentAction = {
- id: uuid.v4(),
+ const configChangeAction = await createAgentAction(soClient, {
+ agent_id: agent.id,
type: 'CONFIG_CHANGE',
+ data: { config } as any,
created_at: new Date().toISOString(),
- data: JSON.stringify({
- config,
- }),
sent_at: undefined,
- };
+ });
actions.push(configChangeAction);
- // persist new action
- updateData.actions = actions;
}
}
if (localMetadata) {
@@ -149,7 +145,7 @@ function isActionEvent(event: AgentEvent) {
);
}
-export function shouldCreateConfigAction(agent: Agent): boolean {
+export function shouldCreateConfigAction(agent: Agent, actions: AgentAction[]): boolean {
if (!agent.config_id) {
return false;
}
@@ -167,7 +163,7 @@ export function shouldCreateConfigAction(agent: Agent): boolean {
return false;
}
- const isActionAlreadyGenerated = !!agent.actions.find(action => {
+ const isActionAlreadyGenerated = !!actions.find(action => {
if (!action.data || action.type !== 'CONFIG_CHANGE') {
return false;
}
@@ -181,7 +177,3 @@ export function shouldCreateConfigAction(agent: Agent): boolean {
return !isActionAlreadyGenerated;
}
-
-function filterActionsForCheckin(agent: Agent): AgentAction[] {
- return agent.actions.filter((a: AgentAction) => !a.sent_at);
-}
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts
index 52547e9bcb0f..a34d2e03e9b3 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts
@@ -35,7 +35,6 @@ export async function enroll(
user_provided_metadata: JSON.stringify(metadata?.userProvided ?? {}),
local_metadata: JSON.stringify(metadata?.local ?? {}),
current_error_events: undefined,
- actions: [],
access_api_key_id: undefined,
last_checkin: undefined,
default_api_key: undefined,
diff --git a/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts b/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts
index dbe268818713..aa8852074068 100644
--- a/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts
@@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import Boom from 'boom';
import { SavedObject } from 'src/core/server';
-import { Agent, AgentSOAttributes } from '../../types';
+import { Agent, AgentSOAttributes, AgentAction, AgentActionSOAttributes } from '../../types';
export function savedObjectToAgent(so: SavedObject): Agent {
if (so.error) {
@@ -24,3 +25,18 @@ export function savedObjectToAgent(so: SavedObject): Agent {
status: undefined,
};
}
+
+export function savedObjectToAgentAction(so: SavedObject): AgentAction {
+ if (so.error) {
+ if (so.error.statusCode === 404) {
+ throw Boom.notFound(so.error.message);
+ }
+
+ throw new Error(so.error.message);
+ }
+
+ return {
+ id: so.id,
+ ...so.attributes,
+ };
+}
diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx
index 59c7f152e5cb..1cd5622c0c7b 100644
--- a/x-pack/plugins/ingest_manager/server/types/index.tsx
+++ b/x-pack/plugins/ingest_manager/server/types/index.tsx
@@ -14,6 +14,7 @@ export {
AgentEvent,
AgentEventSOAttributes,
AgentAction,
+ AgentActionSOAttributes,
Datasource,
NewDatasource,
FullAgentConfigDatasource,
diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts
index f70b3cf0ed09..f18846348432 100644
--- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts
+++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts
@@ -60,6 +60,6 @@ export const NewAgentActionSchema = schema.object({
schema.literal('RESUME'),
schema.literal('PAUSE'),
]),
- data: schema.maybe(schema.string()),
+ data: schema.maybe(schema.any()),
sent_at: schema.maybe(schema.string()),
});
diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/api_integration/apis/fleet/agents/acks.ts
index a2eba2c23c39..f08ce33d8b60 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/acks.ts
+++ b/x-pack/test/api_integration/apis/fleet/agents/acks.ts
@@ -178,7 +178,7 @@ export default function(providerContext: FtrProviderContext) {
],
})
.expect(400);
- expect(apiResponse.message).to.eql('all actions should belong to current agent');
+ expect(apiResponse.message).to.eql('One or more actions cannot be found');
});
it('should return a 400 when request event list contains action types that are not allowed for acknowledgement', async () => {
diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/api_integration/apis/fleet/agents/actions.ts
index f27b932cff5c..cf0641acf9e1 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/actions.ts
+++ b/x-pack/test/api_integration/apis/fleet/agents/actions.ts
@@ -28,28 +28,15 @@ export default function(providerContext: FtrProviderContext) {
.send({
action: {
type: 'CONFIG_CHANGE',
- data: 'action_data',
+ data: { data: 'action_data' },
sent_at: '2020-03-18T19:45:02.620Z',
},
})
.expect(200);
expect(apiResponse.success).to.be(true);
- expect(apiResponse.item.data).to.be('action_data');
+ expect(apiResponse.item.data).to.eql({ data: 'action_data' });
expect(apiResponse.item.sent_at).to.be('2020-03-18T19:45:02.620Z');
-
- const { body: agentResponse } = await supertest
- .get(`/api/ingest_manager/fleet/agents/agent1`)
- .set('kbn-xsrf', 'xx')
- .expect(200);
-
- const updatedAction = agentResponse.item.actions.find(
- (itemAction: Record) => itemAction?.data === 'action_data'
- );
-
- expect(updatedAction.type).to.be('CONFIG_CHANGE');
- expect(updatedAction.data).to.be('action_data');
- expect(updatedAction.sent_at).to.be('2020-03-18T19:45:02.620Z');
});
it('should return a 400 when request does not have type information', async () => {
@@ -58,7 +45,7 @@ export default function(providerContext: FtrProviderContext) {
.set('kbn-xsrf', 'xx')
.send({
action: {
- data: 'action_data',
+ data: { data: 'action_data' },
sent_at: '2020-03-18T19:45:02.620Z',
},
})
@@ -75,7 +62,7 @@ export default function(providerContext: FtrProviderContext) {
.send({
action: {
type: 'CONFIG_CHANGE',
- data: 'action_data',
+ data: { data: 'action_data' },
sent_at: '2020-03-18T19:45:02.620Z',
},
})
diff --git a/x-pack/test/functional/es_archives/fleet/agents/data.json b/x-pack/test/functional/es_archives/fleet/agents/data.json
index 9b29767d5162..1ffb119ca102 100644
--- a/x-pack/test/functional/es_archives/fleet/agents/data.json
+++ b/x-pack/test/functional/es_archives/fleet/agents/data.json
@@ -12,30 +12,7 @@
"config_id": "1",
"type": "PERMANENT",
"local_metadata": "{}",
- "user_provided_metadata": "{}",
- "actions": [{
- "id": "37ed51ff-e80f-4f2a-a62d-f4fa975e7d85",
- "created_at": "2019-09-04T15:04:07+0000",
- "type": "RESUME"
- },
- {
- "id": "b400439c-bbbf-43d5-83cb-cf8b7e32506f",
- "type": "PAUSE",
- "created_at": "2019-09-04T15:01:07+0000",
- "sent_at": "2019-09-04T15:03:07+0000"
- },
- {
- "created_at" : "2020-03-15T03:47:15.129Z",
- "id" : "48cebde1-c906-4893-b89f-595d943b72a1",
- "type" : "CONFIG_CHANGE",
- "sent_at": "2020-03-04T15:03:07+0000"
- },
- {
- "created_at" : "2020-03-16T03:47:15.129Z",
- "id" : "48cebde1-c906-4893-b89f-595d943b72a2",
- "type" : "CONFIG_CHANGE",
- "sent_at": "2020-03-04T15:03:07+0000"
- }]
+ "user_provided_metadata": "{}"
}
}
}
@@ -54,8 +31,7 @@
"shared_id": "agent2_filebeat",
"type": "PERMANENT",
"local_metadata": "{}",
- "user_provided_metadata": "{}",
- "actions": []
+ "user_provided_metadata": "{}"
}
}
}
@@ -74,8 +50,7 @@
"shared_id": "agent3_metricbeat",
"type": "PERMANENT",
"local_metadata": "{}",
- "user_provided_metadata": "{}",
- "actions": []
+ "user_provided_metadata": "{}"
}
}
}
@@ -94,8 +69,7 @@
"shared_id": "agent4_metricbeat",
"type": "PERMANENT",
"local_metadata": "{}",
- "user_provided_metadata": "{}",
- "actions": []
+ "user_provided_metadata": "{}"
}
}
}
@@ -157,3 +131,71 @@
}
}
}
+
+{
+ "type": "doc",
+ "value": {
+ "id": "agent_actions:37ed51ff-e80f-4f2a-a62d-f4fa975e7d85",
+ "index": ".kibana",
+ "source": {
+ "type": "agent_actions",
+ "agent_actions": {
+ "agent_id": "agent1",
+ "created_at": "2019-09-04T15:04:07+0000",
+ "type": "RESUME",
+ "sent_at": "2019-09-04T15:03:07+0000"
+ }
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "id": "agent_actions:b400439c-bbbf-43d5-83cb-cf8b7e32506f",
+ "index": ".kibana",
+ "source": {
+ "type": "agent_actions",
+ "agent_actions": {
+ "agent_id": "agent1",
+ "type": "PAUSE",
+ "created_at": "2019-09-04T15:01:07+0000",
+ "sent_at": "2019-09-04T15:03:07+0000"
+ }
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "id": "agent_actions:48cebde1-c906-4893-b89f-595d943b72a1",
+ "index": ".kibana",
+ "source": {
+ "type": "agent_actions",
+ "agent_actions": {
+ "agent_id": "agent1",
+ "type": "CONFIG_CHANGE",
+ "created_at": "2020-03-15T03:47:15.129Z",
+ "sent_at": "2020-03-04T15:03:07+0000"
+ }
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "id": "agent_actions:48cebde1-c906-4893-b89f-595d943b72a2",
+ "index": ".kibana",
+ "source": {
+ "type": "agent_actions",
+ "agent_actions": {
+ "agent_id": "agent1",
+ "type": "CONFIG_CHANGE",
+ "created_at": "2020-03-15T03:47:15.129Z",
+ "sent_at": "2020-03-04T15:03:07+0000"
+ }
+ }
+ }
+}
diff --git a/x-pack/test/functional/es_archives/fleet/agents/mappings.json b/x-pack/test/functional/es_archives/fleet/agents/mappings.json
index 0f632b7333ee..31ae16104930 100644
--- a/x-pack/test/functional/es_archives/fleet/agents/mappings.json
+++ b/x-pack/test/functional/es_archives/fleet/agents/mappings.json
@@ -9,58 +9,168 @@
"dynamic": "strict",
"_meta": {
"migrationMappingPropertyHashes": {
+ "outputs": "aee9782e0d500b867859650a36280165",
"ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9",
- "server": "ec97f1c5da1a19609a60874e5af1100c",
"visualization": "52d7a13ad68a150c4525b292d23e12cc",
"references": "7997cf5a56cc02bdc9c93361bde732b0",
"graph-workspace": "cd7ba1330e6682e9cc00b78850874be1",
- "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084",
- "policies": "1a096b98c98c2efebfdba77cefcfe54a",
"type": "2f4316de49999235636386fe51dc06c1",
- "lens": "21c3ea0763beb1ecb0162529706b88c5",
- "space": "c5ca8acafa0beaa4d08d014a97b6bc6b",
"infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c",
+ "space": "c5ca8acafa0beaa4d08d014a97b6bc6b",
+ "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1",
+ "action": "6e96ac5e648f57523879661ea72525b7",
+ "agent_configs": "38abaf89513877745c359e7700c0c66a",
+ "dashboard": "d00f614b29a80360e1190193fd333bab",
+ "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd",
+ "siem-detection-engine-rule-actions": "90eee2e4635260f4be0a1da8f5bc0aa0",
+ "agent_events": "3231653fafe4ef3196fe3b32ab774bf2",
+ "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9",
+ "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e",
+ "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a",
+ "action_task_params": "a9d49f184ee89641044be0ca2950fa3a",
+ "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd",
+ "inventory-view": "9ecce5b58867403613d82fe496470b34",
+ "enrollment_api_keys": "28b91e20b105b6f928e2012600085d8f",
+ "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6",
+ "cases-comments": "c2061fb929f585df57425102fa928b4b",
+ "canvas-element": "7390014e1091044523666d97247392fc",
+ "datasources": "d4bc0c252b2b5683ff21ea32d00acffc",
+ "telemetry": "36a616f7026dfa617d6655df850fe16d",
+ "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b",
+ "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327",
+ "server": "ec97f1c5da1a19609a60874e5af1100c",
+ "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084",
+ "lens": "21c3ea0763beb1ecb0162529706b88c5",
"sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4",
"search": "181661168bbadd1eff5902361e2a0d5c",
"updated_at": "00da57df13e94e9d98437d13ace4bfe0",
+ "cases-configure": "42711cbb311976c0687853f4c1354572",
"canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231",
+ "alert": "7b44fba6773e37c806ce290ea9b7024e",
+ "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0",
"map": "23d7aa4a720d4938ccde3983f87bd58d",
- "dashboard": "d00f614b29a80360e1190193fd333bab",
- "apm-services-telemetry": "07ee1939fa4302c62ddc052ec03fed90",
- "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd",
- "epm": "abf5b64aa599932bd181efc86dce14a7",
- "siem-ui-timeline": "6485ab095be8d15246667b98a1a34295",
- "agent_events": "8060c5567d33f6697164e1fd5c81b8ed",
- "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e",
- "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9",
+ "uptime-dynamic-settings": "b6289473c8985c79b6c47eebc19a0ca5",
+ "epm-package": "75d12cd13c867fd713d7dfb27366bc20",
+ "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2",
+ "cases": "08b8b110dbca273d37e8aef131ecab61",
+ "siem-ui-timeline": "ac8020190f5950dd3250b6499144e7fb",
"kql-telemetry": "d12a98a6f19a2d273696597547e064ee",
"ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3",
"url": "c7f66a0df8b1b52f17c28c4adb111105",
- "apm-indices": "c69b68f3fe2bf27b4788d4191c1d6011",
- "agents": "1c8e942384219bd899f381fd40e407d7",
+ "agents": "c3eeb7b9d97176f15f6d126370ab23c7",
"migrationVersion": "4a1746014a75ade3a714e1db5763276f",
- "inventory-view": "84b320fd67209906333ffce261128462",
- "enrollment_api_keys": "90e66b79e8e948e9c15434fdb3ae576e",
- "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6",
"index-pattern": "66eccb05066c5a89924f48a9e9736499",
- "canvas-element": "7390014e1091044523666d97247392fc",
- "datasources": "2fed9e9883b9622cd59a73ee5550ef4f",
- "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d",
+ "maps-telemetry": "268da3a48066123fc5baf35abaa55014",
"namespace": "2f4316de49999235636386fe51dc06c1",
- "telemetry": "358ffaa88ba34a97d55af0933a117de4",
+ "cases-user-actions": "32277330ec6b721abe3b846cfd939a71",
+ "agent_actions": "ed270b46812f0fa1439366c428a2cf17",
"siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29",
"timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf",
- "config": "87aca8fdb053154f11383fce3dbf3edf",
- "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b",
- "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327"
+ "config": "ae24d22d5986d04124cc6568f771066f",
+ "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215"
}
},
"properties": {
+ "action": {
+ "properties": {
+ "actionTypeId": {
+ "type": "keyword"
+ },
+ "config": {
+ "type": "object",
+ "enabled": false
+ },
+ "name": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword"
+ }
+ }
+ },
+ "secrets": {
+ "type": "binary"
+ }
+ }
+ },
+ "action_task_params": {
+ "properties": {
+ "actionId": {
+ "type": "keyword"
+ },
+ "apiKey": {
+ "type": "binary"
+ },
+ "params": {
+ "type": "object",
+ "enabled": false
+ }
+ }
+ },
+ "agent_actions": {
+ "properties": {
+ "agent_id": {
+ "type": "keyword"
+ },
+ "created_at": {
+ "type": "date"
+ },
+ "data": {
+ "type": "flattened"
+ },
+ "sent_at": {
+ "type": "date"
+ },
+ "type": {
+ "type": "keyword"
+ }
+ }
+ },
+ "agent_configs": {
+ "properties": {
+ "datasources": {
+ "type": "keyword"
+ },
+ "description": {
+ "type": "text"
+ },
+ "id": {
+ "type": "keyword"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "text"
+ },
+ "namespace": {
+ "type": "keyword"
+ },
+ "revision": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "keyword"
+ },
+ "updated_by": {
+ "type": "keyword"
+ },
+ "updated_on": {
+ "type": "keyword"
+ }
+ }
+ },
"agent_events": {
"properties": {
+ "action_id": {
+ "type": "keyword"
+ },
"agent_id": {
"type": "keyword"
},
+ "config_id": {
+ "type": "keyword"
+ },
"data": {
"type": "text"
},
@@ -70,6 +180,9 @@
"payload": {
"type": "text"
},
+ "stream_id": {
+ "type": "keyword"
+ },
"subtype": {
"type": "keyword"
},
@@ -86,29 +199,24 @@
"access_api_key_id": {
"type": "keyword"
},
- "actions": {
- "type": "nested",
- "properties": {
- "created_at": {
- "type": "date"
- },
- "data": {
- "type": "text"
- },
- "id": {
- "type": "keyword"
- },
- "sent_at": {
- "type": "date"
- },
- "type": {
- "type": "keyword"
- }
- }
- },
"active": {
"type": "boolean"
},
+ "config_id": {
+ "type": "keyword"
+ },
+ "config_newest_revision": {
+ "type": "integer"
+ },
+ "config_revision": {
+ "type": "integer"
+ },
+ "current_error_events": {
+ "type": "text"
+ },
+ "default_api_key": {
+ "type": "keyword"
+ },
"enrolled_at": {
"type": "date"
},
@@ -121,9 +229,6 @@
"local_metadata": {
"type": "text"
},
- "config_id": {
- "type": "keyword"
- },
"shared_id": {
"type": "keyword"
},
@@ -136,21 +241,95 @@
"user_provided_metadata": {
"type": "text"
},
- "current_error_events": {
- "type": "text"
- },
"version": {
"type": "keyword"
}
}
},
- "apm-indices": {
+ "alert": {
"properties": {
- "apm_oss": {
+ "actions": {
+ "type": "nested",
"properties": {
- "apmAgentConfigurationIndex": {
+ "actionRef": {
+ "type": "keyword"
+ },
+ "actionTypeId": {
+ "type": "keyword"
+ },
+ "group": {
"type": "keyword"
},
+ "params": {
+ "type": "object",
+ "enabled": false
+ }
+ }
+ },
+ "alertTypeId": {
+ "type": "keyword"
+ },
+ "apiKey": {
+ "type": "binary"
+ },
+ "apiKeyOwner": {
+ "type": "keyword"
+ },
+ "consumer": {
+ "type": "keyword"
+ },
+ "createdAt": {
+ "type": "date"
+ },
+ "createdBy": {
+ "type": "keyword"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "muteAll": {
+ "type": "boolean"
+ },
+ "mutedInstanceIds": {
+ "type": "keyword"
+ },
+ "name": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword"
+ }
+ }
+ },
+ "params": {
+ "type": "object",
+ "enabled": false
+ },
+ "schedule": {
+ "properties": {
+ "interval": {
+ "type": "keyword"
+ }
+ }
+ },
+ "scheduledTaskId": {
+ "type": "keyword"
+ },
+ "tags": {
+ "type": "keyword"
+ },
+ "throttle": {
+ "type": "keyword"
+ },
+ "updatedBy": {
+ "type": "keyword"
+ }
+ }
+ },
+ "apm-indices": {
+ "properties": {
+ "apm_oss": {
+ "properties": {
"errorIndices": {
"type": "keyword"
},
@@ -173,33 +352,779 @@
}
}
},
- "apm-services-telemetry": {
+ "apm-telemetry": {
"properties": {
- "has_any_services": {
- "type": "boolean"
- },
- "services_per_agent": {
+ "agents": {
"properties": {
"dotnet": {
- "type": "long",
- "null_value": 0
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
},
"go": {
- "type": "long",
- "null_value": 0
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
},
"java": {
- "type": "long",
- "null_value": 0
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
},
"js-base": {
- "type": "long",
- "null_value": 0
- },
- "nodejs": {
- "type": "long",
- "null_value": 0
- },
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "nodejs": {
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "python": {
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "ruby": {
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "rum-js": {
+ "properties": {
+ "agent": {
+ "properties": {
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "service": {
+ "properties": {
+ "framework": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "language": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ },
+ "runtime": {
+ "properties": {
+ "composite": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "name": {
+ "type": "keyword",
+ "ignore_above": 1024
+ },
+ "version": {
+ "type": "keyword",
+ "ignore_above": 1024
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "cardinality": {
+ "properties": {
+ "transaction": {
+ "properties": {
+ "name": {
+ "properties": {
+ "all_agents": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ },
+ "rum": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "user_agent": {
+ "properties": {
+ "original": {
+ "properties": {
+ "all_agents": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ },
+ "rum": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "counts": {
+ "properties": {
+ "agent_configuration": {
+ "properties": {
+ "all": {
+ "type": "long"
+ }
+ }
+ },
+ "error": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ },
+ "all": {
+ "type": "long"
+ }
+ }
+ },
+ "max_error_groups_per_service": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ },
+ "max_transaction_groups_per_service": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ },
+ "metric": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ },
+ "all": {
+ "type": "long"
+ }
+ }
+ },
+ "onboarding": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ },
+ "all": {
+ "type": "long"
+ }
+ }
+ },
+ "services": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ },
+ "sourcemap": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ },
+ "all": {
+ "type": "long"
+ }
+ }
+ },
+ "span": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ },
+ "all": {
+ "type": "long"
+ }
+ }
+ },
+ "traces": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ }
+ }
+ },
+ "transaction": {
+ "properties": {
+ "1d": {
+ "type": "long"
+ },
+ "all": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "has_any_services": {
+ "type": "boolean"
+ },
+ "indices": {
+ "properties": {
+ "all": {
+ "properties": {
+ "total": {
+ "properties": {
+ "docs": {
+ "properties": {
+ "count": {
+ "type": "long"
+ }
+ }
+ },
+ "store": {
+ "properties": {
+ "size_in_bytes": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "shards": {
+ "properties": {
+ "total": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "integrations": {
+ "properties": {
+ "ml": {
+ "properties": {
+ "all_jobs_count": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "retainment": {
+ "properties": {
+ "error": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ },
+ "metric": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ },
+ "onboarding": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ },
+ "span": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ },
+ "transaction": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "services_per_agent": {
+ "properties": {
+ "dotnet": {
+ "type": "long",
+ "null_value": 0
+ },
+ "go": {
+ "type": "long",
+ "null_value": 0
+ },
+ "java": {
+ "type": "long",
+ "null_value": 0
+ },
+ "js-base": {
+ "type": "long",
+ "null_value": 0
+ },
+ "nodejs": {
+ "type": "long",
+ "null_value": 0
+ },
"python": {
"type": "long",
"null_value": 0
@@ -213,6 +1138,155 @@
"null_value": 0
}
}
+ },
+ "tasks": {
+ "properties": {
+ "agent_configuration": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "agents": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "cardinality": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "groupings": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "indices_stats": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "integrations": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "processor_events": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "services": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ },
+ "versions": {
+ "properties": {
+ "took": {
+ "properties": {
+ "ms": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "version": {
+ "properties": {
+ "apm_server": {
+ "properties": {
+ "major": {
+ "type": "long"
+ },
+ "minor": {
+ "type": "long"
+ },
+ "patch": {
+ "type": "long"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "application_usage_totals": {
+ "properties": {
+ "appId": {
+ "type": "keyword"
+ },
+ "minutesOnScreen": {
+ "type": "float"
+ },
+ "numberOfClicks": {
+ "type": "long"
+ }
+ }
+ },
+ "application_usage_transactional": {
+ "properties": {
+ "appId": {
+ "type": "keyword"
+ },
+ "minutesOnScreen": {
+ "type": "float"
+ },
+ "numberOfClicks": {
+ "type": "long"
+ },
+ "timestamp": {
+ "type": "date"
}
}
},
@@ -244,22 +1318,253 @@
}
}
},
- "canvas-workpad": {
- "dynamic": "false",
+ "canvas-workpad": {
+ "dynamic": "false",
+ "properties": {
+ "@created": {
+ "type": "date"
+ },
+ "@timestamp": {
+ "type": "date"
+ },
+ "name": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "cases": {
+ "properties": {
+ "closed_at": {
+ "type": "date"
+ },
+ "closed_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ },
+ "created_at": {
+ "type": "date"
+ },
+ "created_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ },
+ "description": {
+ "type": "text"
+ },
+ "external_service": {
+ "properties": {
+ "connector_id": {
+ "type": "keyword"
+ },
+ "connector_name": {
+ "type": "keyword"
+ },
+ "external_id": {
+ "type": "keyword"
+ },
+ "external_title": {
+ "type": "text"
+ },
+ "external_url": {
+ "type": "text"
+ },
+ "pushed_at": {
+ "type": "date"
+ },
+ "pushed_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "status": {
+ "type": "keyword"
+ },
+ "tags": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "keyword"
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "updated_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "cases-comments": {
+ "properties": {
+ "comment": {
+ "type": "text"
+ },
+ "created_at": {
+ "type": "date"
+ },
+ "created_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ },
+ "pushed_at": {
+ "type": "date"
+ },
+ "pushed_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "updated_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "cases-configure": {
+ "properties": {
+ "closure_type": {
+ "type": "keyword"
+ },
+ "connector_id": {
+ "type": "keyword"
+ },
+ "connector_name": {
+ "type": "keyword"
+ },
+ "created_at": {
+ "type": "date"
+ },
+ "created_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "updated_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "cases-user-actions": {
"properties": {
- "@created": {
- "type": "date"
+ "action": {
+ "type": "keyword"
},
- "@timestamp": {
+ "action_at": {
"type": "date"
},
- "name": {
- "type": "text",
- "fields": {
- "keyword": {
+ "action_by": {
+ "properties": {
+ "email": {
+ "type": "keyword"
+ },
+ "full_name": {
+ "type": "keyword"
+ },
+ "username": {
"type": "keyword"
}
}
+ },
+ "action_field": {
+ "type": "keyword"
+ },
+ "new_value": {
+ "type": "text"
+ },
+ "old_value": {
+ "type": "text"
}
}
},
@@ -327,81 +1632,76 @@
},
"datasources": {
"properties": {
- "id": {
- "type": "keyword"
- },
- "name": {
+ "config_id": {
"type": "keyword"
},
- "package": {
- "properties": {
- "assets": {
- "properties": {
- "id": {
- "type": "keyword"
- },
- "type": {
- "type": "keyword"
- }
- }
- },
- "description": {
- "type": "keyword"
- },
- "name": {
- "type": "keyword"
- },
- "title": {
- "type": "keyword"
- },
- "version": {
- "type": "keyword"
- }
- }
+ "description": {
+ "type": "text"
},
- "read_alias": {
- "type": "keyword"
+ "enabled": {
+ "type": "boolean"
},
- "streams": {
+ "inputs": {
+ "type": "nested",
"properties": {
"config": {
"type": "flattened"
},
- "id": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "processors": {
"type": "keyword"
},
- "input": {
+ "streams": {
+ "type": "nested",
"properties": {
"config": {
"type": "flattened"
},
- "fields": {
- "type": "flattened"
- },
- "id": {
- "type": "keyword"
- },
- "ilm_policy": {
+ "dataset": {
"type": "keyword"
},
- "index_template": {
- "type": "keyword"
+ "enabled": {
+ "type": "boolean"
},
- "ingest_pipelines": {
+ "id": {
"type": "keyword"
},
- "type": {
+ "processors": {
"type": "keyword"
}
}
},
- "output_id": {
+ "type": {
+ "type": "keyword"
+ }
+ }
+ },
+ "name": {
+ "type": "keyword"
+ },
+ "namespace": {
+ "type": "keyword"
+ },
+ "output_id": {
+ "type": "keyword"
+ },
+ "package": {
+ "properties": {
+ "name": {
+ "type": "keyword"
+ },
+ "title": {
"type": "keyword"
},
- "processors": {
+ "version": {
"type": "keyword"
}
}
+ },
+ "revision": {
+ "type": "integer"
}
}
},
@@ -416,49 +1716,18 @@
"api_key_id": {
"type": "keyword"
},
+ "config_id": {
+ "type": "keyword"
+ },
"created_at": {
"type": "date"
},
- "enrollment_rules": {
- "type": "nested",
- "properties": {
- "created_at": {
- "type": "date"
- },
- "id": {
- "type": "keyword"
- },
- "ip_ranges": {
- "type": "keyword"
- },
- "types": {
- "type": "keyword"
- },
- "updated_at": {
- "type": "date"
- },
- "window_duration": {
- "type": "nested",
- "properties": {
- "from": {
- "type": "date"
- },
- "to": {
- "type": "date"
- }
- }
- }
- }
- },
"expire_at": {
"type": "date"
},
"name": {
"type": "keyword"
},
- "config_id": {
- "type": "keyword"
- },
"type": {
"type": "keyword"
},
@@ -467,7 +1736,7 @@
}
}
},
- "epm": {
+ "epm-package": {
"properties": {
"installed": {
"type": "nested",
@@ -479,6 +1748,12 @@
"type": "keyword"
}
}
+ },
+ "name": {
+ "type": "keyword"
+ },
+ "version": {
+ "type": "keyword"
}
}
},
@@ -631,6 +1906,26 @@
}
}
},
+ "customMetrics": {
+ "type": "nested",
+ "properties": {
+ "aggregation": {
+ "type": "keyword"
+ },
+ "field": {
+ "type": "keyword"
+ },
+ "id": {
+ "type": "keyword"
+ },
+ "label": {
+ "type": "keyword"
+ },
+ "type": {
+ "type": "keyword"
+ }
+ }
+ },
"customOptions": {
"type": "nested",
"properties": {
@@ -665,6 +1960,18 @@
},
"metric": {
"properties": {
+ "aggregation": {
+ "type": "keyword"
+ },
+ "field": {
+ "type": "keyword"
+ },
+ "id": {
+ "type": "keyword"
+ },
+ "label": {
+ "type": "keyword"
+ },
"type": {
"type": "keyword"
}
@@ -792,9 +2099,19 @@
}
}
},
+ "indexPatternsWithGeoFieldCount": {
+ "type": "long"
+ },
"mapsTotalCount": {
"type": "long"
},
+ "settings": {
+ "properties": {
+ "showMapVisualizationTypes": {
+ "type": "boolean"
+ }
+ }
+ },
"timeCaptured": {
"type": "date"
}
@@ -894,30 +2211,33 @@
"namespace": {
"type": "keyword"
},
- "policies": {
+ "outputs": {
"properties": {
- "datasources": {
+ "api_key": {
"type": "keyword"
},
- "description": {
- "type": "text"
- },
- "id": {
+ "ca_sha256": {
"type": "keyword"
},
- "label": {
- "type": "keyword"
+ "config": {
+ "type": "flattened"
},
- "name": {
- "type": "text"
+ "fleet_enroll_password": {
+ "type": "binary"
},
- "status": {
+ "fleet_enroll_username": {
+ "type": "binary"
+ },
+ "hosts": {
"type": "keyword"
},
- "updated_by": {
+ "is_default": {
+ "type": "boolean"
+ },
+ "name": {
"type": "keyword"
},
- "updated_on": {
+ "type": {
"type": "keyword"
}
}
@@ -1011,6 +2331,73 @@
}
}
},
+ "siem-detection-engine-rule-actions": {
+ "properties": {
+ "actions": {
+ "properties": {
+ "action_type_id": {
+ "type": "keyword"
+ },
+ "group": {
+ "type": "keyword"
+ },
+ "id": {
+ "type": "keyword"
+ },
+ "params": {
+ "type": "object",
+ "dynamic": "true"
+ }
+ }
+ },
+ "alertThrottle": {
+ "type": "keyword"
+ },
+ "ruleAlertId": {
+ "type": "keyword"
+ },
+ "ruleThrottle": {
+ "type": "keyword"
+ }
+ }
+ },
+ "siem-detection-engine-rule-status": {
+ "properties": {
+ "alertId": {
+ "type": "keyword"
+ },
+ "bulkCreateTimeDurations": {
+ "type": "float"
+ },
+ "gap": {
+ "type": "text"
+ },
+ "lastFailureAt": {
+ "type": "date"
+ },
+ "lastFailureMessage": {
+ "type": "text"
+ },
+ "lastLookBackDate": {
+ "type": "date"
+ },
+ "lastSuccessAt": {
+ "type": "date"
+ },
+ "lastSuccessMessage": {
+ "type": "text"
+ },
+ "searchAfterTimeDurations": {
+ "type": "float"
+ },
+ "status": {
+ "type": "keyword"
+ },
+ "statusDate": {
+ "type": "date"
+ }
+ }
+ },
"siem-ui-timeline": {
"properties": {
"columns": {
@@ -1145,6 +2532,9 @@
"description": {
"type": "text"
},
+ "eventType": {
+ "type": "keyword"
+ },
"favorite": {
"properties": {
"favoriteDate": {
@@ -1349,6 +2739,9 @@
},
"telemetry": {
"properties": {
+ "allowChangingOptInStatus": {
+ "type": "boolean"
+ },
"enabled": {
"type": "boolean"
},
@@ -1356,12 +2749,16 @@
"type": "date"
},
"lastVersionChecked": {
- "type": "keyword",
- "ignore_above": 256
+ "type": "keyword"
+ },
+ "reportFailureCount": {
+ "type": "integer"
+ },
+ "reportFailureVersion": {
+ "type": "keyword"
},
"sendUsageFrom": {
- "type": "keyword",
- "ignore_above": 256
+ "type": "keyword"
},
"userHasSeenNotice": {
"type": "boolean"
@@ -1409,6 +2806,13 @@
}
}
},
+ "tsvb-validation-telemetry": {
+ "properties": {
+ "failedRequests": {
+ "type": "long"
+ }
+ }
+ },
"type": {
"type": "keyword"
},
@@ -1485,6 +2889,13 @@
}
}
},
+ "uptime-dynamic-settings": {
+ "properties": {
+ "heartbeatIndices": {
+ "type": "keyword"
+ }
+ }
+ },
"url": {
"properties": {
"accessCount": {
From ab0cc8894a924dda18fc8664cf903fdf7a2d9920 Mon Sep 17 00:00:00 2001
From: Chris Roberson
Date: Mon, 6 Apr 2020 15:31:01 -0400
Subject: [PATCH 22/27] [Monitoring] Cluster state watch to Kibana alerting
(#61685)
* WIP
* Add new alert with tests
* Fix type issues, and disable new alerting for tests
* Fix up the view all alerts view
* Turn off for merging
* Fix jest test
Co-authored-by: Elastic Machine
---
.../plugins/monitoring/common/constants.ts | 8 +-
.../public/components/alerts/alerts.js | 44 +-
.../public/components/alerts/status.test.tsx | 8 +-
.../public/components/alerts/status.tsx | 2 +-
.../cluster/overview/alerts_panel.js | 81 ++-
.../monitoring/public/views/alerts/index.js | 30 +-
x-pack/plugins/monitoring/common/constants.ts | 8 +-
.../server/alerts/cluster_state.test.ts | 186 ++++++
.../monitoring/server/alerts/cluster_state.ts | 134 ++++
.../plugins/monitoring/server/alerts/enums.ts | 16 +
.../server/alerts/license_expiration.test.ts | 572 +++++-------------
.../server/alerts/license_expiration.ts | 127 ++--
.../monitoring/server/alerts/types.d.ts | 62 +-
.../lib/alerts/cluster_state.lib.test.ts | 70 +++
.../server/lib/alerts/cluster_state.lib.ts | 88 +++
.../lib/alerts/fetch_cluster_state.test.ts | 39 ++
.../server/lib/alerts/fetch_cluster_state.ts | 53 ++
.../server/lib/alerts/fetch_clusters.test.ts | 46 +-
.../server/lib/alerts/fetch_clusters.ts | 41 +-
.../server/lib/alerts/fetch_licenses.test.ts | 67 +-
.../server/lib/alerts/fetch_licenses.ts | 16 +-
.../server/lib/alerts/fetch_status.test.ts | 122 ++++
.../server/lib/alerts/fetch_status.ts | 100 ++-
.../lib/alerts/get_prepared_alert.test.ts | 163 +++++
.../server/lib/alerts/get_prepared_alert.ts | 87 +++
.../lib/alerts/license_expiration.lib.test.ts | 23 +-
.../lib/alerts/license_expiration.lib.ts | 56 +-
.../lib/cluster/get_clusters_from_request.js | 12 +-
x-pack/plugins/monitoring/server/plugin.ts | 12 +
.../server/routes/api/v1/alerts/alerts.js | 53 +-
30 files changed, 1570 insertions(+), 756 deletions(-)
create mode 100644 x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts
create mode 100644 x-pack/plugins/monitoring/server/alerts/cluster_state.ts
create mode 100644 x-pack/plugins/monitoring/server/alerts/enums.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.test.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.test.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/fetch_status.test.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.test.ts
create mode 100644 x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.ts
diff --git a/x-pack/legacy/plugins/monitoring/common/constants.ts b/x-pack/legacy/plugins/monitoring/common/constants.ts
index 9a4030f3eb21..3a4c7b71dcd0 100644
--- a/x-pack/legacy/plugins/monitoring/common/constants.ts
+++ b/x-pack/legacy/plugins/monitoring/common/constants.ts
@@ -239,11 +239,15 @@ export const ALERT_TYPE_PREFIX = 'monitoring_';
* This is the alert type id for the license expiration alert
*/
export const ALERT_TYPE_LICENSE_EXPIRATION = `${ALERT_TYPE_PREFIX}alert_type_license_expiration`;
+/**
+ * This is the alert type id for the cluster state alert
+ */
+export const ALERT_TYPE_CLUSTER_STATE = `${ALERT_TYPE_PREFIX}alert_type_cluster_state`;
/**
* A listing of all alert types
*/
-export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION];
+export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION, ALERT_TYPE_CLUSTER_STATE];
/**
* Matches the id for the built-in in email action type
@@ -254,7 +258,7 @@ export const ALERT_ACTION_TYPE_EMAIL = '.email';
/**
* The number of alerts that have been migrated
*/
-export const NUMBER_OF_MIGRATED_ALERTS = 1;
+export const NUMBER_OF_MIGRATED_ALERTS = 2;
/**
* The advanced settings config name for the email address
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/alerts.js b/x-pack/legacy/plugins/monitoring/public/components/alerts/alerts.js
index 11fcef73a4b9..95c1af554919 100644
--- a/x-pack/legacy/plugins/monitoring/public/components/alerts/alerts.js
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/alerts.js
@@ -6,10 +6,15 @@
import React from 'react';
import chrome from '../../np_imports/ui/chrome';
-import { capitalize } from 'lodash';
+import { capitalize, get } from 'lodash';
import { formatDateTimeLocal } from '../../../common/formatting';
import { formatTimestampToDuration } from '../../../common';
-import { CALCULATE_DURATION_SINCE, EUI_SORT_DESCENDING } from '../../../common/constants';
+import {
+ CALCULATE_DURATION_SINCE,
+ EUI_SORT_DESCENDING,
+ ALERT_TYPE_LICENSE_EXPIRATION,
+ ALERT_TYPE_CLUSTER_STATE,
+} from '../../../common/constants';
import { mapSeverity } from './map_severity';
import { FormattedAlert } from 'plugins/monitoring/components/alerts/formatted_alert';
import { EuiMonitoringTable } from 'plugins/monitoring/components/table';
@@ -21,6 +26,8 @@ const linkToCategories = {
'elasticsearch/indices': 'Elasticsearch Indices',
'kibana/instances': 'Kibana Instances',
'logstash/instances': 'Logstash Nodes',
+ [ALERT_TYPE_LICENSE_EXPIRATION]: 'License expiration',
+ [ALERT_TYPE_CLUSTER_STATE]: 'Cluster state',
};
const getColumns = (kbnUrl, scope, timezone) => [
{
@@ -94,19 +101,22 @@ const getColumns = (kbnUrl, scope, timezone) => [
}),
field: 'message',
sortable: true,
- render: (message, alert) => (
- {
- scope.$evalAsync(() => {
- kbnUrl.changePath(target);
- });
- }}
- />
- ),
+ render: (_message, alert) => {
+ const message = get(alert, 'message.text', get(alert, 'message', ''));
+ return (
+ {
+ scope.$evalAsync(() => {
+ kbnUrl.changePath(target);
+ });
+ }}
+ />
+ );
+ },
},
{
name: i18n.translate('xpack.monitoring.alerts.categoryColumnTitle', {
@@ -148,8 +158,8 @@ const getColumns = (kbnUrl, scope, timezone) => [
export const Alerts = ({ alerts, angular, sorting, pagination, onTableChange }) => {
const alertsFlattened = alerts.map(alert => ({
...alert,
- status: alert.metadata.severity,
- category: alert.metadata.link,
+ status: get(alert, 'metadata.severity', get(alert, 'severity', 0)),
+ category: get(alert, 'metadata.link', get(alert, 'type', null)),
}));
const injector = chrome.dangerouslyGetActiveInjector();
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx
index 258a5b68db37..d3cf4b463a2c 100644
--- a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx
@@ -8,7 +8,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { kfetch } from 'ui/kfetch';
import { AlertsStatus, AlertsStatusProps } from './status';
-import { ALERT_TYPE_PREFIX } from '../../../common/constants';
+import { ALERT_TYPES } from '../../../common/constants';
import { getSetupModeState } from '../../lib/setup_mode';
import { mockUseEffects } from '../../jest.helpers';
@@ -63,11 +63,7 @@ describe('Status', () => {
it('should render a success message if all alerts have been migrated and in setup mode', async () => {
(kfetch as jest.Mock).mockReturnValue({
- data: [
- {
- alertTypeId: ALERT_TYPE_PREFIX,
- },
- ],
+ data: ALERT_TYPES.map(type => ({ alertTypeId: type })),
});
(getSetupModeState as jest.Mock).mockReturnValue({
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx
index 072a98b12345..5f5329bf7fff 100644
--- a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx
@@ -142,7 +142,7 @@ export const AlertsStatus: React.FC = (props: AlertsStatusPro
);
}
- const allMigrated = kibanaAlerts.length === NUMBER_OF_MIGRATED_ALERTS;
+ const allMigrated = kibanaAlerts.length >= NUMBER_OF_MIGRATED_ALERTS;
if (allMigrated) {
if (setupModeEnabled) {
return (
diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js
index 8455fb8cf308..d87ff98e79be 100644
--- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js
+++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js
@@ -6,14 +6,12 @@
import React, { Fragment } from 'react';
import moment from 'moment-timezone';
-import chrome from '../../../np_imports/ui/chrome';
import { FormattedAlert } from 'plugins/monitoring/components/alerts/formatted_alert';
import { mapSeverity } from 'plugins/monitoring/components/alerts/map_severity';
import { formatTimestampToDuration } from '../../../../common/format_timestamp_to_duration';
import {
CALCULATE_DURATION_SINCE,
KIBANA_ALERTING_ENABLED,
- ALERT_TYPE_LICENSE_EXPIRATION,
CALCULATE_DURATION_UNTIL,
} from '../../../../common/constants';
import { formatDateTimeLocal } from '../../../../common/formatting';
@@ -31,6 +29,37 @@ import {
EuiLink,
} from '@elastic/eui';
+function replaceTokens(alert) {
+ if (!alert.message.tokens) {
+ return alert.message.text;
+ }
+
+ let text = alert.message.text;
+
+ for (const token of alert.message.tokens) {
+ if (token.type === 'time') {
+ text = text.replace(
+ token.startToken,
+ token.isRelative
+ ? formatTimestampToDuration(alert.expirationTime, CALCULATE_DURATION_UNTIL)
+ : moment.tz(alert.expirationTime, moment.tz.guess()).format('LLL z')
+ );
+ } else if (token.type === 'link') {
+ const linkPart = new RegExp(`${token.startToken}(.+?)${token.endToken}`).exec(text);
+ // TODO: we assume this is at the end, which works for now but will not always work
+ const nonLinkText = text.replace(linkPart[0], '');
+ text = (
+
+ {nonLinkText}
+ {linkPart[1]}
+
+ );
+ }
+ }
+
+ return text;
+}
+
export function AlertsPanel({ alerts, changeUrl }) {
const goToAlerts = () => changeUrl('/alerts');
@@ -58,9 +87,6 @@ export function AlertsPanel({ alerts, changeUrl }) {
severityIcon.iconType = 'check';
}
- const injector = chrome.dangerouslyGetActiveInjector();
- const timezone = injector.get('config').get('dateFormat:tz');
-
return (
@@ -96,14 +122,7 @@ export function AlertsPanel({ alerts, changeUrl }) {
const alertsList = KIBANA_ALERTING_ENABLED
? alerts.map((alert, idx) => {
const callOutProps = mapSeverity(alert.severity);
- let message = alert.message
- // scan message prefix and replace relative times
- // \w: Matches any alphanumeric character from the basic Latin alphabet, including the underscore. Equivalent to [A-Za-z0-9_].
- .replace(
- '#relative',
- formatTimestampToDuration(alert.expirationTime, CALCULATE_DURATION_UNTIL)
- )
- .replace('#absolute', moment.tz(alert.expirationTime, moment.tz.guess()).format('LLL z'));
+ const message = replaceTokens(alert);
if (!alert.isFiring) {
callOutProps.title = i18n.translate(
@@ -118,22 +137,30 @@ export function AlertsPanel({ alerts, changeUrl }) {
);
callOutProps.color = 'success';
callOutProps.iconType = 'check';
- } else {
- if (alert.type === ALERT_TYPE_LICENSE_EXPIRATION) {
- message = (
-
- {message}
-
- Please update your license
-
- );
- }
}
return (
-
- {message}
-
+
+
+ {message}
+
+
+
+
+
+
+
+
);
})
: alerts.map((item, index) => (
diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js
index 7c065a78a8af..62cc985887e9 100644
--- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js
+++ b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js
@@ -18,25 +18,37 @@ import { Alerts } from '../../components/alerts';
import { MonitoringViewBaseEuiTableController } from '../base_eui_table_controller';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLink } from '@elastic/eui';
-import { CODE_PATH_ALERTS } from '../../../common/constants';
+import { CODE_PATH_ALERTS, KIBANA_ALERTING_ENABLED } from '../../../common/constants';
function getPageData($injector) {
const globalState = $injector.get('globalState');
const $http = $injector.get('$http');
const Private = $injector.get('Private');
- const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/legacy_alerts`;
+ const url = KIBANA_ALERTING_ENABLED
+ ? `../api/monitoring/v1/alert_status`
+ : `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/legacy_alerts`;
const timeBounds = timefilter.getBounds();
+ const data = {
+ timeRange: {
+ min: timeBounds.min.toISOString(),
+ max: timeBounds.max.toISOString(),
+ },
+ };
+
+ if (!KIBANA_ALERTING_ENABLED) {
+ data.ccs = globalState.ccs;
+ }
return $http
- .post(url, {
- ccs: globalState.ccs,
- timeRange: {
- min: timeBounds.min.toISOString(),
- max: timeBounds.max.toISOString(),
- },
+ .post(url, data)
+ .then(response => {
+ const result = get(response, 'data', []);
+ if (KIBANA_ALERTING_ENABLED) {
+ return result.alerts;
+ }
+ return result;
})
- .then(response => get(response, 'data', []))
.catch(err => {
const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider);
return ajaxErrorHandlers(err);
diff --git a/x-pack/plugins/monitoring/common/constants.ts b/x-pack/plugins/monitoring/common/constants.ts
index 9a4030f3eb21..3a4c7b71dcd0 100644
--- a/x-pack/plugins/monitoring/common/constants.ts
+++ b/x-pack/plugins/monitoring/common/constants.ts
@@ -239,11 +239,15 @@ export const ALERT_TYPE_PREFIX = 'monitoring_';
* This is the alert type id for the license expiration alert
*/
export const ALERT_TYPE_LICENSE_EXPIRATION = `${ALERT_TYPE_PREFIX}alert_type_license_expiration`;
+/**
+ * This is the alert type id for the cluster state alert
+ */
+export const ALERT_TYPE_CLUSTER_STATE = `${ALERT_TYPE_PREFIX}alert_type_cluster_state`;
/**
* A listing of all alert types
*/
-export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION];
+export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION, ALERT_TYPE_CLUSTER_STATE];
/**
* Matches the id for the built-in in email action type
@@ -254,7 +258,7 @@ export const ALERT_ACTION_TYPE_EMAIL = '.email';
/**
* The number of alerts that have been migrated
*/
-export const NUMBER_OF_MIGRATED_ALERTS = 1;
+export const NUMBER_OF_MIGRATED_ALERTS = 2;
/**
* The advanced settings config name for the email address
diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts b/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts
new file mode 100644
index 000000000000..6a9ca8843734
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts
@@ -0,0 +1,186 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Logger } from 'src/core/server';
+import { savedObjectsClientMock } from 'src/core/server/mocks';
+import { getClusterState } from './cluster_state';
+import { AlertServices } from '../../../alerting/server';
+import { ALERT_TYPE_CLUSTER_STATE } from '../../common/constants';
+import { AlertCommonParams, AlertCommonState, AlertClusterStatePerClusterState } from './types';
+import { getPreparedAlert } from '../lib/alerts/get_prepared_alert';
+import { executeActions } from '../lib/alerts/cluster_state.lib';
+import { AlertClusterStateState } from './enums';
+
+jest.mock('../lib/alerts/cluster_state.lib', () => ({
+ executeActions: jest.fn(),
+ getUiMessage: jest.fn(),
+}));
+
+jest.mock('../lib/alerts/get_prepared_alert', () => ({
+ getPreparedAlert: jest.fn(() => {
+ return {
+ emailAddress: 'foo@foo.com',
+ };
+ }),
+}));
+
+interface MockServices {
+ callCluster: jest.Mock;
+ alertInstanceFactory: jest.Mock;
+ savedObjectsClient: jest.Mock;
+}
+
+describe('getClusterState', () => {
+ const services: MockServices | AlertServices = {
+ callCluster: jest.fn(),
+ alertInstanceFactory: jest.fn(),
+ savedObjectsClient: savedObjectsClientMock.create(),
+ };
+
+ const params: AlertCommonParams = {
+ dateFormat: 'YYYY',
+ timezone: 'UTC',
+ };
+
+ const emailAddress = 'foo@foo.com';
+ const clusterUuid = 'kdksdfj434';
+ const clusterName = 'monitoring_test';
+ const cluster = { clusterUuid, clusterName };
+
+ async function setupAlert(
+ previousState: AlertClusterStateState,
+ newState: AlertClusterStateState
+ ): Promise {
+ const logger: Logger = {
+ warn: jest.fn(),
+ log: jest.fn(),
+ debug: jest.fn(),
+ trace: jest.fn(),
+ error: jest.fn(),
+ fatal: jest.fn(),
+ info: jest.fn(),
+ get: jest.fn(),
+ };
+ const getLogger = (): Logger => logger;
+ const ccrEnabled = false;
+ (getPreparedAlert as jest.Mock).mockImplementation(() => ({
+ emailAddress,
+ data: [
+ {
+ state: newState,
+ clusterUuid,
+ },
+ ],
+ clusters: [cluster],
+ }));
+
+ const alert = getClusterState(null as any, null as any, getLogger, ccrEnabled);
+ const state: AlertCommonState = {
+ [clusterUuid]: {
+ state: previousState,
+ ui: {
+ isFiring: false,
+ severity: 0,
+ message: null,
+ resolvedMS: 0,
+ lastCheckedMS: 0,
+ triggeredMS: 0,
+ },
+ } as AlertClusterStatePerClusterState,
+ };
+
+ return (await alert.executor({ services, params, state } as any)) as AlertCommonState;
+ }
+
+ afterEach(() => {
+ (executeActions as jest.Mock).mockClear();
+ });
+
+ it('should configure the alert properly', () => {
+ const alert = getClusterState(null as any, null as any, jest.fn(), false);
+ expect(alert.id).toBe(ALERT_TYPE_CLUSTER_STATE);
+ expect(alert.actionGroups).toEqual([{ id: 'default', name: 'Default' }]);
+ });
+
+ it('should alert if green -> yellow', async () => {
+ const result = await setupAlert(AlertClusterStateState.Green, AlertClusterStateState.Yellow);
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ AlertClusterStateState.Yellow,
+ emailAddress
+ );
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Yellow);
+ expect(clusterResult.ui.isFiring).toBe(true);
+ expect(clusterResult.ui.resolvedMS).toBe(0);
+ });
+
+ it('should alert if yellow -> green', async () => {
+ const result = await setupAlert(AlertClusterStateState.Yellow, AlertClusterStateState.Green);
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ AlertClusterStateState.Green,
+ emailAddress,
+ true
+ );
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Green);
+ expect(clusterResult.ui.resolvedMS).toBeGreaterThan(0);
+ });
+
+ it('should alert if green -> red', async () => {
+ const result = await setupAlert(AlertClusterStateState.Green, AlertClusterStateState.Red);
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ AlertClusterStateState.Red,
+ emailAddress
+ );
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Red);
+ expect(clusterResult.ui.isFiring).toBe(true);
+ expect(clusterResult.ui.resolvedMS).toBe(0);
+ });
+
+ it('should alert if red -> green', async () => {
+ const result = await setupAlert(AlertClusterStateState.Red, AlertClusterStateState.Green);
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ AlertClusterStateState.Green,
+ emailAddress,
+ true
+ );
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Green);
+ expect(clusterResult.ui.resolvedMS).toBeGreaterThan(0);
+ });
+
+ it('should not alert if red -> yellow', async () => {
+ const result = await setupAlert(AlertClusterStateState.Red, AlertClusterStateState.Yellow);
+ expect(executeActions).not.toHaveBeenCalled();
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Red);
+ expect(clusterResult.ui.resolvedMS).toBe(0);
+ });
+
+ it('should not alert if yellow -> red', async () => {
+ const result = await setupAlert(AlertClusterStateState.Yellow, AlertClusterStateState.Red);
+ expect(executeActions).not.toHaveBeenCalled();
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Yellow);
+ expect(clusterResult.ui.resolvedMS).toBe(0);
+ });
+
+ it('should not alert if green -> green', async () => {
+ const result = await setupAlert(AlertClusterStateState.Green, AlertClusterStateState.Green);
+ expect(executeActions).not.toHaveBeenCalled();
+ const clusterResult = result[clusterUuid] as AlertClusterStatePerClusterState;
+ expect(clusterResult.state).toBe(AlertClusterStateState.Green);
+ expect(clusterResult.ui.resolvedMS).toBe(0);
+ });
+});
diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_state.ts b/x-pack/plugins/monitoring/server/alerts/cluster_state.ts
new file mode 100644
index 000000000000..9a5805b8af7c
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/alerts/cluster_state.ts
@@ -0,0 +1,134 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import moment from 'moment-timezone';
+import { i18n } from '@kbn/i18n';
+import { Logger, ICustomClusterClient, UiSettingsServiceStart } from 'src/core/server';
+import { ALERT_TYPE_CLUSTER_STATE } from '../../common/constants';
+import { AlertType } from '../../../alerting/server';
+import { executeActions, getUiMessage } from '../lib/alerts/cluster_state.lib';
+import {
+ AlertCommonExecutorOptions,
+ AlertCommonState,
+ AlertClusterStatePerClusterState,
+ AlertCommonCluster,
+} from './types';
+import { AlertClusterStateState } from './enums';
+import { getPreparedAlert } from '../lib/alerts/get_prepared_alert';
+import { fetchClusterState } from '../lib/alerts/fetch_cluster_state';
+
+export const getClusterState = (
+ getUiSettingsService: () => Promise,
+ monitoringCluster: ICustomClusterClient,
+ getLogger: (...scopes: string[]) => Logger,
+ ccsEnabled: boolean
+): AlertType => {
+ const logger = getLogger(ALERT_TYPE_CLUSTER_STATE);
+ return {
+ id: ALERT_TYPE_CLUSTER_STATE,
+ name: 'Monitoring Alert - Cluster Status',
+ actionGroups: [
+ {
+ id: 'default',
+ name: i18n.translate('xpack.monitoring.alerts.clusterState.actionGroups.default', {
+ defaultMessage: 'Default',
+ }),
+ },
+ ],
+ defaultActionGroupId: 'default',
+ async executor({
+ services,
+ params,
+ state,
+ }: AlertCommonExecutorOptions): Promise {
+ logger.debug(
+ `Firing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}`
+ );
+
+ const preparedAlert = await getPreparedAlert(
+ ALERT_TYPE_CLUSTER_STATE,
+ getUiSettingsService,
+ monitoringCluster,
+ logger,
+ ccsEnabled,
+ services,
+ fetchClusterState
+ );
+
+ if (!preparedAlert) {
+ return state;
+ }
+
+ const { emailAddress, data: states, clusters } = preparedAlert;
+
+ const result: AlertCommonState = { ...state };
+ const defaultAlertState: AlertClusterStatePerClusterState = {
+ state: AlertClusterStateState.Green,
+ ui: {
+ isFiring: false,
+ message: null,
+ severity: 0,
+ resolvedMS: 0,
+ triggeredMS: 0,
+ lastCheckedMS: 0,
+ },
+ };
+
+ for (const clusterState of states) {
+ const alertState: AlertClusterStatePerClusterState =
+ (state[clusterState.clusterUuid] as AlertClusterStatePerClusterState) ||
+ defaultAlertState;
+ const cluster = clusters.find(
+ (c: AlertCommonCluster) => c.clusterUuid === clusterState.clusterUuid
+ );
+ if (!cluster) {
+ logger.warn(`Unable to find cluster for clusterUuid='${clusterState.clusterUuid}'`);
+ continue;
+ }
+ const isNonGreen = clusterState.state !== AlertClusterStateState.Green;
+ const severity = clusterState.state === AlertClusterStateState.Red ? 2100 : 1100;
+
+ const ui = alertState.ui;
+ let triggered = ui.triggeredMS;
+ let resolved = ui.resolvedMS;
+ let message = ui.message || {};
+ let lastState = alertState.state;
+ const instance = services.alertInstanceFactory(ALERT_TYPE_CLUSTER_STATE);
+
+ if (isNonGreen) {
+ if (lastState === AlertClusterStateState.Green) {
+ logger.debug(`Cluster state changed from green to ${clusterState.state}`);
+ executeActions(instance, cluster, clusterState.state, emailAddress);
+ lastState = clusterState.state;
+ triggered = moment().valueOf();
+ }
+ message = getUiMessage(clusterState.state);
+ resolved = 0;
+ } else if (!isNonGreen && lastState !== AlertClusterStateState.Green) {
+ logger.debug(`Cluster state changed from ${lastState} to green`);
+ executeActions(instance, cluster, clusterState.state, emailAddress, true);
+ lastState = clusterState.state;
+ message = getUiMessage(clusterState.state, true);
+ resolved = moment().valueOf();
+ }
+
+ result[clusterState.clusterUuid] = {
+ state: lastState,
+ ui: {
+ message,
+ isFiring: isNonGreen,
+ severity,
+ resolvedMS: resolved,
+ triggeredMS: triggered,
+ lastCheckedMS: moment().valueOf(),
+ },
+ } as AlertClusterStatePerClusterState;
+ }
+
+ return result;
+ },
+ };
+};
diff --git a/x-pack/plugins/monitoring/server/alerts/enums.ts b/x-pack/plugins/monitoring/server/alerts/enums.ts
new file mode 100644
index 000000000000..ccff588743af
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/alerts/enums.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export enum AlertClusterStateState {
+ Green = 'green',
+ Red = 'red',
+ Yellow = 'yellow',
+}
+
+export enum AlertCommonPerClusterMessageTokenType {
+ Time = 'time',
+ Link = 'link',
+}
diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts
index 0773af6e7f07..92047e300bc1 100644
--- a/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts
+++ b/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts
@@ -6,42 +6,31 @@
import moment from 'moment-timezone';
import { getLicenseExpiration } from './license_expiration';
-import {
- ALERT_TYPE_LICENSE_EXPIRATION,
- MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS,
-} from '../../common/constants';
+import { ALERT_TYPE_LICENSE_EXPIRATION } from '../../common/constants';
import { Logger } from 'src/core/server';
-import { AlertServices, AlertInstance } from '../../../alerting/server';
+import { AlertServices } from '../../../alerting/server';
import { savedObjectsClientMock } from 'src/core/server/mocks';
import {
- AlertState,
- AlertClusterState,
- AlertParams,
- LicenseExpirationAlertExecutorOptions,
+ AlertCommonParams,
+ AlertCommonState,
+ AlertLicensePerClusterState,
+ AlertLicense,
} from './types';
-import { SavedObject, SavedObjectAttributes } from 'src/core/server';
-import { SavedObjectsClientContract } from 'src/core/server';
-
-function fillLicense(license: any, clusterUuid?: string) {
- return {
- hits: {
- hits: [
- {
- _source: {
- license,
- cluster_uuid: clusterUuid,
- },
- },
- ],
- },
- };
-}
-
-const clusterUuid = 'a4545jhjb';
-const params: AlertParams = {
- dateFormat: 'YYYY',
- timezone: 'UTC',
-};
+import { executeActions } from '../lib/alerts/license_expiration.lib';
+import { PreparedAlert, getPreparedAlert } from '../lib/alerts/get_prepared_alert';
+
+jest.mock('../lib/alerts/license_expiration.lib', () => ({
+ executeActions: jest.fn(),
+ getUiMessage: jest.fn(),
+}));
+
+jest.mock('../lib/alerts/get_prepared_alert', () => ({
+ getPreparedAlert: jest.fn(() => {
+ return {
+ emailAddress: 'foo@foo.com',
+ };
+ }),
+}));
interface MockServices {
callCluster: jest.Mock;
@@ -49,428 +38,169 @@ interface MockServices {
savedObjectsClient: jest.Mock;
}
-const alertExecutorOptions: LicenseExpirationAlertExecutorOptions = {
- alertId: '',
- startedAt: new Date(),
- services: {
- callCluster: (path: string, opts: any) => new Promise(resolve => resolve()),
- alertInstanceFactory: (id: string) => new AlertInstance(),
- savedObjectsClient: {} as jest.Mocked,
- },
- params: {},
- state: {},
- spaceId: '',
- name: '',
- tags: [],
- previousStartedAt: null,
- createdBy: null,
- updatedBy: null,
-};
-
describe('getLicenseExpiration', () => {
- const emailAddress = 'foo@foo.com';
- const getUiSettingsService: any = () => ({
- asScopedToClient: (): any => ({
- get: () => new Promise(resolve => resolve(emailAddress)),
- }),
- });
- const monitoringCluster: any = null;
- const logger: Logger = {
- warn: jest.fn(),
- log: jest.fn(),
- debug: jest.fn(),
- trace: jest.fn(),
- error: jest.fn(),
- fatal: jest.fn(),
- info: jest.fn(),
- get: jest.fn(),
+ const services: MockServices | AlertServices = {
+ callCluster: jest.fn(),
+ alertInstanceFactory: jest.fn(),
+ savedObjectsClient: savedObjectsClientMock.create(),
};
- const getLogger = (): Logger => logger;
- const ccrEnabled = false;
- afterEach(() => {
- (logger.warn as jest.Mock).mockClear();
- });
-
- it('should have the right id and actionGroups', () => {
- const alert = getLicenseExpiration(
- getUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
- expect(alert.id).toBe(ALERT_TYPE_LICENSE_EXPIRATION);
- expect(alert.actionGroups).toEqual([{ id: 'default', name: 'Default' }]);
- });
+ const params: AlertCommonParams = {
+ dateFormat: 'YYYY',
+ timezone: 'UTC',
+ };
- it('should return the state if no license is provided', async () => {
- const alert = getLicenseExpiration(
- getUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
+ const emailAddress = 'foo@foo.com';
+ const clusterUuid = 'kdksdfj434';
+ const clusterName = 'monitoring_test';
+ const dateFormat = 'YYYY-MM-DD';
+ const cluster = { clusterUuid, clusterName };
+ const defaultUiState = {
+ isFiring: false,
+ severity: 0,
+ message: null,
+ resolvedMS: 0,
+ lastCheckedMS: 0,
+ triggeredMS: 0,
+ };
- const services: MockServices | AlertServices = {
- callCluster: jest.fn(),
- alertInstanceFactory: jest.fn(),
- savedObjectsClient: savedObjectsClientMock.create(),
+ async function setupAlert(
+ license: AlertLicense | null,
+ expiredCheckDateMS: number,
+ preparedAlertResponse: PreparedAlert | null | undefined = undefined
+ ): Promise {
+ const logger: Logger = {
+ warn: jest.fn(),
+ log: jest.fn(),
+ debug: jest.fn(),
+ trace: jest.fn(),
+ error: jest.fn(),
+ fatal: jest.fn(),
+ info: jest.fn(),
+ get: jest.fn(),
};
- const state = { foo: 1 };
-
- const result = await alert.executor({
- ...alertExecutorOptions,
- services,
- params,
- state,
- });
-
- expect(result).toEqual(state);
- });
+ const getLogger = (): Logger => logger;
+ const ccrEnabled = false;
+ (getPreparedAlert as jest.Mock).mockImplementation(() => {
+ if (preparedAlertResponse !== undefined) {
+ return preparedAlertResponse;
+ }
- it('should log a warning if no email is provided', async () => {
- const customGetUiSettingsService: any = () => ({
- asScopedToClient: () => ({
- get: () => null,
- }),
+ return {
+ emailAddress,
+ data: [license],
+ clusters: [cluster],
+ dateFormat,
+ };
});
- const alert = getLicenseExpiration(
- customGetUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
- const services = {
- callCluster: jest.fn(
- (method: string, { filterPath }): Promise => {
- return new Promise(resolve => {
- if (filterPath.includes('hits.hits._source.license.*')) {
- resolve(
- fillLicense({
- status: 'good',
- type: 'basic',
- expiry_date_in_millis: moment()
- .add(7, 'days')
- .valueOf(),
- })
- );
- }
- resolve({});
- });
- }
- ),
- alertInstanceFactory: jest.fn(),
- savedObjectsClient: savedObjectsClientMock.create(),
+ const alert = getLicenseExpiration(null as any, null as any, getLogger, ccrEnabled);
+ const state: AlertCommonState = {
+ [clusterUuid]: {
+ expiredCheckDateMS,
+ ui: { ...defaultUiState },
+ } as AlertLicensePerClusterState,
};
- const state = {};
+ return (await alert.executor({ services, params, state } as any)) as AlertCommonState;
+ }
- await alert.executor({
- ...alertExecutorOptions,
- services,
- params,
- state,
- });
-
- expect((logger.warn as jest.Mock).mock.calls.length).toBe(1);
- expect(logger.warn).toHaveBeenCalledWith(
- `Unable to send email for ${ALERT_TYPE_LICENSE_EXPIRATION} because there is no email configured.`
- );
+ afterEach(() => {
+ (executeActions as jest.Mock).mockClear();
+ (getPreparedAlert as jest.Mock).mockClear();
});
- it('should fire actions if going to expire', async () => {
- const scheduleActions = jest.fn();
- const alertInstanceFactory = jest.fn(
- (id: string): AlertInstance => {
- const instance = new AlertInstance();
- instance.scheduleActions = scheduleActions;
- return instance;
- }
- );
+ it('should have the right id and actionGroups', () => {
+ const alert = getLicenseExpiration(null as any, null as any, jest.fn(), false);
+ expect(alert.id).toBe(ALERT_TYPE_LICENSE_EXPIRATION);
+ expect(alert.actionGroups).toEqual([{ id: 'default', name: 'Default' }]);
+ });
- const alert = getLicenseExpiration(
- getUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
+ it('should return the state if no license is provided', async () => {
+ const result = await setupAlert(null, 0, null);
+ expect(result[clusterUuid].ui).toEqual(defaultUiState);
+ });
- const savedObjectsClient = savedObjectsClientMock.create();
- savedObjectsClient.get.mockReturnValue(
- new Promise(resolve => {
- const savedObject: SavedObject = {
- id: '',
- type: '',
- references: [],
- attributes: {
- [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
- },
- };
- resolve(savedObject);
- })
- );
- const services = {
- callCluster: jest.fn(
- (method: string, { filterPath }): Promise => {
- return new Promise(resolve => {
- if (filterPath.includes('hits.hits._source.license.*')) {
- resolve(
- fillLicense(
- {
- status: 'active',
- type: 'gold',
- expiry_date_in_millis: moment()
- .add(7, 'days')
- .valueOf(),
- },
- clusterUuid
- )
- );
- }
- resolve({});
- });
- }
- ),
- alertInstanceFactory,
- savedObjectsClient,
+ it('should fire actions if going to expire', async () => {
+ const expiryDateMS = moment()
+ .add(7, 'days')
+ .valueOf();
+ const license = {
+ status: 'active',
+ type: 'gold',
+ expiryDateMS,
+ clusterUuid,
};
-
- const state = {};
-
- const result: AlertState = (await alert.executor({
- ...alertExecutorOptions,
- services,
- params,
- state,
- })) as AlertState;
-
- const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
-
+ const result = await setupAlert(license, 0);
+ const newState = result[clusterUuid] as AlertLicensePerClusterState;
expect(newState.expiredCheckDateMS > 0).toBe(true);
- expect(scheduleActions.mock.calls.length).toBe(1);
- expect(scheduleActions.mock.calls[0][1].subject).toBe(
- 'NEW X-Pack Monitoring: License Expiration'
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ moment.utc(expiryDateMS),
+ dateFormat,
+ emailAddress
);
- expect(scheduleActions.mock.calls[0][1].to).toBe(emailAddress);
});
it('should fire actions if the user fixed their license', async () => {
- const scheduleActions = jest.fn();
- const alertInstanceFactory = jest.fn(
- (id: string): AlertInstance => {
- const instance = new AlertInstance();
- instance.scheduleActions = scheduleActions;
- return instance;
- }
- );
- const alert = getLicenseExpiration(
- getUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
-
- const savedObjectsClient = savedObjectsClientMock.create();
- savedObjectsClient.get.mockReturnValue(
- new Promise(resolve => {
- const savedObject: SavedObject = {
- id: '',
- type: '',
- references: [],
- attributes: {
- [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
- },
- };
- resolve(savedObject);
- })
- );
- const services = {
- callCluster: jest.fn(
- (method: string, { filterPath }): Promise => {
- return new Promise(resolve => {
- if (filterPath.includes('hits.hits._source.license.*')) {
- resolve(
- fillLicense(
- {
- status: 'active',
- type: 'gold',
- expiry_date_in_millis: moment()
- .add(120, 'days')
- .valueOf(),
- },
- clusterUuid
- )
- );
- }
- resolve({});
- });
- }
- ),
- alertInstanceFactory,
- savedObjectsClient,
- };
-
- const state: AlertState = {
- [clusterUuid]: {
- expiredCheckDateMS: moment()
- .subtract(1, 'day')
- .valueOf(),
- ui: { isFiring: true, severity: 0, message: null, resolvedMS: 0, expirationTime: 0 },
- },
+ const expiryDateMS = moment()
+ .add(365, 'days')
+ .valueOf();
+ const license = {
+ status: 'active',
+ type: 'gold',
+ expiryDateMS,
+ clusterUuid,
};
-
- const result: AlertState = (await alert.executor({
- ...alertExecutorOptions,
- services,
- params,
- state,
- })) as AlertState;
-
- const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
+ const result = await setupAlert(license, 100);
+ const newState = result[clusterUuid] as AlertLicensePerClusterState;
expect(newState.expiredCheckDateMS).toBe(0);
- expect(scheduleActions.mock.calls.length).toBe(1);
- expect(scheduleActions.mock.calls[0][1].subject).toBe(
- 'RESOLVED X-Pack Monitoring: License Expiration'
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ moment.utc(expiryDateMS),
+ dateFormat,
+ emailAddress,
+ true
);
- expect(scheduleActions.mock.calls[0][1].to).toBe(emailAddress);
});
it('should not fire actions for trial license that expire in more than 14 days', async () => {
- const scheduleActions = jest.fn();
- const alertInstanceFactory = jest.fn(
- (id: string): AlertInstance => {
- const instance = new AlertInstance();
- instance.scheduleActions = scheduleActions;
- return instance;
- }
- );
- const alert = getLicenseExpiration(
- getUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
-
- const savedObjectsClient = savedObjectsClientMock.create();
- savedObjectsClient.get.mockReturnValue(
- new Promise(resolve => {
- const savedObject: SavedObject = {
- id: '',
- type: '',
- references: [],
- attributes: {
- [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
- },
- };
- resolve(savedObject);
- })
- );
- const services = {
- callCluster: jest.fn(
- (method: string, { filterPath }): Promise => {
- return new Promise(resolve => {
- if (filterPath.includes('hits.hits._source.license.*')) {
- resolve(
- fillLicense(
- {
- status: 'active',
- type: 'trial',
- expiry_date_in_millis: moment()
- .add(15, 'days')
- .valueOf(),
- },
- clusterUuid
- )
- );
- }
- resolve({});
- });
- }
- ),
- alertInstanceFactory,
- savedObjectsClient,
+ const expiryDateMS = moment()
+ .add(20, 'days')
+ .valueOf();
+ const license = {
+ status: 'active',
+ type: 'trial',
+ expiryDateMS,
+ clusterUuid,
};
-
- const state = {};
- const result: AlertState = (await alert.executor({
- ...alertExecutorOptions,
- services,
- params,
- state,
- })) as AlertState;
-
- const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
- expect(newState.expiredCheckDateMS).toBe(undefined);
- expect(scheduleActions).not.toHaveBeenCalled();
+ const result = await setupAlert(license, 0);
+ const newState = result[clusterUuid] as AlertLicensePerClusterState;
+ expect(newState.expiredCheckDateMS).toBe(0);
+ expect(executeActions).not.toHaveBeenCalled();
});
it('should fire actions for trial license that in 14 days or less', async () => {
- const scheduleActions = jest.fn();
- const alertInstanceFactory = jest.fn(
- (id: string): AlertInstance => {
- const instance = new AlertInstance();
- instance.scheduleActions = scheduleActions;
- return instance;
- }
- );
- const alert = getLicenseExpiration(
- getUiSettingsService,
- monitoringCluster,
- getLogger,
- ccrEnabled
- );
-
- const savedObjectsClient = savedObjectsClientMock.create();
- savedObjectsClient.get.mockReturnValue(
- new Promise(resolve => {
- const savedObject: SavedObject = {
- id: '',
- type: '',
- references: [],
- attributes: {
- [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
- },
- };
- resolve(savedObject);
- })
- );
- const services = {
- callCluster: jest.fn(
- (method: string, { filterPath }): Promise => {
- return new Promise(resolve => {
- if (filterPath.includes('hits.hits._source.license.*')) {
- resolve(
- fillLicense(
- {
- status: 'active',
- type: 'trial',
- expiry_date_in_millis: moment()
- .add(13, 'days')
- .valueOf(),
- },
- clusterUuid
- )
- );
- }
- resolve({});
- });
- }
- ),
- alertInstanceFactory,
- savedObjectsClient,
+ const expiryDateMS = moment()
+ .add(7, 'days')
+ .valueOf();
+ const license = {
+ status: 'active',
+ type: 'trial',
+ expiryDateMS,
+ clusterUuid,
};
-
- const state = {};
- const result: AlertState = (await alert.executor({
- ...alertExecutorOptions,
- services,
- params,
- state,
- })) as AlertState;
-
- const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
+ const result = await setupAlert(license, 0);
+ const newState = result[clusterUuid] as AlertLicensePerClusterState;
expect(newState.expiredCheckDateMS > 0).toBe(true);
- expect(scheduleActions.mock.calls.length).toBe(1);
+ expect(executeActions).toHaveBeenCalledWith(
+ undefined,
+ cluster,
+ moment.utc(expiryDateMS),
+ dateFormat,
+ emailAddress
+ );
});
});
diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration.ts
index 93397ff3641a..2e5356150086 100644
--- a/x-pack/plugins/monitoring/server/alerts/license_expiration.ts
+++ b/x-pack/plugins/monitoring/server/alerts/license_expiration.ts
@@ -5,24 +5,20 @@
*/
import moment from 'moment-timezone';
-import { get } from 'lodash';
import { Logger, ICustomClusterClient, UiSettingsServiceStart } from 'src/core/server';
import { i18n } from '@kbn/i18n';
-import { ALERT_TYPE_LICENSE_EXPIRATION, INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants';
+import { ALERT_TYPE_LICENSE_EXPIRATION } from '../../common/constants';
import { AlertType } from '../../../../plugins/alerting/server';
import { fetchLicenses } from '../lib/alerts/fetch_licenses';
-import { fetchDefaultEmailAddress } from '../lib/alerts/fetch_default_email_address';
-import { fetchClusters } from '../lib/alerts/fetch_clusters';
-import { fetchAvailableCcs } from '../lib/alerts/fetch_available_ccs';
import {
- AlertLicense,
- AlertState,
- AlertClusterState,
- AlertClusterUiState,
- LicenseExpirationAlertExecutorOptions,
+ AlertCommonState,
+ AlertLicensePerClusterState,
+ AlertCommonExecutorOptions,
+ AlertCommonCluster,
+ AlertLicensePerClusterUiState,
} from './types';
-import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern';
import { executeActions, getUiMessage } from '../lib/alerts/license_expiration.lib';
+import { getPreparedAlert } from '../lib/alerts/get_prepared_alert';
const EXPIRES_DAYS = [60, 30, 14, 7];
@@ -32,14 +28,6 @@ export const getLicenseExpiration = (
getLogger: (...scopes: string[]) => Logger,
ccsEnabled: boolean
): AlertType => {
- async function getCallCluster(services: any): Promise {
- if (!monitoringCluster) {
- return services.callCluster;
- }
-
- return monitoringCluster.callAsInternalUser;
- }
-
const logger = getLogger(ALERT_TYPE_LICENSE_EXPIRATION);
return {
id: ALERT_TYPE_LICENSE_EXPIRATION,
@@ -53,54 +41,50 @@ export const getLicenseExpiration = (
},
],
defaultActionGroupId: 'default',
- async executor({
- services,
- params,
- state,
- }: LicenseExpirationAlertExecutorOptions): Promise {
+ async executor({ services, params, state }: AlertCommonExecutorOptions): Promise {
logger.debug(
`Firing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}`
);
- const callCluster = await getCallCluster(services);
-
- // Support CCS use cases by querying to find available remote clusters
- // and then adding those to the index pattern we are searching against
- let esIndexPattern = INDEX_PATTERN_ELASTICSEARCH;
- if (ccsEnabled) {
- const availableCcs = await fetchAvailableCcs(callCluster);
- if (availableCcs.length > 0) {
- esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs);
- }
- }
-
- const clusters = await fetchClusters(callCluster, esIndexPattern);
+ const preparedAlert = await getPreparedAlert(
+ ALERT_TYPE_LICENSE_EXPIRATION,
+ getUiSettingsService,
+ monitoringCluster,
+ logger,
+ ccsEnabled,
+ services,
+ fetchLicenses
+ );
- // Fetch licensing information from cluster_stats documents
- const licenses: AlertLicense[] = await fetchLicenses(callCluster, clusters, esIndexPattern);
- if (licenses.length === 0) {
- logger.warn(`No license found for ${ALERT_TYPE_LICENSE_EXPIRATION}.`);
+ if (!preparedAlert) {
return state;
}
- const uiSettings = (await getUiSettingsService()).asScopedToClient(
- services.savedObjectsClient
- );
- const dateFormat: string = await uiSettings.get('dateFormat');
- const timezone: string = await uiSettings.get('dateFormat:tz');
- const emailAddress = await fetchDefaultEmailAddress(uiSettings);
- if (!emailAddress) {
- // TODO: we can do more here
- logger.warn(
- `Unable to send email for ${ALERT_TYPE_LICENSE_EXPIRATION} because there is no email configured.`
- );
- return;
- }
+ const { emailAddress, data: licenses, clusters, dateFormat } = preparedAlert;
- const result: AlertState = { ...state };
+ const result: AlertCommonState = { ...state };
+ const defaultAlertState: AlertLicensePerClusterState = {
+ expiredCheckDateMS: 0,
+ ui: {
+ isFiring: false,
+ message: null,
+ severity: 0,
+ resolvedMS: 0,
+ lastCheckedMS: 0,
+ triggeredMS: 0,
+ },
+ };
for (const license of licenses) {
- const licenseState: AlertClusterState = state[license.clusterUuid] || {};
+ const alertState: AlertLicensePerClusterState =
+ (state[license.clusterUuid] as AlertLicensePerClusterState) || defaultAlertState;
+ const cluster = clusters.find(
+ (c: AlertCommonCluster) => c.clusterUuid === license.clusterUuid
+ );
+ if (!cluster) {
+ logger.warn(`Unable to find cluster for clusterUuid='${license.clusterUuid}'`);
+ continue;
+ }
const $expiry = moment.utc(license.expiryDateMS);
let isExpired = false;
let severity = 0;
@@ -123,31 +107,26 @@ export const getLicenseExpiration = (
}
}
- const ui: AlertClusterUiState = get(licenseState, 'ui', {
- isFiring: false,
- message: null,
- severity: 0,
- resolvedMS: 0,
- expirationTime: 0,
- });
+ const ui = alertState.ui;
+ let triggered = ui.triggeredMS;
let resolved = ui.resolvedMS;
let message = ui.message;
- let expiredCheckDate = licenseState.expiredCheckDateMS;
+ let expiredCheckDate = alertState.expiredCheckDateMS;
const instance = services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION);
if (isExpired) {
- if (!licenseState.expiredCheckDateMS) {
+ if (!alertState.expiredCheckDateMS) {
logger.debug(`License will expire soon, sending email`);
- executeActions(instance, license, $expiry, dateFormat, emailAddress);
- expiredCheckDate = moment().valueOf();
+ executeActions(instance, cluster, $expiry, dateFormat, emailAddress);
+ expiredCheckDate = triggered = moment().valueOf();
}
- message = getUiMessage(license, timezone);
+ message = getUiMessage();
resolved = 0;
- } else if (!isExpired && licenseState.expiredCheckDateMS) {
+ } else if (!isExpired && alertState.expiredCheckDateMS) {
logger.debug(`License expiration has been resolved, sending email`);
- executeActions(instance, license, $expiry, dateFormat, emailAddress, true);
+ executeActions(instance, cluster, $expiry, dateFormat, emailAddress, true);
expiredCheckDate = 0;
- message = getUiMessage(license, timezone, true);
+ message = getUiMessage(true);
resolved = moment().valueOf();
}
@@ -159,8 +138,10 @@ export const getLicenseExpiration = (
isFiring: expiredCheckDate > 0,
severity,
resolvedMS: resolved,
- },
- };
+ triggeredMS: triggered,
+ lastCheckedMS: moment().valueOf(),
+ } as AlertLicensePerClusterUiState,
+ } as AlertLicensePerClusterState;
}
return result;
diff --git a/x-pack/plugins/monitoring/server/alerts/types.d.ts b/x-pack/plugins/monitoring/server/alerts/types.d.ts
index ff47d6f2ad4d..b689d008b51a 100644
--- a/x-pack/plugins/monitoring/server/alerts/types.d.ts
+++ b/x-pack/plugins/monitoring/server/alerts/types.d.ts
@@ -5,41 +5,79 @@
*/
import { Moment } from 'moment';
import { AlertExecutorOptions } from '../../../alerting/server';
+import { AlertClusterStateState, AlertCommonPerClusterMessageTokenType } from './enums';
export interface AlertLicense {
status: string;
type: string;
expiryDateMS: number;
clusterUuid: string;
- clusterName: string;
}
-export interface AlertState {
- [clusterUuid: string]: AlertClusterState;
+export interface AlertClusterState {
+ state: AlertClusterStateState;
+ clusterUuid: string;
+}
+
+export interface AlertCommonState {
+ [clusterUuid: string]: AlertCommonPerClusterState;
}
-export interface AlertClusterState {
- expiredCheckDateMS: number | Moment;
- ui: AlertClusterUiState;
+export interface AlertCommonPerClusterState {
+ ui: AlertCommonPerClusterUiState;
}
-export interface AlertClusterUiState {
+export interface AlertClusterStatePerClusterState extends AlertCommonPerClusterState {
+ state: AlertClusterStateState;
+}
+
+export interface AlertLicensePerClusterState extends AlertCommonPerClusterState {
+ expiredCheckDateMS: number;
+}
+
+export interface AlertCommonPerClusterUiState {
isFiring: boolean;
severity: number;
- message: string | null;
+ message: AlertCommonPerClusterMessage | null;
resolvedMS: number;
+ lastCheckedMS: number;
+ triggeredMS: number;
+}
+
+export interface AlertCommonPerClusterMessage {
+ text: string; // Do this. #link this is a link #link
+ tokens?: AlertCommonPerClusterMessageToken[];
+}
+
+export interface AlertCommonPerClusterMessageToken {
+ startToken: string;
+ endToken?: string;
+ type: AlertCommonPerClusterMessageTokenType;
+}
+
+export interface AlertCommonPerClusterMessageLinkToken extends AlertCommonPerClusterMessageToken {
+ url?: string;
+}
+
+export interface AlertCommonPerClusterMessageTimeToken extends AlertCommonPerClusterMessageToken {
+ isRelative: boolean;
+ isAbsolute: boolean;
+}
+
+export interface AlertLicensePerClusterUiState extends AlertCommonPerClusterUiState {
expirationTime: number;
}
-export interface AlertCluster {
+export interface AlertCommonCluster {
clusterUuid: string;
+ clusterName: string;
}
-export interface LicenseExpirationAlertExecutorOptions extends AlertExecutorOptions {
- state: AlertState;
+export interface AlertCommonExecutorOptions extends AlertExecutorOptions {
+ state: AlertCommonState;
}
-export interface AlertParams {
+export interface AlertCommonParams {
dateFormat: string;
timezone: string;
}
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.test.ts
new file mode 100644
index 000000000000..81e375734cc5
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.test.ts
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { executeActions, getUiMessage } from './cluster_state.lib';
+import { AlertClusterStateState } from '../../alerts/enums';
+import { AlertCommonPerClusterMessageLinkToken } from '../../alerts/types';
+
+describe('clusterState lib', () => {
+ describe('executeActions', () => {
+ const clusterName = 'clusterA';
+ const instance: any = { scheduleActions: jest.fn() };
+ const license: any = { clusterName };
+ const status = AlertClusterStateState.Green;
+ const emailAddress = 'test@test.com';
+
+ beforeEach(() => {
+ instance.scheduleActions.mockClear();
+ });
+
+ it('should schedule actions when firing', () => {
+ executeActions(instance, license, status, emailAddress, false);
+ expect(instance.scheduleActions).toHaveBeenCalledWith('default', {
+ subject: 'NEW X-Pack Monitoring: Cluster Status',
+ message: `Allocate missing replica shards for cluster '${clusterName}'`,
+ to: emailAddress,
+ });
+ });
+
+ it('should have a different message for red state', () => {
+ executeActions(instance, license, AlertClusterStateState.Red, emailAddress, false);
+ expect(instance.scheduleActions).toHaveBeenCalledWith('default', {
+ subject: 'NEW X-Pack Monitoring: Cluster Status',
+ message: `Allocate missing primary and replica shards for cluster '${clusterName}'`,
+ to: emailAddress,
+ });
+ });
+
+ it('should schedule actions when resolved', () => {
+ executeActions(instance, license, status, emailAddress, true);
+ expect(instance.scheduleActions).toHaveBeenCalledWith('default', {
+ subject: 'RESOLVED X-Pack Monitoring: Cluster Status',
+ message: `This cluster alert has been resolved: Allocate missing replica shards for cluster '${clusterName}'`,
+ to: emailAddress,
+ });
+ });
+ });
+
+ describe('getUiMessage', () => {
+ it('should return a message when firing', () => {
+ const message = getUiMessage(AlertClusterStateState.Red, false);
+ expect(message.text).toBe(
+ `Elasticsearch cluster status is red. #start_linkAllocate missing primary and replica shards#end_link`
+ );
+ expect(message.tokens && message.tokens.length).toBe(1);
+ expect(message.tokens && message.tokens[0].startToken).toBe('#start_link');
+ expect(message.tokens && message.tokens[0].endToken).toBe('#end_link');
+ expect(
+ message.tokens && (message.tokens[0] as AlertCommonPerClusterMessageLinkToken).url
+ ).toBe('elasticsearch/indices');
+ });
+
+ it('should return a message when resolved', () => {
+ const message = getUiMessage(AlertClusterStateState.Green, true);
+ expect(message.text).toBe(`Elasticsearch cluster status is green.`);
+ expect(message.tokens).not.toBeDefined();
+ });
+ });
+});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.ts b/x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.ts
new file mode 100644
index 000000000000..ae66d603507c
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/cluster_state.lib.ts
@@ -0,0 +1,88 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { i18n } from '@kbn/i18n';
+import { AlertInstance } from '../../../../alerting/server';
+import {
+ AlertCommonCluster,
+ AlertCommonPerClusterMessage,
+ AlertCommonPerClusterMessageLinkToken,
+} from '../../alerts/types';
+import { AlertClusterStateState, AlertCommonPerClusterMessageTokenType } from '../../alerts/enums';
+
+const RESOLVED_SUBJECT = i18n.translate('xpack.monitoring.alerts.clusterStatus.resolvedSubject', {
+ defaultMessage: 'RESOLVED X-Pack Monitoring: Cluster Status',
+});
+
+const NEW_SUBJECT = i18n.translate('xpack.monitoring.alerts.clusterStatus.newSubject', {
+ defaultMessage: 'NEW X-Pack Monitoring: Cluster Status',
+});
+
+const RED_STATUS_MESSAGE = i18n.translate('xpack.monitoring.alerts.clusterStatus.redMessage', {
+ defaultMessage: 'Allocate missing primary and replica shards',
+});
+
+const YELLOW_STATUS_MESSAGE = i18n.translate(
+ 'xpack.monitoring.alerts.clusterStatus.yellowMessage',
+ {
+ defaultMessage: 'Allocate missing replica shards',
+ }
+);
+
+export function executeActions(
+ instance: AlertInstance,
+ cluster: AlertCommonCluster,
+ status: AlertClusterStateState,
+ emailAddress: string,
+ resolved: boolean = false
+) {
+ const message =
+ status === AlertClusterStateState.Red ? RED_STATUS_MESSAGE : YELLOW_STATUS_MESSAGE;
+ if (resolved) {
+ instance.scheduleActions('default', {
+ subject: RESOLVED_SUBJECT,
+ message: `This cluster alert has been resolved: ${message} for cluster '${cluster.clusterName}'`,
+ to: emailAddress,
+ });
+ } else {
+ instance.scheduleActions('default', {
+ subject: NEW_SUBJECT,
+ message: `${message} for cluster '${cluster.clusterName}'`,
+ to: emailAddress,
+ });
+ }
+}
+
+export function getUiMessage(
+ status: AlertClusterStateState,
+ resolved: boolean = false
+): AlertCommonPerClusterMessage {
+ if (resolved) {
+ return {
+ text: i18n.translate('xpack.monitoring.alerts.clusterStatus.ui.resolvedMessage', {
+ defaultMessage: `Elasticsearch cluster status is green.`,
+ }),
+ };
+ }
+ const message =
+ status === AlertClusterStateState.Red ? RED_STATUS_MESSAGE : YELLOW_STATUS_MESSAGE;
+ return {
+ text: i18n.translate('xpack.monitoring.alerts.clusterStatus.ui.firingMessage', {
+ defaultMessage: `Elasticsearch cluster status is {status}. #start_link{message}#end_link`,
+ values: {
+ status,
+ message,
+ },
+ }),
+ tokens: [
+ {
+ startToken: '#start_link',
+ endToken: '#end_link',
+ type: AlertCommonPerClusterMessageTokenType.Link,
+ url: 'elasticsearch/indices',
+ } as AlertCommonPerClusterMessageLinkToken,
+ ],
+ };
+}
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.test.ts
new file mode 100644
index 000000000000..642ae3c39a02
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.test.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { fetchClusterState } from './fetch_cluster_state';
+
+describe('fetchClusterState', () => {
+ it('should return the cluster state', async () => {
+ const status = 'green';
+ const clusterUuid = 'sdfdsaj34434';
+ const callCluster = jest.fn(() => ({
+ hits: {
+ hits: [
+ {
+ _source: {
+ cluster_state: {
+ status,
+ },
+ cluster_uuid: clusterUuid,
+ },
+ },
+ ],
+ },
+ }));
+
+ const clusters = [{ clusterUuid, clusterName: 'foo' }];
+ const index = '.monitoring-es-*';
+
+ const state = await fetchClusterState(callCluster, clusters, index);
+ expect(state).toEqual([
+ {
+ state: status,
+ clusterUuid,
+ },
+ ]);
+ });
+});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.ts
new file mode 100644
index 000000000000..66ea30d5f2e9
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_state.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { get } from 'lodash';
+import { AlertCommonCluster, AlertClusterState } from '../../alerts/types';
+
+export async function fetchClusterState(
+ callCluster: any,
+ clusters: AlertCommonCluster[],
+ index: string
+): Promise {
+ const params = {
+ index,
+ filterPath: ['hits.hits._source.cluster_state.status', 'hits.hits._source.cluster_uuid'],
+ body: {
+ size: 1,
+ sort: [{ timestamp: { order: 'desc' } }],
+ query: {
+ bool: {
+ filter: [
+ {
+ terms: {
+ cluster_uuid: clusters.map(cluster => cluster.clusterUuid),
+ },
+ },
+ {
+ term: {
+ type: 'cluster_stats',
+ },
+ },
+ {
+ range: {
+ timestamp: {
+ gte: 'now-2m',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const response = await callCluster('search', params);
+ return get(response, 'hits.hits', []).map((hit: any) => {
+ return {
+ state: get(hit, '_source.cluster_state.status'),
+ clusterUuid: get(hit, '_source.cluster_uuid'),
+ };
+ });
+}
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts
index 78eb9773df15..7a9b61f37707 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts
@@ -6,21 +6,51 @@
import { fetchClusters } from './fetch_clusters';
describe('fetchClusters', () => {
+ const clusterUuid = '1sdfds734';
+ const clusterName = 'monitoring';
+
it('return a list of clusters', async () => {
const callCluster = jest.fn().mockImplementation(() => ({
- aggregations: {
- clusters: {
- buckets: [
- {
- key: 'clusterA',
+ hits: {
+ hits: [
+ {
+ _source: {
+ cluster_uuid: clusterUuid,
+ cluster_name: clusterName,
+ },
+ },
+ ],
+ },
+ }));
+ const index = '.monitoring-es-*';
+ const result = await fetchClusters(callCluster, index);
+ expect(result).toEqual([{ clusterUuid, clusterName }]);
+ });
+
+ it('return the metadata name if available', async () => {
+ const metadataName = 'custom-monitoring';
+ const callCluster = jest.fn().mockImplementation(() => ({
+ hits: {
+ hits: [
+ {
+ _source: {
+ cluster_uuid: clusterUuid,
+ cluster_name: clusterName,
+ cluster_settings: {
+ cluster: {
+ metadata: {
+ display_name: metadataName,
+ },
+ },
+ },
},
- ],
- },
+ },
+ ],
},
}));
const index = '.monitoring-es-*';
const result = await fetchClusters(callCluster, index);
- expect(result).toEqual([{ clusterUuid: 'clusterA' }]);
+ expect(result).toEqual([{ clusterUuid, clusterName: metadataName }]);
});
it('should limit the time period in the query', async () => {
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts
index 8ef7339618a2..d1513ac16fb1 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts
@@ -4,18 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
-import { AlertCluster } from '../../alerts/types';
+import { AlertCommonCluster } from '../../alerts/types';
-interface AggregationResult {
- key: string;
-}
-
-export async function fetchClusters(callCluster: any, index: string): Promise {
+export async function fetchClusters(
+ callCluster: any,
+ index: string
+): Promise {
const params = {
index,
- filterPath: 'aggregations.clusters.buckets',
+ filterPath: [
+ 'hits.hits._source.cluster_settings.cluster.metadata.display_name',
+ 'hits.hits._source.cluster_uuid',
+ 'hits.hits._source.cluster_name',
+ ],
body: {
- size: 0,
+ size: 1000,
query: {
bool: {
filter: [
@@ -34,19 +37,21 @@ export async function fetchClusters(callCluster: any, index: string): Promise ({
- clusterUuid: bucket.key,
- }));
+ return get(response, 'hits.hits', []).map((hit: any) => {
+ const clusterName: string =
+ get(hit, '_source.cluster_settings.cluster.metadata.display_name') ||
+ get(hit, '_source.cluster_name') ||
+ get(hit, '_source.cluster_uuid');
+ return {
+ clusterUuid: get(hit, '_source.cluster_uuid'),
+ clusterName,
+ };
+ });
}
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts
index dd6c074e68b1..9dcb4ffb82a5 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts
@@ -6,28 +6,28 @@
import { fetchLicenses } from './fetch_licenses';
describe('fetchLicenses', () => {
+ const clusterName = 'MyCluster';
+ const clusterUuid = 'clusterA';
+ const license = {
+ status: 'active',
+ expiry_date_in_millis: 1579532493876,
+ type: 'basic',
+ };
+
it('return a list of licenses', async () => {
- const clusterName = 'MyCluster';
- const clusterUuid = 'clusterA';
- const license = {
- status: 'active',
- expiry_date_in_millis: 1579532493876,
- type: 'basic',
- };
const callCluster = jest.fn().mockImplementation(() => ({
hits: {
hits: [
{
_source: {
license,
- cluster_name: clusterName,
cluster_uuid: clusterUuid,
},
},
],
},
}));
- const clusters = [{ clusterUuid }];
+ const clusters = [{ clusterUuid, clusterName }];
const index = '.monitoring-es-*';
const result = await fetchLicenses(callCluster, clusters, index);
expect(result).toEqual([
@@ -36,15 +36,13 @@ describe('fetchLicenses', () => {
type: license.type,
expiryDateMS: license.expiry_date_in_millis,
clusterUuid,
- clusterName,
},
]);
});
it('should only search for the clusters provided', async () => {
- const clusterUuid = 'clusterA';
const callCluster = jest.fn();
- const clusters = [{ clusterUuid }];
+ const clusters = [{ clusterUuid, clusterName }];
const index = '.monitoring-es-*';
await fetchLicenses(callCluster, clusters, index);
const params = callCluster.mock.calls[0][1];
@@ -52,54 +50,11 @@ describe('fetchLicenses', () => {
});
it('should limit the time period in the query', async () => {
- const clusterUuid = 'clusterA';
const callCluster = jest.fn();
- const clusters = [{ clusterUuid }];
+ const clusters = [{ clusterUuid, clusterName }];
const index = '.monitoring-es-*';
await fetchLicenses(callCluster, clusters, index);
const params = callCluster.mock.calls[0][1];
expect(params.body.query.bool.filter[2].range.timestamp.gte).toBe('now-2m');
});
-
- it('should give priority to the metadata name', async () => {
- const clusterName = 'MyCluster';
- const clusterUuid = 'clusterA';
- const license = {
- status: 'active',
- expiry_date_in_millis: 1579532493876,
- type: 'basic',
- };
- const callCluster = jest.fn().mockImplementation(() => ({
- hits: {
- hits: [
- {
- _source: {
- license,
- cluster_name: 'fakeName',
- cluster_uuid: clusterUuid,
- cluster_settings: {
- cluster: {
- metadata: {
- display_name: clusterName,
- },
- },
- },
- },
- },
- ],
- },
- }));
- const clusters = [{ clusterUuid }];
- const index = '.monitoring-es-*';
- const result = await fetchLicenses(callCluster, clusters, index);
- expect(result).toEqual([
- {
- status: license.status,
- type: license.type,
- expiryDateMS: license.expiry_date_in_millis,
- clusterUuid,
- clusterName,
- },
- ]);
- });
});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts
index 31a68e8aa9c3..5b05c907e796 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts
@@ -4,21 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
-import { AlertLicense, AlertCluster } from '../../alerts/types';
+import { AlertLicense, AlertCommonCluster } from '../../alerts/types';
export async function fetchLicenses(
callCluster: any,
- clusters: AlertCluster[],
+ clusters: AlertCommonCluster[],
index: string
): Promise {
const params = {
index,
- filterPath: [
- 'hits.hits._source.license.*',
- 'hits.hits._source.cluster_settings.cluster.metadata.display_name',
- 'hits.hits._source.cluster_uuid',
- 'hits.hits._source.cluster_name',
- ],
+ filterPath: ['hits.hits._source.license.*', 'hits.hits._source.cluster_uuid'],
body: {
size: 1,
sort: [{ timestamp: { order: 'desc' } }],
@@ -50,17 +45,12 @@ export async function fetchLicenses(
const response = await callCluster('search', params);
return get(response, 'hits.hits', []).map((hit: any) => {
- const clusterName: string =
- get(hit, '_source.cluster_settings.cluster.metadata.display_name') ||
- get(hit, '_source.cluster_name') ||
- get(hit, '_source.cluster_uuid');
const rawLicense: any = get(hit, '_source.license', {});
const license: AlertLicense = {
status: rawLicense.status,
type: rawLicense.type,
expiryDateMS: rawLicense.expiry_date_in_millis,
clusterUuid: get(hit, '_source.cluster_uuid'),
- clusterName,
};
return license;
});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.test.ts
new file mode 100644
index 000000000000..a3bcb61afacd
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.test.ts
@@ -0,0 +1,122 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { fetchStatus } from './fetch_status';
+import { AlertCommonPerClusterState } from '../../alerts/types';
+
+describe('fetchStatus', () => {
+ const alertType = 'monitoringTest';
+ const log = { warn: jest.fn() };
+ const start = 0;
+ const end = 0;
+ const id = 1;
+ const defaultUiState = {
+ isFiring: false,
+ severity: 0,
+ message: null,
+ resolvedMS: 0,
+ lastCheckedMS: 0,
+ triggeredMS: 0,
+ };
+ const alertsClient = {
+ find: jest.fn(() => ({
+ total: 1,
+ data: [
+ {
+ id,
+ },
+ ],
+ })),
+ getAlertState: jest.fn(() => ({
+ alertTypeState: {
+ state: {
+ ui: defaultUiState,
+ } as AlertCommonPerClusterState,
+ },
+ })),
+ };
+
+ afterEach(() => {
+ (alertsClient.find as jest.Mock).mockClear();
+ (alertsClient.getAlertState as jest.Mock).mockClear();
+ });
+
+ it('should fetch from the alerts client', async () => {
+ const status = await fetchStatus(alertsClient as any, [alertType], start, end, log as any);
+ expect(status).toEqual([]);
+ });
+
+ it('should return alerts that are firing', async () => {
+ alertsClient.getAlertState = jest.fn(() => ({
+ alertTypeState: {
+ state: {
+ ui: {
+ ...defaultUiState,
+ isFiring: true,
+ },
+ } as AlertCommonPerClusterState,
+ },
+ }));
+
+ const status = await fetchStatus(alertsClient as any, [alertType], start, end, log as any);
+ expect(status.length).toBe(1);
+ expect(status[0].type).toBe(alertType);
+ expect(status[0].isFiring).toBe(true);
+ });
+
+ it('should return alerts that have been resolved in the time period', async () => {
+ alertsClient.getAlertState = jest.fn(() => ({
+ alertTypeState: {
+ state: {
+ ui: {
+ ...defaultUiState,
+ resolvedMS: 1500,
+ },
+ } as AlertCommonPerClusterState,
+ },
+ }));
+
+ const customStart = 1000;
+ const customEnd = 2000;
+
+ const status = await fetchStatus(
+ alertsClient as any,
+ [alertType],
+ customStart,
+ customEnd,
+ log as any
+ );
+ expect(status.length).toBe(1);
+ expect(status[0].type).toBe(alertType);
+ expect(status[0].isFiring).toBe(false);
+ });
+
+ it('should pass in the right filter to the alerts client', async () => {
+ await fetchStatus(alertsClient as any, [alertType], start, end, log as any);
+ expect((alertsClient.find as jest.Mock).mock.calls[0][0].options.filter).toBe(
+ `alert.attributes.alertTypeId:${alertType}`
+ );
+ });
+
+ it('should return nothing if no alert state is found', async () => {
+ alertsClient.getAlertState = jest.fn(() => ({
+ alertTypeState: null,
+ })) as any;
+
+ const status = await fetchStatus(alertsClient as any, [alertType], start, end, log as any);
+ expect(status).toEqual([]);
+ });
+
+ it('should return nothing if no alerts are found', async () => {
+ alertsClient.find = jest.fn(() => ({
+ total: 0,
+ data: [],
+ })) as any;
+
+ const status = await fetchStatus(alertsClient as any, [alertType], start, end, log as any);
+ expect(status).toEqual([]);
+ });
+});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.ts
index 9f7c1d5a994d..bf6ee965d3b2 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_status.ts
@@ -4,81 +4,53 @@
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment';
-import { get } from 'lodash';
-import { AlertClusterState } from '../../alerts/types';
-import { ALERT_TYPES, LOGGING_TAG } from '../../../common/constants';
+import { Logger } from '../../../../../../src/core/server';
+import { AlertCommonPerClusterState } from '../../alerts/types';
+import { AlertsClient } from '../../../../alerting/server';
export async function fetchStatus(
- callCluster: any,
+ alertsClient: AlertsClient,
+ alertTypes: string[],
start: number,
end: number,
- clusterUuid: string,
- server: any
+ log: Logger
): Promise {
- // TODO: this shouldn't query task manager directly but rather
- // use an api exposed by the alerting/actions plugin
- // See https://github.com/elastic/kibana/issues/48442
const statuses = await Promise.all(
- ALERT_TYPES.map(
+ alertTypes.map(
type =>
new Promise(async (resolve, reject) => {
- try {
- const params = {
- index: '.kibana_task_manager',
- filterPath: ['hits.hits._source.task.state'],
- body: {
- size: 1,
- sort: [{ updated_at: { order: 'desc' } }],
- query: {
- bool: {
- filter: [
- {
- term: {
- 'task.taskType': `alerting:${type}`,
- },
- },
- ],
- },
- },
- },
- };
-
- const response = await callCluster('search', params);
- const state = get(response, 'hits.hits[0]._source.task.state', '{}');
- const clusterState: AlertClusterState = get(
- JSON.parse(state),
- `alertTypeState.${clusterUuid}`,
- {
- expiredCheckDateMS: 0,
- ui: {
- isFiring: false,
- message: null,
- severity: 0,
- resolvedMS: 0,
- expirationTime: 0,
- },
- }
- );
- const isInBetween = moment(clusterState.ui.resolvedMS).isBetween(start, end);
- if (clusterState.ui.isFiring || isInBetween) {
- return resolve({
- type,
- ...clusterState.ui,
- });
- }
+ // We need to get the id from the alertTypeId
+ const alerts = await alertsClient.find({
+ options: {
+ filter: `alert.attributes.alertTypeId:${type}`,
+ },
+ });
+ if (alerts.total === 0) {
return resolve(false);
- } catch (err) {
- const reason = get(err, 'body.error.type');
- if (reason === 'index_not_found_exception') {
- server.log(
- ['error', LOGGING_TAG],
- `Unable to fetch alerts. Alerts depends on task manager, which has not been started yet.`
- );
- } else {
- server.log(['error', LOGGING_TAG], err.message);
- }
+ }
+
+ if (alerts.total !== 1) {
+ log.warn(`Found more than one alert for type ${type} which is unexpected.`);
+ }
+
+ const id = alerts.data[0].id;
+
+ // Now that we have the id, we can get the state
+ const states = await alertsClient.getAlertState({ id });
+ if (!states || !states.alertTypeState) {
+ log.warn(`No alert states found for type ${type} which is unexpected.`);
return resolve(false);
}
+
+ const state = Object.values(states.alertTypeState)[0] as AlertCommonPerClusterState;
+ const isInBetween = moment(state.ui.resolvedMS).isBetween(start, end);
+ if (state.ui.isFiring || isInBetween) {
+ return resolve({
+ type,
+ ...state.ui,
+ });
+ }
+ return resolve(false);
})
)
);
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.test.ts
new file mode 100644
index 000000000000..1840a2026a75
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.test.ts
@@ -0,0 +1,163 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getPreparedAlert } from './get_prepared_alert';
+import { fetchClusters } from './fetch_clusters';
+import { fetchDefaultEmailAddress } from './fetch_default_email_address';
+
+jest.mock('./fetch_clusters', () => ({
+ fetchClusters: jest.fn(),
+}));
+
+jest.mock('./fetch_default_email_address', () => ({
+ fetchDefaultEmailAddress: jest.fn(),
+}));
+
+describe('getPreparedAlert', () => {
+ const uiSettings = { get: jest.fn() };
+ const alertType = 'test';
+ const getUiSettingsService = async () => ({
+ asScopedToClient: () => uiSettings,
+ });
+ const monitoringCluster = null;
+ const logger = { warn: jest.fn() };
+ const ccsEnabled = false;
+ const services = {
+ callCluster: jest.fn(),
+ savedObjectsClient: null,
+ };
+ const emailAddress = 'foo@foo.com';
+ const data = [{ foo: 1 }];
+ const dataFetcher = () => data;
+ const clusterName = 'MonitoringCluster';
+ const clusterUuid = 'sdf34sdf';
+ const clusters = [{ clusterName, clusterUuid }];
+
+ afterEach(() => {
+ (uiSettings.get as jest.Mock).mockClear();
+ (services.callCluster as jest.Mock).mockClear();
+ (fetchClusters as jest.Mock).mockClear();
+ (fetchDefaultEmailAddress as jest.Mock).mockClear();
+ });
+
+ beforeEach(() => {
+ (fetchClusters as jest.Mock).mockImplementation(() => clusters);
+ (fetchDefaultEmailAddress as jest.Mock).mockImplementation(() => emailAddress);
+ });
+
+ it('should return fields as expected', async () => {
+ (uiSettings.get as jest.Mock).mockImplementation(() => {
+ return emailAddress;
+ });
+
+ const alert = await getPreparedAlert(
+ alertType,
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ logger as any,
+ ccsEnabled,
+ services as any,
+ dataFetcher as any
+ );
+
+ expect(alert && alert.emailAddress).toBe(emailAddress);
+ expect(alert && alert.data).toBe(data);
+ });
+
+ it('should add ccs if specified', async () => {
+ const ccsClusterName = 'remoteCluster';
+ (services.callCluster as jest.Mock).mockImplementation(() => {
+ return {
+ [ccsClusterName]: {
+ connected: true,
+ },
+ };
+ });
+
+ await getPreparedAlert(
+ alertType,
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ logger as any,
+ true,
+ services as any,
+ dataFetcher as any
+ );
+
+ expect((fetchClusters as jest.Mock).mock.calls[0][1].includes(ccsClusterName)).toBe(true);
+ });
+
+ it('should ignore ccs if no remote clusters are available', async () => {
+ const ccsClusterName = 'remoteCluster';
+ (services.callCluster as jest.Mock).mockImplementation(() => {
+ return {
+ [ccsClusterName]: {
+ connected: false,
+ },
+ };
+ });
+
+ await getPreparedAlert(
+ alertType,
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ logger as any,
+ true,
+ services as any,
+ dataFetcher as any
+ );
+
+ expect((fetchClusters as jest.Mock).mock.calls[0][1].includes(ccsClusterName)).toBe(false);
+ });
+
+ it('should pass in the clusters into the data fetcher', async () => {
+ const customDataFetcher = jest.fn(() => data);
+
+ await getPreparedAlert(
+ alertType,
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ logger as any,
+ true,
+ services as any,
+ customDataFetcher as any
+ );
+
+ expect((customDataFetcher as jest.Mock).mock.calls[0][1]).toBe(clusters);
+ });
+
+ it('should return nothing if the data fetcher returns nothing', async () => {
+ const customDataFetcher = jest.fn(() => []);
+
+ const result = await getPreparedAlert(
+ alertType,
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ logger as any,
+ true,
+ services as any,
+ customDataFetcher as any
+ );
+
+ expect(result).toBe(null);
+ });
+
+ it('should return nothing if there is no email address', async () => {
+ (fetchDefaultEmailAddress as jest.Mock).mockImplementation(() => null);
+
+ const result = await getPreparedAlert(
+ alertType,
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ logger as any,
+ true,
+ services as any,
+ dataFetcher as any
+ );
+
+ expect(result).toBe(null);
+ });
+});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.ts b/x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.ts
new file mode 100644
index 000000000000..83a9e26e4c58
--- /dev/null
+++ b/x-pack/plugins/monitoring/server/lib/alerts/get_prepared_alert.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Logger, ICustomClusterClient, UiSettingsServiceStart } from 'kibana/server';
+import { CallCluster } from 'src/legacy/core_plugins/elasticsearch';
+import { AlertServices } from '../../../../alerting/server';
+import { AlertCommonCluster } from '../../alerts/types';
+import { INDEX_PATTERN_ELASTICSEARCH } from '../../../common/constants';
+import { fetchAvailableCcs } from './fetch_available_ccs';
+import { getCcsIndexPattern } from './get_ccs_index_pattern';
+import { fetchClusters } from './fetch_clusters';
+import { fetchDefaultEmailAddress } from './fetch_default_email_address';
+
+export interface PreparedAlert {
+ emailAddress: string;
+ clusters: AlertCommonCluster[];
+ data: any[];
+ timezone: string;
+ dateFormat: string;
+}
+
+async function getCallCluster(
+ monitoringCluster: ICustomClusterClient,
+ services: Pick
+): Promise {
+ if (!monitoringCluster) {
+ return services.callCluster;
+ }
+
+ return monitoringCluster.callAsInternalUser;
+}
+
+export async function getPreparedAlert(
+ alertType: string,
+ getUiSettingsService: () => Promise,
+ monitoringCluster: ICustomClusterClient,
+ logger: Logger,
+ ccsEnabled: boolean,
+ services: Pick,
+ dataFetcher: (
+ callCluster: CallCluster,
+ clusters: AlertCommonCluster[],
+ esIndexPattern: string
+ ) => Promise
+): Promise {
+ const callCluster = await getCallCluster(monitoringCluster, services);
+
+ // Support CCS use cases by querying to find available remote clusters
+ // and then adding those to the index pattern we are searching against
+ let esIndexPattern = INDEX_PATTERN_ELASTICSEARCH;
+ if (ccsEnabled) {
+ const availableCcs = await fetchAvailableCcs(callCluster);
+ if (availableCcs.length > 0) {
+ esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs);
+ }
+ }
+
+ const clusters = await fetchClusters(callCluster, esIndexPattern);
+
+ // Fetch the specific data
+ const data = await dataFetcher(callCluster, clusters, esIndexPattern);
+ if (data.length === 0) {
+ logger.warn(`No data found for ${alertType}.`);
+ return null;
+ }
+
+ const uiSettings = (await getUiSettingsService()).asScopedToClient(services.savedObjectsClient);
+ const dateFormat: string = await uiSettings.get('dateFormat');
+ const timezone: string = await uiSettings.get('dateFormat:tz');
+ const emailAddress = await fetchDefaultEmailAddress(uiSettings);
+ if (!emailAddress) {
+ // TODO: we can do more here
+ logger.warn(`Unable to send email for ${alertType} because there is no email configured.`);
+ return null;
+ }
+
+ return {
+ emailAddress,
+ data,
+ clusters,
+ dateFormat,
+ timezone,
+ };
+}
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts
index 1a2eb1e44be8..6c0301b6cc34 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts
@@ -39,17 +39,26 @@ describe('licenseExpiration lib', () => {
});
describe('getUiMessage', () => {
- const timezone = 'Europe/London';
- const license: any = { expiryDateMS: moment.tz('2020-01-20 08:00:00', timezone).utc() };
-
it('should return a message when firing', () => {
- const message = getUiMessage(license, timezone, false);
- expect(message).toBe(`This cluster's license is going to expire in #relative at #absolute.`);
+ const message = getUiMessage(false);
+ expect(message.text).toBe(
+ `This cluster's license is going to expire in #relative at #absolute. #start_linkPlease update your license#end_link`
+ );
+ // LOL How do I avoid this in TS????
+ if (!message.tokens) {
+ return expect(false).toBe(true);
+ }
+ expect(message.tokens.length).toBe(3);
+ expect(message.tokens[0].startToken).toBe('#relative');
+ expect(message.tokens[1].startToken).toBe('#absolute');
+ expect(message.tokens[2].startToken).toBe('#start_link');
+ expect(message.tokens[2].endToken).toBe('#end_link');
});
it('should return a message when resolved', () => {
- const message = getUiMessage(license, timezone, true);
- expect(message).toBe(`This cluster's license is active.`);
+ const message = getUiMessage(true);
+ expect(message.text).toBe(`This cluster's license is active.`);
+ expect(message.tokens).not.toBeDefined();
});
});
});
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts b/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts
index 41b68d69bbd2..a590021a2f29 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts
@@ -6,7 +6,13 @@
import { Moment } from 'moment-timezone';
import { i18n } from '@kbn/i18n';
import { AlertInstance } from '../../../../alerting/server';
-import { AlertLicense } from '../../alerts/types';
+import {
+ AlertCommonPerClusterMessageLinkToken,
+ AlertCommonPerClusterMessageTimeToken,
+ AlertCommonCluster,
+ AlertCommonPerClusterMessage,
+} from '../../alerts/types';
+import { AlertCommonPerClusterMessageTokenType } from '../../alerts/enums';
const RESOLVED_SUBJECT = i18n.translate(
'xpack.monitoring.alerts.licenseExpiration.resolvedSubject',
@@ -21,7 +27,7 @@ const NEW_SUBJECT = i18n.translate('xpack.monitoring.alerts.licenseExpiration.ne
export function executeActions(
instance: AlertInstance,
- license: AlertLicense,
+ cluster: AlertCommonCluster,
$expiry: Moment,
dateFormat: string,
emailAddress: string,
@@ -31,14 +37,14 @@ export function executeActions(
instance.scheduleActions('default', {
subject: RESOLVED_SUBJECT,
message: `This cluster alert has been resolved: Cluster '${
- license.clusterName
+ cluster.clusterName
}' license was going to expire on ${$expiry.format(dateFormat)}.`,
to: emailAddress,
});
} else {
instance.scheduleActions('default', {
subject: NEW_SUBJECT,
- message: `Cluster '${license.clusterName}' license is going to expire on ${$expiry.format(
+ message: `Cluster '${cluster.clusterName}' license is going to expire on ${$expiry.format(
dateFormat
)}. Please update your license.`,
to: emailAddress,
@@ -46,13 +52,43 @@ export function executeActions(
}
}
-export function getUiMessage(license: AlertLicense, timezone: string, resolved: boolean = false) {
+export function getUiMessage(resolved: boolean = false): AlertCommonPerClusterMessage {
if (resolved) {
- return i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.resolvedMessage', {
- defaultMessage: `This cluster's license is active.`,
- });
+ return {
+ text: i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.resolvedMessage', {
+ defaultMessage: `This cluster's license is active.`,
+ }),
+ };
}
- return i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.firingMessage', {
- defaultMessage: `This cluster's license is going to expire in #relative at #absolute.`,
+ const linkText = i18n.translate('xpack.monitoring.alerts.licenseExpiration.linkText', {
+ defaultMessage: 'Please update your license',
});
+ return {
+ text: i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.firingMessage', {
+ defaultMessage: `This cluster's license is going to expire in #relative at #absolute. #start_link{linkText}#end_link`,
+ values: {
+ linkText,
+ },
+ }),
+ tokens: [
+ {
+ startToken: '#relative',
+ type: AlertCommonPerClusterMessageTokenType.Time,
+ isRelative: true,
+ isAbsolute: false,
+ } as AlertCommonPerClusterMessageTimeToken,
+ {
+ startToken: '#absolute',
+ type: AlertCommonPerClusterMessageTokenType.Time,
+ isAbsolute: true,
+ isRelative: false,
+ } as AlertCommonPerClusterMessageTimeToken,
+ {
+ startToken: '#start_link',
+ endToken: '#end_link',
+ type: AlertCommonPerClusterMessageTokenType.Link,
+ url: 'license',
+ } as AlertCommonPerClusterMessageLinkToken,
+ ],
+ };
}
diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js
index c5091c36c3bb..1bddede52207 100644
--- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js
+++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js
@@ -29,6 +29,7 @@ import {
CODE_PATH_BEATS,
CODE_PATH_APM,
KIBANA_ALERTING_ENABLED,
+ ALERT_TYPES,
} from '../../../common/constants';
import { getApmsForClusters } from '../apm/get_apms_for_clusters';
import { i18n } from '@kbn/i18n';
@@ -102,15 +103,8 @@ export async function getClustersFromRequest(
if (isInCodePath(codePaths, [CODE_PATH_ALERTS])) {
if (KIBANA_ALERTING_ENABLED) {
- const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring');
- const callCluster = (...args) => callWithRequest(req, ...args);
- cluster.alerts = await fetchStatus(
- callCluster,
- start,
- end,
- cluster.cluster_uuid,
- req.server
- );
+ const alertsClient = req.getAlertsClient ? req.getAlertsClient() : null;
+ cluster.alerts = await fetchStatus(alertsClient, ALERT_TYPES, start, end, req.logger);
} else {
cluster.alerts = await alertsClusterSearch(
req,
diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts
index 24d8bcaa4397..784226dca66f 100644
--- a/x-pack/plugins/monitoring/server/plugin.ts
+++ b/x-pack/plugins/monitoring/server/plugin.ts
@@ -47,6 +47,7 @@ import {
PluginSetupContract as AlertingPluginSetupContract,
} from '../../alerting/server';
import { getLicenseExpiration } from './alerts/license_expiration';
+import { getClusterState } from './alerts/cluster_state';
import { InfraPluginSetup } from '../../infra/server';
export interface LegacyAPI {
@@ -154,6 +155,17 @@ export class Plugin {
config.ui.ccs.enabled
)
);
+ plugins.alerting.registerType(
+ getClusterState(
+ async () => {
+ const coreStart = (await core.getStartServices())[0];
+ return coreStart.uiSettings;
+ },
+ cluster,
+ this.getLogger,
+ config.ui.ccs.enabled
+ )
+ );
}
// Initialize telemetry
diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/alerts.js b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/alerts.js
index 56922bd8e87e..d5a43d32f600 100644
--- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/alerts.js
+++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/alerts.js
@@ -8,8 +8,12 @@ import { schema } from '@kbn/config-schema';
import { isFunction } from 'lodash';
import {
ALERT_TYPE_LICENSE_EXPIRATION,
+ ALERT_TYPE_CLUSTER_STATE,
MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS,
+ ALERT_TYPES,
} from '../../../../../common/constants';
+import { handleError } from '../../../../lib/errors';
+import { fetchStatus } from '../../../../lib/alerts/fetch_status';
async function createAlerts(req, alertsClient, { selectedEmailActionId }) {
const createdAlerts = [];
@@ -17,7 +21,21 @@ async function createAlerts(req, alertsClient, { selectedEmailActionId }) {
// Create alerts
const ALERT_TYPES = {
[ALERT_TYPE_LICENSE_EXPIRATION]: {
- schedule: { interval: '10s' },
+ schedule: { interval: '1m' },
+ actions: [
+ {
+ group: 'default',
+ id: selectedEmailActionId,
+ params: {
+ subject: '{{context.subject}}',
+ message: `{{context.message}}`,
+ to: ['{{context.to}}'],
+ },
+ },
+ ],
+ },
+ [ALERT_TYPE_CLUSTER_STATE]: {
+ schedule: { interval: '1m' },
actions: [
{
group: 'default',
@@ -86,4 +104,37 @@ export function createKibanaAlertsRoute(server) {
return { alerts, emailResponse };
},
});
+
+ server.route({
+ method: 'POST',
+ path: '/api/monitoring/v1/alert_status',
+ config: {
+ validate: {
+ payload: schema.object({
+ timeRange: schema.object({
+ min: schema.string(),
+ max: schema.string(),
+ }),
+ }),
+ },
+ },
+ async handler(req, headers) {
+ const alertsClient = isFunction(req.getAlertsClient) ? req.getAlertsClient() : null;
+ if (!alertsClient) {
+ return headers.response().code(404);
+ }
+
+ const start = req.payload.timeRange.min;
+ const end = req.payload.timeRange.max;
+ let alerts;
+
+ try {
+ alerts = await fetchStatus(alertsClient, ALERT_TYPES, start, end, req.logger);
+ } catch (err) {
+ throw handleError(err, req);
+ }
+
+ return { alerts };
+ },
+ });
}
From 813d6cb796b42738fc24db43b0df2f4d337a06ed Mon Sep 17 00:00:00 2001
From: MadameSheema
Date: Mon, 6 Apr 2020 21:42:43 +0200
Subject: [PATCH 23/27] [SIEM] View signal in default timeline (#62616)
* adds test data
* adds 'View a signal in timeline' test
* implements test
* fixes implementation
* changes view signal for investigate signal
---
.../integration/detections_timeline.spec.ts | 43 +
.../plugins/siem/cypress/objects/timeline.ts | 10 +
.../siem/cypress/screens/detections.ts | 6 +
.../plugins/siem/cypress/screens/timeline.ts | 2 +
.../plugins/siem/cypress/tasks/detections.ts | 14 +
.../es_archives/timeline_signals/data.json.gz | Bin 0 -> 225608 bytes
.../timeline_signals/mappings.json | 9063 +++++++++++++++++
7 files changed, 9138 insertions(+)
create mode 100644 x-pack/legacy/plugins/siem/cypress/integration/detections_timeline.spec.ts
create mode 100644 x-pack/legacy/plugins/siem/cypress/objects/timeline.ts
create mode 100644 x-pack/test/siem_cypress/es_archives/timeline_signals/data.json.gz
create mode 100644 x-pack/test/siem_cypress/es_archives/timeline_signals/mappings.json
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/detections_timeline.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/detections_timeline.spec.ts
new file mode 100644
index 000000000000..2cac6e0f603b
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/integration/detections_timeline.spec.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SIGNAL_ID } from '../screens/detections';
+import { PROVIDER_BADGE } from '../screens/timeline';
+
+import {
+ expandFirstSignal,
+ investigateFirstSignalInTimeline,
+ waitForSignalsPanelToBeLoaded,
+} from '../tasks/detections';
+import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
+import { loginAndWaitForPage } from '../tasks/login';
+
+import { DETECTIONS } from '../urls/navigation';
+
+describe('Detections timeline', () => {
+ beforeEach(() => {
+ esArchiverLoad('timeline_signals');
+ loginAndWaitForPage(DETECTIONS);
+ });
+
+ afterEach(() => {
+ esArchiverUnload('timeline_signals');
+ });
+
+ it('Investigate signal in default timeline', () => {
+ waitForSignalsPanelToBeLoaded();
+ expandFirstSignal();
+ cy.get(SIGNAL_ID)
+ .first()
+ .invoke('text')
+ .then(eventId => {
+ investigateFirstSignalInTimeline();
+ cy.get(PROVIDER_BADGE)
+ .invoke('text')
+ .should('eql', `_id: "${eventId}"`);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/cypress/objects/timeline.ts b/x-pack/legacy/plugins/siem/cypress/objects/timeline.ts
new file mode 100644
index 000000000000..bca99bfa9266
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/objects/timeline.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+interface Timeline {
+ title: string;
+ query: string;
+}
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/detections.ts b/x-pack/legacy/plugins/siem/cypress/screens/detections.ts
index cb776be8d7b6..d9ffa5b5a4ab 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/detections.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/detections.ts
@@ -6,6 +6,8 @@
export const CLOSED_SIGNALS_BTN = '[data-test-subj="closedSignals"]';
+export const EXPAND_SIGNAL_BTN = '[data-test-subj="expand-event"]';
+
export const LOADING_SIGNALS_PANEL = '[data-test-subj="loading-signals-panel"]';
export const MANAGE_SIGNAL_DETECTION_RULES_BTN = '[data-test-subj="manage-signal-detection-rules"]';
@@ -20,8 +22,12 @@ export const OPENED_SIGNALS_BTN = '[data-test-subj="openSignals"]';
export const SELECTED_SIGNALS = '[data-test-subj="selectedSignals"]';
+export const SEND_SIGNAL_TO_TIMELINE_BTN = '[data-test-subj="send-signal-to-timeline-button"]';
+
export const SHOWING_SIGNALS = '[data-test-subj="showingSignals"]';
export const SIGNALS = '[data-test-subj="event"]';
+export const SIGNAL_ID = '[data-test-subj="draggable-content-_id"]';
+
export const SIGNAL_CHECKBOX = '[data-test-subj="select-event-container"] .euiCheckbox__input';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts b/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts
index fbce585a70f8..53d8273d9ce6 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts
@@ -14,6 +14,8 @@ export const ID_FIELD = '[data-test-subj="timeline"] [data-test-subj="field-name
export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]';
+export const PROVIDER_BADGE = '[data-test-subj="providerBadge"]';
+
export const SEARCH_OR_FILTER_CONTAINER =
'[data-test-subj="timeline-search-or-filter-search-container"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts
index abea4a887b8b..c30a178eab48 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts
@@ -6,11 +6,13 @@
import {
CLOSED_SIGNALS_BTN,
+ EXPAND_SIGNAL_BTN,
LOADING_SIGNALS_PANEL,
MANAGE_SIGNAL_DETECTION_RULES_BTN,
OPEN_CLOSE_SIGNAL_BTN,
OPEN_CLOSE_SIGNALS_BTN,
OPENED_SIGNALS_BTN,
+ SEND_SIGNAL_TO_TIMELINE_BTN,
SIGNALS,
SIGNAL_CHECKBOX,
} from '../screens/detections';
@@ -26,6 +28,12 @@ export const closeSignals = () => {
cy.get(OPEN_CLOSE_SIGNALS_BTN).click({ force: true });
};
+export const expandFirstSignal = () => {
+ cy.get(EXPAND_SIGNAL_BTN)
+ .first()
+ .click({ force: true });
+};
+
export const goToClosedSignals = () => {
cy.get(CLOSED_SIGNALS_BTN).click({ force: true });
};
@@ -58,6 +66,12 @@ export const selectNumberOfSignals = (numberOfSignals: number) => {
}
};
+export const investigateFirstSignalInTimeline = () => {
+ cy.get(SEND_SIGNAL_TO_TIMELINE_BTN)
+ .first()
+ .click({ force: true });
+};
+
export const waitForSignals = () => {
cy.get(REFRESH_BUTTON)
.invoke('text')
diff --git a/x-pack/test/siem_cypress/es_archives/timeline_signals/data.json.gz b/x-pack/test/siem_cypress/es_archives/timeline_signals/data.json.gz
new file mode 100644
index 0000000000000000000000000000000000000000..485d9868efd21af89ca7145386c7b95142f17205
GIT binary patch
literal 225608
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