diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index d80ad948cbb55..acb62043a15ca 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -10,15 +10,15 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Fetch Node.js rules http_archive( name = "build_bazel_rules_nodejs", - sha256 = "10f534e1c80f795cffe1f2822becd4897754d18564612510c59b3c73544ae7c6", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.0/rules_nodejs-3.5.0.tar.gz"], + sha256 = "4a5d654a4ccd4a4c24eca5d319d85a88a650edf119601550c95bf400c8cc897e", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.1/rules_nodejs-3.5.1.tar.gz"], ) # Now that we have the rules let's import from them to complete the work load("@build_bazel_rules_nodejs//:index.bzl", "check_rules_nodejs_version", "node_repositories", "yarn_install") # Assure we have at least a given rules_nodejs version -check_rules_nodejs_version(minimum_version_string = "3.5.0") +check_rules_nodejs_version(minimum_version_string = "3.5.1") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/dev_docs/tutorials/expressions.mdx b/dev_docs/tutorials/expressions.mdx index f0fc1dc595cfa..288fb9afdd722 100644 --- a/dev_docs/tutorials/expressions.mdx +++ b/dev_docs/tutorials/expressions.mdx @@ -9,23 +9,24 @@ tags: ['kibana', 'onboarding', 'dev', 'architecture'] ## Expressions service -Expression service exposes a registry of reusable functions primary used for fetching and transposing data and a registry of renderer functions that can render data into a DOM element. -Adding functions is easy and so is reusing them. An expression is a chain of functions with provided arguments, which given a single input translates to a single output. +Expression service exposes a registry of reusable functions primary used for fetching and transposing data and a registry of renderer functions that can render data into a DOM element. +Adding functions is easy and so is reusing them. An expression is a chain of functions with provided arguments, which given a single input translates to a single output. Each expression is representable by a human friendly string which a user can type. ### creating expressions Here is a very simple expression string: - essql 'select column1, column2 from myindex' | mapColumn name=column3 fn='{ column1 + 3 }' | table - +``` +essql 'select column1, column2 from myindex' | mapColumn name=column3 fn='{ column1 + 3 }' | table +``` It consists of 3 functions: - essql which runs given sql query against elasticsearch and returns the results - `mapColumn`, which computes a new column from existing ones; - `table`, which prepares the data for rendering in a tabular format. - + The same expression could also be constructed in the code: ```ts @@ -61,7 +62,7 @@ In addition, on the browser side, there are two additional ways to run expressio #### React expression renderer component -This is the easiest way to get expressions rendered inside your application. +This is the easiest way to get expressions rendered inside your application. ```ts diff --git a/package.json b/package.json index a2499d85247d7..65cb1e51866df 100644 --- a/package.json +++ b/package.json @@ -441,7 +441,7 @@ "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", "@bazel/ibazel": "^0.15.10", - "@bazel/typescript": "^3.5.0", + "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", diff --git a/packages/kbn-apm-utils/src/index.ts b/packages/kbn-apm-utils/src/index.ts index f2f537138dad0..384b6683199e5 100644 --- a/packages/kbn-apm-utils/src/index.ts +++ b/packages/kbn-apm-utils/src/index.ts @@ -16,6 +16,8 @@ export interface SpanOptions { labels?: Record; } +type Span = Exclude; + export function parseSpanOptions(optionsOrName: SpanOptions | string) { const options = typeof optionsOrName === 'string' ? { name: optionsOrName } : optionsOrName; @@ -30,7 +32,7 @@ const runInNewContext = any>(cb: T): ReturnType( optionsOrName: SpanOptions | string, - cb: () => Promise + cb: (span?: Span) => Promise ): Promise { const options = parseSpanOptions(optionsOrName); @@ -71,13 +73,17 @@ export async function withSpan( span.addLabels(labels); } - return cb() + return cb(span) .then((res) => { - span.outcome = 'success'; + if (!span.outcome || span.outcome === 'unknown') { + span.outcome = 'success'; + } return res; }) .catch((err) => { - span.outcome = 'failure'; + if (!span.outcome || span.outcome === 'unknown') { + span.outcome = 'failure'; + } throw err; }) .finally(() => { diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index efa4d8eaa5cfc..267de376c68ff 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -1,26 +1,29 @@ # Chromium build -We ship our own headless build of Chromium which is significantly smaller than -the standard binaries shipped by Google. The scripts in this folder can be used -to accept a commit hash from the Chromium repository, and initialize the build -on Ubuntu Linux. +We ship our own headless build of Chromium for Linux and Mac OS, using a +version of the source that corresponds to the requirements of the Puppeteer +node module. The scripts in this folder can be used to accept a commit hash +from the Chromium repository, and initialize the build in a workspace. ## Why do we do this -By default, Puppeteer will download a zip file containing the Chromium browser for any -OS. This creates problems on Linux, because Chromium has a dependency on X11, which -is often not installed for a server environment. We don't want to make a requirement -for Linux that you need X11 to run Kibana. To work around this, we create our own Chromium -build, using the +**Linux**: By default, Puppeteer will download a zip file containing the +Chromium browser for any OS. This creates problems on Linux, because Chromium +has a dependency on X11, which is often not installed for a server environment. +We don't want to make a requirement for Linux that you need X11 to run Kibana. +To work around this, we create our own Chromium build, using the [`headless_shell`](https://chromium.googlesource.com/chromium/src/+/5cf4b8b13ed518472038170f8de9db2f6c258fe4/headless) -build target. There are no (trustworthy) sources of these builds available elsewhere. - -Fortunately, creating the custom builds is only necessary for Linux. When you have a build -of Kibana for Linux, or if you use a Linux desktop to develop Kibana, you have a copy of -`headless_shell` bundled inside. When you have a Windows or Mac build of Kibana, or use -either of those for development, you have a copy of the full build of Chromium, which -was downloaded from the main [Chromium download -location](https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html). +build target. There are no (trustworthy) sources of these builds available +elsewhere. + +**Mac**: We do this on Mac because Elastic signs the Kibanna release artifact +with Apple to work with Gatekeeper on Mac OS. Having our own binary of Chromium +and bundling it with Kibana is integral to the artifact signing process. + +**Windows**: No custom build is necessary for Windows. We are able to use the +full build of Chromium, downloaded from the main [Chromium download +location](https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html), +using the revision that corresponds with the Puppeteer dependency. ## Build Script Usage diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index 2f85e95b337d5..8f6c6c79e2d8c 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -6,7 +6,7 @@ md5_file, ) -# This file builds Chromium headless on Linux. +# This file builds Chromium headless on Mac and Linux. # Verify that we have an argument, and if not print instructions if (len(sys.argv) < 2): @@ -68,7 +68,6 @@ print('Running install-build-deps...') runcmd(src_path + '/build/install-build-deps.sh') - print('Updating all modules') runcmd('gclient sync -D') @@ -89,7 +88,7 @@ print('Compiling... this will take a while') runcmd('autoninja -C out/headless headless_shell') -# Optimize the output on Linux x64 by stripping inessentials from the binary +# Optimize the output on Linux x64 and Mac by stripping inessentials from the binary # ARM must be cross-compiled from Linux and can not read the ARM binary in order to strip if platform.system() != 'Windows' and arch_name != 'arm64': print('Optimizing headless_shell') @@ -112,10 +111,18 @@ def archive_file(name): archive.write(from_path, to_path) return to_path -# Add dependencies that must be bundled with the Chromium executable. +# Each platform has slightly different requirements for what dependencies +# must be bundled with the Chromium executable. archive_file('headless_shell') -archive_file(path.join('swiftshader', 'libEGL.so')) -archive_file(path.join('swiftshader', 'libGLESv2.so')) +if platform.system() == 'Linux': + archive_file(path.join('swiftshader', 'libEGL.so')) + archive_file(path.join('swiftshader', 'libGLESv2.so')) + +elif platform.system() == 'Darwin': + archive_file('headless_shell') + archive_file('libswiftshader_libEGL.dylib') + archive_file('libswiftshader_libGLESv2.dylib') + archive_file(path.join('Helpers', 'chrome_crashpad_handler')) archive.close() diff --git a/x-pack/build_chromium/darwin/args.gn b/x-pack/build_chromium/darwin/args.gn new file mode 100644 index 0000000000000..605778a6a737c --- /dev/null +++ b/x-pack/build_chromium/darwin/args.gn @@ -0,0 +1,30 @@ +# Based on //build/headless.gn + +# Embed resource.pak into binary to simplify deployment. +headless_use_embedded_resources = true + +# In order to simplify deployment we build ICU data file +# into binary. +icu_use_data_file = false + +# Use embedded data instead external files for headless in order +# to simplify deployment. +v8_use_external_startup_data = false + +enable_nacl = false +enable_print_preview = false +enable_basic_printing = false +enable_remoting = false +use_alsa = false +use_cups = false +use_dbus = false +use_gio = false +# Please, consult @elastic/kibana-security before changing/removing this option. +use_kerberos = false +use_libpci = false +use_pulseaudio = false +use_udev = false + +is_debug = false +symbol_level = 0 +is_component_build = false diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index b08985e516f84..0737e0ce3f071 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -7,6 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger, KibanaRequest } from 'src/core/server'; +import { withSpan } from '@kbn/apm-utils'; import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { ActionTypeExecutorResult, @@ -78,113 +79,136 @@ export class ActionExecutor { ); } - const { - logger, - spaces, - getServices, - encryptedSavedObjectsClient, - actionTypeRegistry, - eventLogger, - preconfiguredActions, - getActionsClientWithRequest, - } = this.actionExecutorContext!; - - const services = getServices(request); - const spaceId = spaces && spaces.getSpaceId(request); - const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; - - const { actionTypeId, name, config, secrets } = await getActionInfo( - await getActionsClientWithRequest(request, source), - encryptedSavedObjectsClient, - preconfiguredActions, - actionId, - namespace.namespace - ); + return withSpan( + { + name: `execute_action`, + type: 'actions', + labels: { + actionId, + }, + }, + async (span) => { + const { + logger, + spaces, + getServices, + encryptedSavedObjectsClient, + actionTypeRegistry, + eventLogger, + preconfiguredActions, + getActionsClientWithRequest, + } = this.actionExecutorContext!; - if (!actionTypeRegistry.isActionExecutable(actionId, actionTypeId, { notifyUsage: true })) { - actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - } - const actionType = actionTypeRegistry.get(actionTypeId); - - let validatedParams: Record; - let validatedConfig: Record; - let validatedSecrets: Record; - - try { - validatedParams = validateParams(actionType, params); - validatedConfig = validateConfig(actionType, config); - validatedSecrets = validateSecrets(actionType, secrets); - } catch (err) { - return { status: 'error', actionId, message: err.message, retry: false }; - } + const services = getServices(request); + const spaceId = spaces && spaces.getSpaceId(request); + const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; + + const { actionTypeId, name, config, secrets } = await getActionInfo( + await getActionsClientWithRequest(request, source), + encryptedSavedObjectsClient, + preconfiguredActions, + actionId, + namespace.namespace + ); + + if (span) { + span.name = `execute_action ${actionTypeId}`; + span.addLabels({ + actionTypeId, + }); + } + + if (!actionTypeRegistry.isActionExecutable(actionId, actionTypeId, { notifyUsage: true })) { + actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + } + const actionType = actionTypeRegistry.get(actionTypeId); + + let validatedParams: Record; + let validatedConfig: Record; + let validatedSecrets: Record; + + try { + validatedParams = validateParams(actionType, params); + validatedConfig = validateConfig(actionType, config); + validatedSecrets = validateSecrets(actionType, secrets); + } catch (err) { + span?.setOutcome('failure'); + return { status: 'error', actionId, message: err.message, retry: false }; + } - const actionLabel = `${actionTypeId}:${actionId}: ${name}`; - logger.debug(`executing action ${actionLabel}`); - - const event: IEvent = { - event: { action: EVENT_LOG_ACTIONS.execute }, - kibana: { - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'action', - id: actionId, - ...namespace, + const actionLabel = `${actionTypeId}:${actionId}: ${name}`; + logger.debug(`executing action ${actionLabel}`); + + const event: IEvent = { + event: { action: EVENT_LOG_ACTIONS.execute }, + kibana: { + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: 'action', + id: actionId, + type_id: actionTypeId, + ...namespace, + }, + ], }, - ], - }, - }; + }; - eventLogger.startTiming(event); - let rawResult: ActionTypeExecutorResult; - try { - rawResult = await actionType.executor({ - actionId, - services, - params: validatedParams, - config: validatedConfig, - secrets: validatedSecrets, - }); - } catch (err) { - rawResult = { - actionId, - status: 'error', - message: 'an error occurred while running the action executor', - serviceMessage: err.message, - retry: false, - }; - } - eventLogger.stopTiming(event); + eventLogger.startTiming(event); + let rawResult: ActionTypeExecutorResult; + try { + rawResult = await actionType.executor({ + actionId, + services, + params: validatedParams, + config: validatedConfig, + secrets: validatedSecrets, + }); + } catch (err) { + rawResult = { + actionId, + status: 'error', + message: 'an error occurred while running the action executor', + serviceMessage: err.message, + retry: false, + }; + } + eventLogger.stopTiming(event); - // allow null-ish return to indicate success - const result = rawResult || { - actionId, - status: 'ok', - }; + // allow null-ish return to indicate success + const result = rawResult || { + actionId, + status: 'ok', + }; - event.event = event.event || {}; - - if (result.status === 'ok') { - event.event.outcome = 'success'; - event.message = `action executed: ${actionLabel}`; - } else if (result.status === 'error') { - event.event.outcome = 'failure'; - event.message = `action execution failure: ${actionLabel}`; - event.error = event.error || {}; - event.error.message = actionErrorToMessage(result); - logger.warn(`action execution failure: ${actionLabel}: ${event.error.message}`); - } else { - event.event.outcome = 'failure'; - event.message = `action execution returned unexpected result: ${actionLabel}: "${result.status}"`; - event.error = event.error || {}; - event.error.message = 'action execution returned unexpected result'; - logger.warn( - `action execution failure: ${actionLabel}: returned unexpected result "${result.status}"` - ); - } + event.event = event.event || {}; - eventLogger.logEvent(event); - return result; + if (result.status === 'ok') { + span?.setOutcome('success'); + event.event.outcome = 'success'; + event.message = `action executed: ${actionLabel}`; + } else if (result.status === 'error') { + span?.setOutcome('failure'); + event.event.outcome = 'failure'; + event.message = `action execution failure: ${actionLabel}`; + event.error = event.error || {}; + event.error.message = actionErrorToMessage(result); + logger.warn(`action execution failure: ${actionLabel}: ${event.error.message}`); + } else { + span?.setOutcome('failure'); + event.event.outcome = 'failure'; + event.message = `action execution returned unexpected result: ${actionLabel}: "${result.status}"`; + event.error = event.error || {}; + event.error.message = 'action execution returned unexpected result'; + logger.warn( + `action execution failure: ${actionLabel}: returned unexpected result "${result.status}"` + ); + } + + eventLogger.logEvent(event); + return result; + } + ); } } 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 78d74b78c99ba..5ab25fbfa39e7 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 @@ -166,10 +166,12 @@ test('enqueues execution per selected action', async () => { "id": "1", "rel": "primary", "type": "alert", + "type_id": "test", }, Object { "id": "1", "type": "action", + "type_id": "test", }, ], }, 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 93cced2043d5e..ef93179bdaba1 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 @@ -182,8 +182,14 @@ export function createExecutionHandler< action_subgroup: actionSubgroup, }, saved_objects: [ - { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', id: alertId, ...namespace }, - { type: 'action', id: action.id, ...namespace }, + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: 'alert', + id: alertId, + type_id: alertType.id, + ...namespace, + }, + { type: 'action', id: action.id, type_id: action.actionTypeId, ...namespace }, ], }, }; 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 9c5ef25e5dfa0..c157765afb359 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 @@ -278,6 +278,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -385,6 +386,7 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, ], }, @@ -406,6 +408,7 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, ], }, @@ -428,11 +431,13 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, { id: '1', namespace: undefined, type: 'action', + type_id: 'action', }, ], }, @@ -455,6 +460,7 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, ], }, @@ -531,6 +537,7 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, ], }, @@ -551,6 +558,7 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, ], }, @@ -572,6 +580,7 @@ describe('Task Runner', () => { namespace: undefined, rel: 'primary', type: 'alert', + type_id: 'test', }, ], }, @@ -702,6 +711,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -725,6 +735,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -931,6 +942,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -953,6 +965,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -976,11 +989,13 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, Object { "id": "1", "namespace": undefined, "type": "action", + "type_id": "action", }, ], }, @@ -1004,6 +1019,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1386,6 +1402,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1408,6 +1425,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1431,6 +1449,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1641,6 +1660,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1700,6 +1720,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1767,6 +1788,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1834,6 +1856,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, @@ -1900,6 +1923,7 @@ describe('Task Runner', () => { "namespace": undefined, "rel": "primary", "type": "alert", + "type_id": "test", }, ], }, 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 043f30ddca371..fd82b38b493d7 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -331,6 +331,7 @@ export class TaskRunner< alertId, alertLabel, namespace, + ruleTypeId: alert.alertTypeId, }); if (!muteAll) { @@ -493,6 +494,7 @@ export class TaskRunner< rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', id: alertId, + type_id: this.alertType.id, namespace, }, ], @@ -598,6 +600,7 @@ interface GenerateNewAndRecoveredInstanceEventsParams< alertId: string; alertLabel: string; namespace: string | undefined; + ruleTypeId: string; } function generateNewAndRecoveredInstanceEvents< @@ -611,6 +614,7 @@ function generateNewAndRecoveredInstanceEvents< currentAlertInstances, originalAlertInstances, recoveredAlertInstances, + ruleTypeId, } = params; const originalAlertInstanceIds = Object.keys(originalAlertInstances); const currentAlertInstanceIds = Object.keys(currentAlertInstances); @@ -664,6 +668,7 @@ function generateNewAndRecoveredInstanceEvents< rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', id: alertId, + type_id: ruleTypeId, namespace, }, ], diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts index 17e22e6f23daf..6c31927cd75b0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts @@ -15,6 +15,7 @@ export const mockFlashMessagesActions = { clearFlashMessages: jest.fn(), setQueuedMessages: jest.fn(), clearQueuedMessages: jest.fn(), + dismissToastMessage: jest.fn(), }; export const mockFlashMessageHelpers = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx index 9591b82773b9f..132579bad8bdc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx @@ -19,14 +19,11 @@ describe('CrawlerLanding', () => { let wrapper: ShallowWrapper; beforeEach(() => { + jest.clearAllMocks(); setMockValues({ ...mockEngineValues }); wrapper = shallow(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('contains an external documentation link', () => { const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerDocumentationLink"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx new file mode 100644 index 0000000000000..eb30ae867b4b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { rerender, setMockActions, setMockValues } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiCode } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { CrawlerOverview } from './crawler_overview'; + +const actions = { + fetchCrawlerData: jest.fn(), +}; + +const values = { + dataLoading: false, + domains: [], +}; + +describe('CrawlerOverview', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + wrapper = shallow(); + }); + + it('renders', () => { + expect(wrapper.find(EuiCode)).toHaveLength(1); + }); + + it('calls fetchCrawlerData on page load', () => { + expect(actions.fetchCrawlerData).toHaveBeenCalledTimes(1); + }); + + // TODO after DomainsTable is built in a future PR + // it('contains a DomainsTable', () => {}) + + // TODO after CrawlRequestsTable is built in a future PR + // it('containss a CrawlRequestsTable,() => {}) + + // TODO after AddDomainForm is built in a future PR + // it('contains an AddDomainForm' () => {}) + + // TODO after empty state is added in a future PR + // it('has an empty state', () => {} ) + + it('shows an empty state when data is loading', () => { + setMockValues({ dataLoading: true }); + rerender(wrapper); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx new file mode 100644 index 0000000000000..5eeaaaef69605 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiCode, EuiPageHeader } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Loading } from '../../../shared/loading'; + +import { CRAWLER_TITLE } from './constants'; +import { CrawlerOverviewLogic } from './crawler_overview_logic'; + +export const CrawlerOverview: React.FC = () => { + const { dataLoading, domains } = useValues(CrawlerOverviewLogic); + + const { fetchCrawlerData } = useActions(CrawlerOverviewLogic); + + useEffect(() => { + fetchCrawlerData(); + }, []); + + if (dataLoading) { + return ; + } + + return ( + <> + + + {JSON.stringify(domains, null, 2)} + > + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts new file mode 100644 index 0000000000000..766f5dcfa02dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { CrawlerOverviewLogic } from './crawler_overview_logic'; +import { CrawlerPolicies, CrawlerRules, CrawlRule } from './types'; + +const DEFAULT_VALUES = { + dataLoading: true, + domains: [], +}; + +const DEFAULT_CRAWL_RULE: CrawlRule = { + id: '-', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.regex, + pattern: '.*', +}; + +describe('CrawlerOverviewLogic', () => { + const { mount } = new LogicMounter(CrawlerOverviewLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(CrawlerOverviewLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onFetchCrawlerData', () => { + const crawlerData = { + domains: [ + { + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'moviedatabase.com', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + defaultCrawlRule: DEFAULT_CRAWL_RULE, + }, + ], + }; + + beforeEach(() => { + CrawlerOverviewLogic.actions.onFetchCrawlerData(crawlerData); + }); + + it('should set all received data as top-level values', () => { + expect(CrawlerOverviewLogic.values.domains).toEqual(crawlerData.domains); + }); + + it('should set dataLoading to false', () => { + expect(CrawlerOverviewLogic.values.dataLoading).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('fetchCrawlerData', () => { + it('calls onFetchCrawlerData with retrieved data that has been converted from server to client', async () => { + jest.spyOn(CrawlerOverviewLogic.actions, 'onFetchCrawlerData'); + + http.get.mockReturnValue( + Promise.resolve({ + domains: [ + { + id: '507f1f77bcf86cd799439011', + name: 'moviedatabase.com', + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + }, + ], + }) + ); + CrawlerOverviewLogic.actions.fetchCrawlerData(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/crawler'); + expect(CrawlerOverviewLogic.actions.onFetchCrawlerData).toHaveBeenCalledWith({ + domains: [ + { + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'moviedatabase.com', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + }, + ], + }); + }); + + it('calls flashApiErrors when there is an error', async () => { + http.get.mockReturnValue(Promise.reject('error')); + CrawlerOverviewLogic.actions.fetchCrawlerData(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts new file mode 100644 index 0000000000000..6f04ade5962eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { CrawlerData, CrawlerDataFromServer, CrawlerDomain } from './types'; +import { crawlerDataServerToClient } from './utils'; + +interface CrawlerOverviewValues { + dataLoading: boolean; + domains: CrawlerDomain[]; +} + +interface CrawlerOverviewActions { + fetchCrawlerData(): void; + onFetchCrawlerData(data: CrawlerData): { data: CrawlerData }; +} + +export const CrawlerOverviewLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'crawler', 'crawler_overview'], + actions: { + fetchCrawlerData: true, + onFetchCrawlerData: (data) => ({ data }), + }, + reducers: { + dataLoading: [ + true, + { + onFetchCrawlerData: () => false, + }, + ], + domains: [ + [], + { + onFetchCrawlerData: (_, { data: { domains } }) => domains, + }, + ], + }, + listeners: ({ actions }) => ({ + fetchCrawlerData: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/crawler`); + const crawlerData = crawlerDataServerToClient(response as CrawlerDataFromServer); + actions.onFetchCrawlerData(crawlerData); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx index 6aa9ca8c4feb1..351f547447803 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx @@ -14,21 +14,32 @@ import { Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; import { CrawlerLanding } from './crawler_landing'; +import { CrawlerOverview } from './crawler_overview'; import { CrawlerRouter } from './crawler_router'; describe('CrawlerRouter', () => { + const OLD_ENV = process.env; + beforeEach(() => { + jest.clearAllMocks(); setMockValues({ ...mockEngineValues }); }); afterEach(() => { - jest.clearAllMocks(); + process.env = OLD_ENV; }); - it('renders a landing page', () => { + it('renders a landing page by default', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(CrawlerLanding)).toHaveLength(1); }); + + it('renders a crawler overview in dev', () => { + process.env.NODE_ENV = 'development'; + const wrapper = shallow(); + + expect(wrapper.find(CrawlerOverview)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx index fcc949de7d8b4..926c45b437937 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx @@ -14,13 +14,14 @@ import { getEngineBreadcrumbs } from '../engine'; import { CRAWLER_TITLE } from './constants'; import { CrawlerLanding } from './crawler_landing'; +import { CrawlerOverview } from './crawler_overview'; export const CrawlerRouter: React.FC = () => { return ( - + {process.env.NODE_ENV === 'development' ? : } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts new file mode 100644 index 0000000000000..f895e8f01e399 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum CrawlerPolicies { + allow = 'allow', + deny = 'deny', +} + +export enum CrawlerRules { + beginsWith = 'begins', + endsWith = 'ends', + contains = 'contains', + regex = 'regex', +} + +export interface CrawlRule { + id: string; + policy: CrawlerPolicies; + rule: CrawlerRules; + pattern: string; +} + +export interface EntryPoint { + id: string; + value: string; +} + +export interface Sitemap { + id: string; + url: string; +} + +export interface CrawlerDomain { + createdOn: string; + documentCount: number; + id: string; + lastCrawl?: string; + url: string; + crawlRules: CrawlRule[]; + defaultCrawlRule?: CrawlRule; + entryPoints: EntryPoint[]; + sitemaps: Sitemap[]; +} + +export interface CrawlerDomainFromServer { + id: string; + name: string; + created_on: string; + last_visited_at?: string; + document_count: number; + crawl_rules: CrawlRule[]; + default_crawl_rule?: CrawlRule; + entry_points: EntryPoint[]; + sitemaps: Sitemap[]; +} + +export interface CrawlerData { + domains: CrawlerDomain[]; +} + +export interface CrawlerDataFromServer { + domains: CrawlerDomainFromServer[]; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts new file mode 100644 index 0000000000000..6e2dd7c826b70 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CrawlerPolicies, CrawlerRules, CrawlRule, CrawlerDomainFromServer } from './types'; + +import { crawlerDomainServerToClient, crawlerDataServerToClient } from './utils'; + +const DEFAULT_CRAWL_RULE: CrawlRule = { + id: '-', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.regex, + pattern: '.*', +}; + +describe('crawlerDomainServerToClient', () => { + it('converts the API payload into properties matching our code style', () => { + const id = '507f1f77bcf86cd799439011'; + const name = 'moviedatabase.com'; + + const defaultServerPayload = { + id, + name, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + }; + + const defaultClientPayload = { + id, + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: name, + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + }; + + expect(crawlerDomainServerToClient(defaultServerPayload)).toStrictEqual(defaultClientPayload); + expect( + crawlerDomainServerToClient({ + ...defaultServerPayload, + last_visited_at: 'Mon, 31 Aug 2020 17:00:00 +0000', + }) + ).toStrictEqual({ ...defaultClientPayload, lastCrawl: 'Mon, 31 Aug 2020 17:00:00 +0000' }); + expect( + crawlerDomainServerToClient({ + ...defaultServerPayload, + default_crawl_rule: DEFAULT_CRAWL_RULE, + }) + ).toStrictEqual({ ...defaultClientPayload, defaultCrawlRule: DEFAULT_CRAWL_RULE }); + }); +}); + +describe('crawlerDataServerToClient', () => { + it('converts all domains from the server form to their client form', () => { + const domains: CrawlerDomainFromServer[] = [ + { + id: 'x', + name: 'moviedatabase.com', + document_count: 13, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + sitemaps: [], + entry_points: [], + crawl_rules: [], + default_crawl_rule: DEFAULT_CRAWL_RULE, + }, + { + id: 'y', + name: 'swiftype.com', + last_visited_at: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 40, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + sitemaps: [], + entry_points: [], + crawl_rules: [], + }, + ]; + + const output = crawlerDataServerToClient({ + domains, + }); + + expect(output.domains).toHaveLength(2); + expect(output.domains[0]).toEqual(crawlerDomainServerToClient(domains[0])); + expect(output.domains[1]).toEqual(crawlerDomainServerToClient(domains[1])); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts new file mode 100644 index 0000000000000..e89c549261fca --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CrawlerDomain, + CrawlerDomainFromServer, + CrawlerData, + CrawlerDataFromServer, +} from './types'; + +export function crawlerDomainServerToClient(payload: CrawlerDomainFromServer): CrawlerDomain { + const { + id, + name, + sitemaps, + created_on: createdOn, + last_visited_at: lastCrawl, + document_count: documentCount, + crawl_rules: crawlRules, + default_crawl_rule: defaultCrawlRule, + entry_points: entryPoints, + } = payload; + + const clientPayload: CrawlerDomain = { + id, + url: name, + documentCount, + createdOn, + crawlRules, + sitemaps, + entryPoints, + }; + + if (lastCrawl) { + clientPayload.lastCrawl = lastCrawl; + } + + if (defaultCrawlRule) { + clientPayload.defaultCrawlRule = defaultCrawlRule; + } + + return clientPayload; +} + +export function crawlerDataServerToClient(payload: CrawlerDataFromServer): CrawlerData { + const { domains } = payload; + + return { + domains: domains.map((domain) => crawlerDomainServerToClient(domain)), + }; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index 5d61929770299..b9d3dbd9ee412 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* istanbul ignore file */ + import React, { useState } from 'react'; import { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index 2f9ff707f9631..59010cb9ab8b6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -9,15 +9,6 @@ import { i18n } from '@kbn/i18n'; import { AdvanceRoleType } from '../../types'; -export const SAVE_ROLE_MAPPING = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.saveRoleMappingButtonLabel', - { defaultMessage: 'Save role mapping' } -); -export const UPDATE_ROLE_MAPPING = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.updateRoleMappingButtonLabel', - { defaultMessage: 'Update role mapping' } -); - export const EMPTY_ROLE_MAPPINGS_BODY = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMapping.emptyRoleMappingsBody', { @@ -126,74 +117,71 @@ export const ADMIN_ROLE_TYPE_DESCRIPTION = i18n.translate( } ); -export const ADVANCED_ROLE_SELECTORS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.advancedRoleSelectorsTitle', +export const ENGINE_REQUIRED_ERROR = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineRequiredError', { - defaultMessage: 'Full or limited engine access', + defaultMessage: 'At least one assigned engine is required.', } ); -export const ROLE_TITLE = i18n.translate('xpack.enterpriseSearch.appSearch.roleTitle', { - defaultMessage: 'Role', -}); - -export const FULL_ENGINE_ACCESS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.fullEngineAccessTitle', +export const ALL_ENGINES_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.allEnginesLabel', { - defaultMessage: 'Full engine access', + defaultMessage: 'Assign to all engines', } ); -export const FULL_ENGINE_ACCESS_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.appSearch.fullEngineAccessDescription', +export const ALL_ENGINES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.allEnginesDescription', { - defaultMessage: 'Access to all current and future engines.', + defaultMessage: + 'Assigning to all engines includes all current and future engines as created and administered at a later date.', } ); -export const LIMITED_ENGINE_ACCESS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.limitedEngineAccessTitle', +export const SPECIFIC_ENGINES_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.specificEnginesLabel', { - defaultMessage: 'Limited engine access', + defaultMessage: 'Assign to specific engines', } ); -export const LIMITED_ENGINE_ACCESS_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.appSearch.limitedEngineAccessDescription', +export const SPECIFIC_ENGINES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.specificEnginesDescription', { - defaultMessage: 'Limit user access to specific engines:', + defaultMessage: 'Assign to a select set of engines statically.', } ); -export const ENGINE_ACCESS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.engineAccessTitle', +export const ENGINE_ASSIGNMENT_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineAssignmentLabel', { - defaultMessage: 'Engine access', + defaultMessage: 'Engine assignment', } ); export const ADVANCED_ROLE_TYPES = [ { - type: 'dev', + id: 'dev', description: DEV_ROLE_TYPE_DESCRIPTION, }, { - type: 'editor', + id: 'editor', description: EDITOR_ROLE_TYPE_DESCRIPTION, }, { - type: 'analyst', + id: 'analyst', description: ANALYST_ROLE_TYPE_DESCRIPTION, }, ] as AdvanceRoleType[]; export const STANDARD_ROLE_TYPES = [ { - type: 'owner', + id: 'owner', description: OWNER_ROLE_TYPE_DESCRIPTION, }, { - type: 'admin', + id: 'admin', description: ADMIN_ROLE_TYPE_DESCRIPTION, }, ] as AdvanceRoleType[]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts index ce4b1de6e399d..19062cf44c17a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { RoleMappingsRouter } from './role_mappings_router'; +export { RoleMappings } from './role_mappings'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx index f50fc21d5ba58..2e179dc2b6ab3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx @@ -12,18 +12,16 @@ import { engines } from '../../__mocks__/engines.mock'; import React from 'react'; +import { waitFor } from '@testing-library/dom'; import { shallow } from 'enzyme'; -import { EuiCheckbox } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiRadioGroup } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; -import { - AttributeSelector, - DeleteMappingCallout, - RoleSelector, -} from '../../../shared/role_mapping'; +import { AttributeSelector, RoleSelector } from '../../../shared/role_mapping'; import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { STANDARD_ROLE_TYPES } from './constants'; + import { RoleMapping } from './role_mapping'; describe('RoleMapping', () => { @@ -68,39 +66,44 @@ describe('RoleMapping', () => { }); it('renders', () => { + setMockValues({ ...mockValues, roleMapping: asRoleMapping }); const wrapper = shallow(); expect(wrapper.find(AttributeSelector)).toHaveLength(1); - expect(wrapper.find(RoleSelector)).toHaveLength(5); + expect(wrapper.find(RoleSelector)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); + it('only passes standard role options for non-advanced roles', () => { + setMockValues({ ...mockValues, hasAdvancedRoles: false }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.find(RoleSelector).prop('roleOptions')).toHaveLength(STANDARD_ROLE_TYPES.length); }); - it('renders DeleteMappingCallout for existing mapping', () => { - setMockValues({ ...mockValues, roleMapping: asRoleMapping }); + it('sets initial selected state when accessAllEngines is true', () => { + setMockValues({ ...mockValues, accessAllEngines: true }); const wrapper = shallow(); - expect(wrapper.find(DeleteMappingCallout)).toHaveLength(1); + expect(wrapper.find(EuiRadioGroup).prop('idSelected')).toBe('all'); }); - it('hides DeleteMappingCallout for new mapping', () => { - const wrapper = shallow(); + it('handles all/specific engines radio change', () => { + const wrapper = shallow(); + const radio = wrapper.find(EuiRadioGroup); + radio.simulate('change', { target: { checked: false } }); - expect(wrapper.find(DeleteMappingCallout)).toHaveLength(0); + expect(actions.handleAccessAllEnginesChange).toHaveBeenCalledWith(false); }); - it('handles engine checkbox click', () => { + it('handles engine checkbox click', async () => { const wrapper = shallow(); - wrapper - .find(EuiCheckbox) - .first() - .simulate('change', { target: { checked: true } }); - - expect(actions.handleEngineSelectionChange).toHaveBeenCalledWith(engines[0].name, true); + await waitFor(() => + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: engines[0].name, value: engines[0].name }]) + ); + wrapper.update(); + + expect(actions.handleEngineSelectionChange).toHaveBeenCalledWith([engines[0].name]); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index 610ceae8856f2..0f201889b2f05 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -5,65 +5,36 @@ * 2.0. */ -import React, { useEffect } from 'react'; - -import { useParams } from 'react-router-dom'; +import React from 'react'; import { useActions, useValues } from 'kea'; -import { - EuiButton, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiPageContentBody, - EuiPageHeader, - EuiPanel, - EuiRadio, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { EuiComboBox, EuiFormRow, EuiHorizontalRule, EuiRadioGroup, EuiSpacer } from '@elastic/eui'; + import { AttributeSelector, - DeleteMappingCallout, RoleSelector, + RoleOptionLabel, + RoleMappingFlyout, } from '../../../shared/role_mapping'; -import { - ROLE_MAPPINGS_TITLE, - ADD_ROLE_MAPPING_TITLE, - MANAGE_ROLE_MAPPING_TITLE, -} from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; +import { AdvanceRoleType } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; -import { Engine } from '../engine/types'; import { - SAVE_ROLE_MAPPING, - UPDATE_ROLE_MAPPING, ADVANCED_ROLE_TYPES, STANDARD_ROLE_TYPES, - ADVANCED_ROLE_SELECTORS_TITLE, - ROLE_TITLE, - FULL_ENGINE_ACCESS_TITLE, - FULL_ENGINE_ACCESS_DESCRIPTION, - LIMITED_ENGINE_ACCESS_TITLE, - LIMITED_ENGINE_ACCESS_DESCRIPTION, - ENGINE_ACCESS_TITLE, + ENGINE_REQUIRED_ERROR, + ALL_ENGINES_LABEL, + ALL_ENGINES_DESCRIPTION, + SPECIFIC_ENGINES_LABEL, + SPECIFIC_ENGINES_DESCRIPTION, + ENGINE_ASSIGNMENT_LABEL, } from './constants'; import { RoleMappingsLogic } from './role_mappings_logic'; -interface RoleMappingProps { - isNew?: boolean; -} - -export const RoleMapping: React.FC = ({ isNew }) => { - const { roleId } = useParams() as { roleId: string }; +export const RoleMapping: React.FC = () => { const { myRole } = useValues(AppLogic); const { @@ -71,12 +42,10 @@ export const RoleMapping: React.FC = ({ isNew }) => { handleAttributeSelectorChange, handleAttributeValueChange, handleAuthProviderChange, - handleDeleteMapping, handleEngineSelectionChange, handleRoleChange, handleSaveMapping, - initializeRoleMapping, - resetState, + closeRoleMappingFlyout, } = useActions(RoleMappingsLogic); const { @@ -86,7 +55,6 @@ export const RoleMapping: React.FC = ({ isNew }) => { attributes, availableAuthProviders, availableEngines, - dataLoading, elasticsearchRoles, hasAdvancedRoles, multipleAuthProvidersConfig, @@ -94,154 +62,97 @@ export const RoleMapping: React.FC = ({ isNew }) => { roleType, selectedEngines, selectedAuthProviders, + selectedOptions, } = useValues(RoleMappingsLogic); - useEffect(() => { - initializeRoleMapping(roleId); - return resetState; - }, []); - - if (dataLoading) return ; - - const SAVE_ROLE_MAPPING_LABEL = isNew ? SAVE_ROLE_MAPPING : UPDATE_ROLE_MAPPING; - const TITLE = isNew ? ADD_ROLE_MAPPING_TITLE : MANAGE_ROLE_MAPPING_TITLE; - - const saveRoleMappingButton = ( - - {SAVE_ROLE_MAPPING_LABEL} - - ); - - const engineSelector = (engine: Engine) => ( - { - handleEngineSelectionChange(engine.name, e.target.checked); - }} - label={engine.name} - /> - ); - - const advancedRoleSelectors = ( - <> - - - {ADVANCED_ROLE_SELECTORS_TITLE} - - - {ADVANCED_ROLE_TYPES.map(({ type, description }) => ( - 0 || accessAllEngines; + + const mapRoleOptions = ({ id, description }: AdvanceRoleType) => ({ + id, + description, + disabled: !myRole.availableRoleTypes.includes(id), + }); + + const standardRoleOptions = STANDARD_ROLE_TYPES.map(mapRoleOptions); + const advancedRoleOptions = ADVANCED_ROLE_TYPES.map(mapRoleOptions); + + const roleOptions = hasAdvancedRoles + ? [...standardRoleOptions, ...advancedRoleOptions] + : standardRoleOptions; + + const engineOptions = [ + { + id: 'all', + label: , + }, + { + id: 'specific', + label: ( + - ))} - > - ); + ), + }, + ]; return ( - <> - - - - - - - - - - - - {ROLE_TITLE} - - - - {FULL_ENGINE_ACCESS_TITLE} - - - {STANDARD_ROLE_TYPES.map(({ type, description }) => ( - - ))} - {hasAdvancedRoles && advancedRoleSelectors} - - - {hasAdvancedRoles && ( - - - - {ENGINE_ACCESS_TITLE} - - - - - - {FULL_ENGINE_ACCESS_TITLE} - - {FULL_ENGINE_ACCESS_DESCRIPTION} - > - } - /> - - - <> - - - {LIMITED_ENGINE_ACCESS_TITLE} - - {LIMITED_ENGINE_ACCESS_DESCRIPTION} - > - } - /> - {!accessAllEngines && ( - - {availableEngines.map((engine) => engineSelector(engine))} - - )} - > - - - - )} - - - {roleMapping && } - - > + + + + + + {hasAdvancedRoles && ( + <> + + + handleAccessAllEnginesChange(id === 'all')} + legend={{ + children: {ENGINE_ASSIGNMENT_LABEL}, + }} + /> + + + ({ label: name, value: name }))} + onChange={(options) => { + handleEngineSelectionChange(options.map(({ value }) => value as string)); + }} + fullWidth + isDisabled={accessAllEngines || !roleHasScopedEngines(roleType)} + /> + + > + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx index c6da903e20912..4ccb1fec0f034 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx @@ -12,16 +12,19 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { Loading } from '../../../shared/loading'; import { RoleMappingsTable } from '../../../shared/role_mapping'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); + const initializeRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); const mockValues = { roleMappings: [wsRoleMapping], dataLoading: false, @@ -31,6 +34,8 @@ describe('RoleMappings', () => { beforeEach(() => { setMockActions({ initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, }); setMockValues(mockValues); }); @@ -54,4 +59,19 @@ describe('RoleMappings', () => { expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); + + it('renders RoleMapping flyout', () => { + setMockValues({ ...mockValues, roleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(RoleMapping)).toHaveLength(1); + }); + + it('handles button click', () => { + setMockValues({ ...mockValues, roleMappings: [] }); + const wrapper = shallow(); + wrapper.find(EuiEmptyPrompt).dive().find(EuiButton).simulate('click'); + + expect(initializeRoleMapping).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index 86e2e51d29a7d..61ed70f515f6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -10,6 +10,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { + EuiButton, EuiEmptyPrompt, EuiPageContent, EuiPageContentBody, @@ -20,22 +21,31 @@ import { import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; -import { AddRoleMappingButton, RoleMappingsTable } from '../../../shared/role_mapping'; +import { RoleMappingsTable } from '../../../shared/role_mapping'; import { EMPTY_ROLE_MAPPINGS_TITLE, + ROLE_MAPPING_ADD_BUTTON, ROLE_MAPPINGS_TITLE, ROLE_MAPPINGS_DESCRIPTION, } from '../../../shared/role_mapping/constants'; -import { ROLE_MAPPING_NEW_PATH } from '../../routes'; - import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING, EMPTY_ROLE_MAPPINGS_BODY } from './constants'; +import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; -import { generateRoleMappingPath } from './utils'; export const RoleMappings: React.FC = () => { - const { initializeRoleMappings, resetState } = useActions(RoleMappingsLogic); - const { roleMappings, multipleAuthProvidersConfig, dataLoading } = useValues(RoleMappingsLogic); + const { + initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, + resetState, + } = useActions(RoleMappingsLogic); + const { + roleMappings, + multipleAuthProvidersConfig, + dataLoading, + roleMappingFlyoutOpen, + } = useValues(RoleMappingsLogic); useEffect(() => { initializeRoleMappings(); @@ -44,7 +54,11 @@ export const RoleMappings: React.FC = () => { if (dataLoading) return ; - const addMappingButton = ; + const addMappingButton = ( + initializeRoleMapping()}> + {ROLE_MAPPING_ADD_BUTTON} + + ); const roleMappingEmptyState = ( @@ -63,8 +77,9 @@ export const RoleMappings: React.FC = () => { accessItemKey="engines" accessHeader={ROLE_MAPPINGS_ENGINE_ACCESS_HEADING} addMappingButton={addMappingButton} - getRoleMappingPath={generateRoleMappingPath} + initializeRoleMapping={initializeRoleMapping} shouldShowAuthProvider={multipleAuthProvidersConfig} + handleDeleteMapping={handleDeleteMapping} /> ); @@ -72,6 +87,8 @@ export const RoleMappings: React.FC = () => { <> + + {roleMappingFlyoutOpen && } 0}> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index ada17fc9a732a..d0534a2a0be59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; import { LogicMounter } from '../../../__mocks__/kea.mock'; import { engines } from '../../__mocks__/engines.mock'; @@ -13,20 +13,25 @@ import { engines } from '../../__mocks__/engines.mock'; import { nextTick } from '@kbn/test/jest'; import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; -import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; - const { navigateToUrl } = mockKibanaValues; - const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + const { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, + setErrorMessage, + } = mockFlashMessageHelpers; const { mount } = new LogicMounter(RoleMappingsLogic); const DEFAULT_VALUES = { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], roleMapping: null, + roleMappingFlyoutOpen: false, roleMappings: [], roleType: 'owner', attributeValue: '', @@ -38,6 +43,7 @@ describe('RoleMappingsLogic', () => { selectedEngines: new Set(), accessAllEngines: true, selectedAuthProviders: [ANY_AUTH_PROVIDER], + selectedOptions: [], }; const mappingsServerProps = { multipleAuthProvidersConfig: true, roleMappings: [asRoleMapping] }; @@ -87,6 +93,10 @@ describe('RoleMappingsLogic', () => { attributeValue: 'superuser', elasticsearchRoles: mappingServerProps.elasticsearchRoles, selectedEngines: new Set(engines.map((e) => e.name)), + selectedOptions: [ + { label: engines[0].name, value: engines[0].name }, + { label: engines[1].name, value: engines[1].name }, + ], }); }); @@ -134,21 +144,21 @@ describe('RoleMappingsLogic', () => { }); it('handles adding an engine to selected engines', () => { - RoleMappingsLogic.actions.handleEngineSelectionChange(otherEngine.name, true); + RoleMappingsLogic.actions.handleEngineSelectionChange([engine.name, otherEngine.name]); expect(RoleMappingsLogic.values.selectedEngines).toEqual( new Set([engine.name, otherEngine.name]) ); }); it('handles removing an engine from selected engines', () => { - RoleMappingsLogic.actions.handleEngineSelectionChange(otherEngine.name, false); + RoleMappingsLogic.actions.handleEngineSelectionChange([engine.name]); expect(RoleMappingsLogic.values.selectedEngines).toEqual(new Set([engine.name])); }); }); it('handleAccessAllEnginesChange', () => { - RoleMappingsLogic.actions.handleAccessAllEnginesChange(); + RoleMappingsLogic.actions.handleAccessAllEnginesChange(false); expect(RoleMappingsLogic.values).toEqual({ ...DEFAULT_VALUES, @@ -250,6 +260,25 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values).toEqual(DEFAULT_VALUES); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('openRoleMappingFlyout', () => { + mount(mappingServerProps); + RoleMappingsLogic.actions.openRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeRoleMappingFlyout', () => { + mount({ + ...mappingServerProps, + roleMappingFlyoutOpen: true, + }); + RoleMappingsLogic.actions.closeRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); + expect(clearFlashMessages).toHaveBeenCalled(); + }); }); describe('listeners', () => { @@ -302,12 +331,12 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - it('redirects when there is a 404 status', async () => { + it('shows error when there is a 404 status', async () => { http.get.mockReturnValue(Promise.reject({ status: 404 })); RoleMappingsLogic.actions.initializeRoleMapping(); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(setErrorMessage).toHaveBeenCalledWith(ROLE_MAPPING_NOT_FOUND); }); }); @@ -322,8 +351,12 @@ describe('RoleMappingsLogic', () => { engines: [], }; - it('calls API and navigates when new mapping', async () => { + it('calls API and refreshes list when new mapping', async () => { mount(mappingsServerProps); + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); http.post.mockReturnValue(Promise.resolve(mappingServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); @@ -333,11 +366,15 @@ describe('RoleMappingsLogic', () => { }); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); }); - it('calls API and navigates when existing mapping', async () => { + it('calls API and refreshes list when existing mapping', async () => { mount(mappingServerProps); + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); http.put.mockReturnValue(Promise.resolve(mappingServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); @@ -347,7 +384,7 @@ describe('RoleMappingsLogic', () => { }); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); expect(setSuccessMessage).toHaveBeenCalled(); }); @@ -383,6 +420,7 @@ describe('RoleMappingsLogic', () => { describe('handleDeleteMapping', () => { let confirmSpy: any; + const roleMappingId = 'r1'; beforeEach(() => { confirmSpy = jest.spyOn(window, 'confirm'); @@ -393,30 +431,26 @@ describe('RoleMappingsLogic', () => { confirmSpy.mockRestore(); }); - it('returns when no mapping', () => { - RoleMappingsLogic.actions.handleDeleteMapping(); - - expect(http.delete).not.toHaveBeenCalled(); - }); - - it('calls API and navigates', async () => { + it('calls API and refreshes list', async () => { mount(mappingServerProps); + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); http.delete.mockReturnValue(Promise.resolve({})); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); - expect(http.delete).toHaveBeenCalledWith( - `/api/app_search/role_mappings/${asRoleMapping.id}` - ); + expect(http.delete).toHaveBeenCalledWith(`/api/app_search/role_mappings/${roleMappingId}`); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles error', async () => { mount(mappingServerProps); http.delete.mockReturnValue(Promise.reject('this is an error')); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); @@ -425,7 +459,7 @@ describe('RoleMappingsLogic', () => { it('will do nothing if not confirmed', () => { mount(mappingServerProps); jest.spyOn(window, 'confirm').mockReturnValueOnce(false); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); expect(http.delete).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index 00b944d91cbcb..6981f48159a4e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -7,16 +7,17 @@ import { kea, MakeLogicType } from 'kea'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + import { clearFlashMessages, flashAPIErrors, setSuccessMessage, + setErrorMessage, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; -import { KibanaLogic } from '../../../shared/kibana'; -import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; import { AttributeName } from '../../../shared/types'; -import { ROLE_MAPPINGS_PATH } from '../../routes'; import { ASRoleMapping, RoleTypes } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; import { Engine } from '../engine/types'; @@ -49,28 +50,24 @@ const getFirstAttributeValue = (roleMapping: ASRoleMapping) => Object.entries(roleMapping.rules)[0][1] as AttributeName; interface RoleMappingsActions { - handleAccessAllEnginesChange(): void; + handleAccessAllEnginesChange(selected: boolean): { selected: boolean }; handleAuthProviderChange(value: string[]): { value: string[] }; handleAttributeSelectorChange( value: AttributeName, firstElasticsearchRole: string ): { value: AttributeName; firstElasticsearchRole: string }; handleAttributeValueChange(value: string): { value: string }; - handleDeleteMapping(): void; - handleEngineSelectionChange( - engineName: string, - selected: boolean - ): { - engineName: string; - selected: boolean; - }; + handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; + handleEngineSelectionChange(engineNames: string[]): { engineNames: string[] }; handleRoleChange(roleType: RoleTypes): { roleType: RoleTypes }; handleSaveMapping(): void; - initializeRoleMapping(roleId?: string): { roleId?: string }; + initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + openRoleMappingFlyout(): void; + closeRoleMappingFlyout(): void; } interface RoleMappingsValues { @@ -89,6 +86,8 @@ interface RoleMappingsValues { roleType: RoleTypes; selectedAuthProviders: string[]; selectedEngines: Set; + roleMappingFlyoutOpen: boolean; + selectedOptions: EuiComboBoxOptionOption[]; } export const RoleMappingsLogic = kea>({ @@ -98,21 +97,20 @@ export const RoleMappingsLogic = kea data, handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), - handleEngineSelectionChange: (engineName: string, selected: boolean) => ({ - engineName, - selected, - }), + handleEngineSelectionChange: (engineNames: string[]) => ({ engineNames }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, firstElasticsearchRole, }), handleAttributeValueChange: (value: string) => ({ value }), - handleAccessAllEnginesChange: true, + handleAccessAllEnginesChange: (selected: boolean) => ({ selected }), resetState: true, initializeRoleMappings: true, - initializeRoleMapping: (roleId) => ({ roleId }), - handleDeleteMapping: true, + initializeRoleMapping: (roleMappingId) => ({ roleMappingId }), + handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + openRoleMappingFlyout: true, + closeRoleMappingFlyout: false, }, reducers: { dataLoading: [ @@ -169,6 +167,7 @@ export const RoleMappingsLogic = kea roleMapping || null, resetState: () => null, + closeRoleMappingFlyout: () => null, }, ], roleType: [ @@ -185,7 +184,7 @@ export const RoleMappingsLogic = kea roleMapping ? roleMapping.accessAllEngines : true, handleRoleChange: (_, { roleType }) => !roleHasScopedEngines(roleType), - handleAccessAllEnginesChange: (accessAllEngines) => !accessAllEngines, + handleAccessAllEnginesChange: (_, { selected }) => selected, }, ], attributeValue: [ @@ -197,6 +196,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', + closeRoleMappingFlyout: () => '', }, ], attributeName: [ @@ -206,6 +206,7 @@ export const RoleMappingsLogic = kea value, resetState: () => 'username', + closeRoleMappingFlyout: () => 'username', }, ], selectedEngines: [ @@ -214,13 +215,9 @@ export const RoleMappingsLogic = kea roleMapping ? new Set(roleMapping.engines.map((engine) => engine.name)) : new Set(), handleAccessAllEnginesChange: () => new Set(), - handleEngineSelectionChange: (engines, { engineName, selected }) => { - const newSelectedEngineNames = new Set(engines as Set); - if (selected) { - newSelectedEngineNames.add(engineName); - } else { - newSelectedEngineNames.delete(engineName); - } + handleEngineSelectionChange: (_, { engineNames }) => { + const newSelectedEngineNames = new Set() as Set; + engineNames.forEach((engineName) => newSelectedEngineNames.add(engineName)); return newSelectedEngineNames; }, @@ -250,7 +247,27 @@ export const RoleMappingsLogic = kea true, + closeRoleMappingFlyout: () => false, + initializeRoleMappings: () => false, + initializeRoleMapping: () => true, + }, + ], }, + selectors: ({ selectors }) => ({ + selectedOptions: [ + () => [selectors.selectedEngines, selectors.availableEngines], + (selectedEngines, availableEngines) => { + const selectedNames = Array.from(selectedEngines.values()); + return availableEngines + .filter(({ name }: { name: string }) => selectedNames.includes(name)) + .map(({ name }: { name: string }) => ({ label: name, value: name })); + }, + ], + }), listeners: ({ actions, values }) => ({ initializeRoleMappings: async () => { const { http } = HttpLogic.values; @@ -263,33 +280,31 @@ export const RoleMappingsLogic = kea { + initializeRoleMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const route = roleId - ? `/api/app_search/role_mappings/${roleId}` + const route = roleMappingId + ? `/api/app_search/role_mappings/${roleMappingId}` : '/api/app_search/role_mappings/new'; try { const response = await http.get(route); actions.setRoleMappingData(response); } catch (e) { - navigateToUrl(ROLE_MAPPINGS_PATH); - flashAPIErrors(e); + if (e.status === 404) { + setErrorMessage(ROLE_MAPPING_NOT_FOUND); + } else { + flashAPIErrors(e); + } } }, - handleDeleteMapping: async () => { - const { roleMapping } = values; - if (!roleMapping) return; - + handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const route = `/api/app_search/role_mappings/${roleMapping.id}`; + const route = `/api/app_search/role_mappings/${roleMappingId}`; if (window.confirm(DELETE_ROLE_MAPPING_MESSAGE)) { try { await http.delete(route); - navigateToUrl(ROLE_MAPPINGS_PATH); + actions.initializeRoleMappings(); setSuccessMessage(ROLE_MAPPING_DELETED_MESSAGE); } catch (e) { flashAPIErrors(e); @@ -298,7 +313,6 @@ export const RoleMappingsLogic = kea { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; const { attributeName, @@ -330,7 +344,7 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, + closeRoleMappingFlyout: () => { + clearFlashMessages(); + }, + openRoleMappingFlyout: () => { + clearFlashMessages(); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx deleted file mode 100644 index e9fc40ba1dbb4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Route, Switch } from 'react-router-dom'; - -import { shallow } from 'enzyme'; - -import { RoleMapping } from './role_mapping'; -import { RoleMappings } from './role_mappings'; -import { RoleMappingsRouter } from './role_mappings_router'; - -describe('RoleMappingsRouter', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(3); - expect(wrapper.find(RoleMapping)).toHaveLength(2); - expect(wrapper.find(RoleMappings)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx deleted file mode 100644 index 7aa8b4067d9e5..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { Route, Switch } from 'react-router-dom'; - -import { ROLE_MAPPING_NEW_PATH, ROLE_MAPPING_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; - -import { RoleMapping } from './role_mapping'; -import { RoleMappings } from './role_mappings'; - -export const RoleMappingsRouter: React.FC = () => ( - - - - - - - - - - - -); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts deleted file mode 100644 index e72f2b90758ac..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { generateRoleMappingPath } from './utils'; - -describe('generateRoleMappingPath', () => { - it('generates paths with roleId filled', () => { - const roleId = 'role123'; - - expect(generateRoleMappingPath(roleId)).toEqual(`/role_mappings/${roleId}`); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 287d46c2dec75..08aab7af164e3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -26,8 +26,9 @@ import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; +import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; -import { RoleMappingsRouter } from './components/role_mappings'; +import { RoleMappings } from './components/role_mappings'; import { SetupGuide } from './components/setup_guide'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; @@ -105,13 +106,13 @@ describe('AppSearchConfigured', () => { it('renders RoleMappings when canViewRoleMappings is true', () => { setMockValues({ myRole: { canViewRoleMappings: true } }); rerender(wrapper); - expect(wrapper.find(RoleMappingsRouter)).toHaveLength(1); + expect(wrapper.find(RoleMappings)).toHaveLength(1); }); it('does not render RoleMappings when user canViewRoleMappings is false', () => { setMockValues({ myRole: { canManageEngines: false } }); rerender(wrapper); - expect(wrapper.find(RoleMappingsRouter)).toHaveLength(0); + expect(wrapper.find(RoleMappings)).toHaveLength(0); }); }); @@ -147,6 +148,28 @@ describe('AppSearchConfigured', () => { }); }); }); + + describe('library', () => { + it('renders a library page in development', () => { + const OLD_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + rerender(wrapper); + + expect(wrapper.find(Library)).toHaveLength(1); + process.env.NODE_ENV = OLD_ENV; + }); + + it("doesn't in production", () => { + const OLD_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + rerender(wrapper); + + expect(wrapper.find(Library)).toHaveLength(0); + process.env.NODE_ENV = OLD_ENV; + }); + }); }); describe('AppSearchNav', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 9b59e0e19a5da..a491efcb234dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -28,7 +28,7 @@ import { ErrorConnecting } from './components/error_connecting'; import { KibanaHeaderActions } from './components/layout/kibana_header_actions'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; -import { RoleMappingsRouter } from './components/role_mappings'; +import { RoleMappings } from './components/role_mappings'; import { Settings, SETTINGS_TITLE } from './components/settings'; import { SetupGuide } from './components/setup_guide'; import { @@ -112,7 +112,7 @@ export const AppSearchConfigured: React.FC> = (props) = {canViewRoleMappings && ( - + )} {canManageEngines && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 872db3e149b60..c8fb009fb31da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -16,8 +16,6 @@ export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; -export const ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/:roleId`; -export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = '/engine_creation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts index 8aa58d08b96dd..f125a9dd13aa5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts @@ -52,6 +52,6 @@ export interface ASRoleMapping extends RoleMapping { } export interface AdvanceRoleType { - type: RoleTypes; + id: RoleTypes; description: string; } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx deleted file mode 100644 index a02f6c43225c0..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiButtonTo } from '../react_router_helpers'; - -import { AddRoleMappingButton } from './add_role_mapping_button'; - -describe('AddRoleMappingButton', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiButtonTo)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx deleted file mode 100644 index 097302e0aa5f1..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiButtonTo } from '../react_router_helpers'; - -import { ADD_ROLE_MAPPING_BUTTON } from './constants'; - -interface Props { - path: string; -} - -export const AddRoleMappingButton: React.FC = ({ path }) => ( - - {ADD_ROLE_MAPPING_BUTTON} - -); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx index 504acf9ae1c6a..2258496464ef5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx @@ -114,6 +114,14 @@ describe('AttributeSelector', () => { expect(handleAuthProviderChange).toHaveBeenCalledWith(['kbn_saml']); }); + it('should call the "handleAuthProviderChange" prop with fallback when a value not present', () => { + const wrapper = shallow(); + const select = findAuthProvidersSelect(wrapper); + select.simulate('change', [{ label: 'kbn_saml' }]); + + expect(handleAuthProviderChange).toHaveBeenCalledWith(['']); + }); + it('should call the "handleAttributeSelectorChange" prop when a value is selected', () => { const wrapper = shallow(); const select = wrapper.find('[data-test-subj="ExternalAttributeSelect"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx index 0ee093ed934c9..bb8bf4ab1abf9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx @@ -11,13 +11,8 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, EuiFormRow, - EuiPanel, EuiSelect, - EuiSpacer, - EuiTitle, } from '@elastic/eui'; import { AttributeName, AttributeExamples } from '../types'; @@ -27,10 +22,6 @@ import { ANY_AUTH_PROVIDER_OPTION_LABEL, AUTH_ANY_PROVIDER_LABEL, AUTH_INDIVIDUAL_PROVIDER_LABEL, - ATTRIBUTE_SELECTOR_TITLE, - AUTH_PROVIDER_LABEL, - EXTERNAL_ATTRIBUTE_LABEL, - ATTRIBUTE_VALUE_LABEL, } from './constants'; interface Props { @@ -100,80 +91,65 @@ export const AttributeSelector: React.FC = ({ handleAuthProviderChange = () => null, }) => { return ( - - - {ATTRIBUTE_SELECTOR_TITLE} - - + {availableAuthProviders && multipleAuthProvidersConfig && ( - - - - { - handleAuthProviderChange(options.map((o) => (o as ChildOption).value)); - }} - fullWidth - isDisabled={disabled} - /> - - - - + + { + handleAuthProviderChange(options.map((o) => o.value || '')); + }} + fullWidth + isDisabled={disabled} + /> + )} - - - - ({ value: attribute, text: attribute }))} - onChange={(e) => { - handleAttributeSelectorChange(e.target.value, elasticsearchRoles[0]); - }} - fullWidth - disabled={disabled} - /> - - - - - {attributeName === 'role' ? ( - ({ - value: elasticsearchRole, - text: elasticsearchRole, - }))} - onChange={(e) => { - handleAttributeValueChange(e.target.value); - }} - fullWidth - disabled={disabled} - /> - ) : ( - { - handleAttributeValueChange(e.target.value); - }} - fullWidth - disabled={disabled} - /> - )} - - - - + + ({ value: attribute, text: attribute }))} + onChange={(e) => { + handleAttributeSelectorChange(e.target.value, elasticsearchRoles[0]); + }} + fullWidth + disabled={disabled} + /> + + + {attributeName === 'role' ? ( + ({ + value: elasticsearchRole, + text: elasticsearchRole, + }))} + onChange={(e) => { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + ) : ( + { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + )} + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index a172fbae18d8f..7c53e37437e84 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -132,3 +132,62 @@ export const ROLE_MAPPINGS_DESCRIPTION = i18n.translate( 'Define role mappings for elasticsearch-native and elasticsearch-saml authentication.', } ); + +export const ROLE_MAPPING_NOT_FOUND = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.notFoundMessage', + { + defaultMessage: 'No matching Role mapping found.', + } +); + +export const ROLE_MAPPING_FLYOUT_CREATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.flyoutCreateTitle', + { + defaultMessage: 'Create a role mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_UPDATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.flyoutUpdateTitle', + { + defaultMessage: 'Update role mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.flyoutDescription', + { + defaultMessage: 'Assign roles and permissions based on user attributes', + } +); + +export const ROLE_MAPPING_ADD_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.roleMappingAddButton', + { + defaultMessage: 'Add mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_CREATE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.roleMappingFlyoutCreateButton', + { + defaultMessage: 'Create mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_UPDATE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.roleMappingFlyoutUpdateButton', + { + defaultMessage: 'Update mapping', + } +); + +export const SAVE_ROLE_MAPPING = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel', + { defaultMessage: 'Save role mapping' } +); + +export const UPDATE_ROLE_MAPPING = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.updateRoleMappingButtonLabel', + { defaultMessage: 'Update role mapping' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx deleted file mode 100644 index c7556ee20e26a..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiButton, EuiCallOut } from '@elastic/eui'; - -import { DeleteMappingCallout } from './delete_mapping_callout'; - -describe('DeleteMappingCallout', () => { - const handleDeleteMapping = jest.fn(); - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - expect(wrapper.find(EuiButton).prop('onClick')).toEqual(handleDeleteMapping); - }); - - it('handles button click', () => { - const wrapper = shallow(); - wrapper.find(EuiButton).simulate('click'); - - expect(handleDeleteMapping).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx deleted file mode 100644 index cb3c27038c566..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiButton, EuiCallOut } from '@elastic/eui'; - -import { - DELETE_ROLE_MAPPING_TITLE, - DELETE_ROLE_MAPPING_DESCRIPTION, - DELETE_ROLE_MAPPING_BUTTON, -} from './constants'; - -interface Props { - handleDeleteMapping(): void; -} - -export const DeleteMappingCallout: React.FC = ({ handleDeleteMapping }) => ( - - {DELETE_ROLE_MAPPING_DESCRIPTION} - - {DELETE_ROLE_MAPPING_BUTTON} - - -); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts index e6320dbb7feef..6f67bc682f333 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts @@ -5,8 +5,8 @@ * 2.0. */ -export { AddRoleMappingButton } from './add_role_mapping_button'; export { AttributeSelector } from './attribute_selector'; -export { DeleteMappingCallout } from './delete_mapping_callout'; export { RoleMappingsTable } from './role_mappings_table'; +export { RoleOptionLabel } from './role_option_label'; export { RoleSelector } from './role_selector'; +export { RoleMappingFlyout } from './role_mapping_flyout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx new file mode 100644 index 0000000000000..c0973bb2c9504 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout } from '@elastic/eui'; + +import { + ROLE_MAPPING_FLYOUT_CREATE_TITLE, + ROLE_MAPPING_FLYOUT_UPDATE_TITLE, + ROLE_MAPPING_FLYOUT_CREATE_BUTTON, + ROLE_MAPPING_FLYOUT_UPDATE_BUTTON, +} from './constants'; +import { RoleMappingFlyout } from './role_mapping_flyout'; + +describe('RoleMappingFlyout', () => { + const closeRoleMappingFlyout = jest.fn(); + const handleSaveMapping = jest.fn(); + + const props = { + isNew: true, + disabled: false, + closeRoleMappingFlyout, + handleSaveMapping, + }; + + it('renders for new mapping', () => { + const wrapper = shallow( + + + + ); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="FlyoutTitle"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_CREATE_TITLE + ); + expect(wrapper.find('[data-test-subj="FlyoutButton"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_CREATE_BUTTON + ); + }); + + it('renders for existing mapping', () => { + const wrapper = shallow( + + + + ); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="FlyoutTitle"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_UPDATE_TITLE + ); + expect(wrapper.find('[data-test-subj="FlyoutButton"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_UPDATE_BUTTON + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx new file mode 100644 index 0000000000000..bae991fef3655 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiPortal, + EuiText, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +import { CANCEL_BUTTON_LABEL } from '../../shared/constants/actions'; + +import { + ROLE_MAPPING_FLYOUT_CREATE_TITLE, + ROLE_MAPPING_FLYOUT_UPDATE_TITLE, + ROLE_MAPPING_FLYOUT_DESCRIPTION, + ROLE_MAPPING_FLYOUT_CREATE_BUTTON, + ROLE_MAPPING_FLYOUT_UPDATE_BUTTON, +} from './constants'; + +interface Props { + children: React.ReactNode; + isNew: boolean; + disabled: boolean; + closeRoleMappingFlyout(): void; + handleSaveMapping(): void; +} + +export const RoleMappingFlyout: React.FC = ({ + children, + isNew, + disabled, + closeRoleMappingFlyout, + handleSaveMapping, +}) => ( + + + + + + {isNew ? ROLE_MAPPING_FLYOUT_CREATE_TITLE : ROLE_MAPPING_FLYOUT_UPDATE_TITLE} + + + + {ROLE_MAPPING_FLYOUT_DESCRIPTION} + + + + {children} + + + + + + {CANCEL_BUTTON_LABEL} + + + + {isNew ? ROLE_MAPPING_FLYOUT_CREATE_BUTTON : ROLE_MAPPING_FLYOUT_UPDATE_BUTTON} + + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index e1c43dca581fe..5ec84db478bc3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -18,7 +18,8 @@ import { ALL_LABEL, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; import { RoleMappingsTable } from './role_mappings_table'; describe('RoleMappingsTable', () => { - const getRoleMappingPath = jest.fn(); + const initializeRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); const roleMappings = [ { ...wsRoleMapping, @@ -36,7 +37,8 @@ describe('RoleMappingsTable', () => { roleMappings, addMappingButton: , shouldShowAuthProvider: true, - getRoleMappingPath, + initializeRoleMapping, + handleDeleteMapping, }; it('renders', () => { @@ -63,6 +65,20 @@ describe('RoleMappingsTable', () => { expect(wrapper.find(EuiTableRow)).toHaveLength(0); }); + it('handles manage click', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ManageButton"]').simulate('click'); + + expect(initializeRoleMapping).toHaveBeenCalled(); + }); + + it('handles delete click', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="DeleteButton"]').simulate('click'); + + expect(handleDeleteMapping).toHaveBeenCalled(); + }); + it('handles input change with special chars', () => { const wrapper = shallow(); const input = wrapper.find(EuiFieldSearch); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index a5f6fb368c96f..d5d6d8b9cd227 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -8,6 +8,7 @@ import React, { Fragment, useState } from 'react'; import { + EuiButtonIcon, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, @@ -25,8 +26,7 @@ import { i18n } from '@kbn/i18n'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; -import { MANAGE_BUTTON_LABEL } from '../constants'; -import { EuiLinkTo } from '../react_router_helpers'; +import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../constants'; import { RoleRules } from '../types'; import './role_mappings_table.scss'; @@ -57,7 +57,8 @@ interface Props { addMappingButton: React.ReactNode; accessAllEngines?: boolean; shouldShowAuthProvider?: boolean; - getRoleMappingPath(roleId: string): string; + initializeRoleMapping(roleMappingId: string): void; + handleDeleteMapping(roleMappingId: string): void; } const MAX_CELL_WIDTH = 24; @@ -72,8 +73,9 @@ export const RoleMappingsTable: React.FC = ({ accessHeader, roleMappings, addMappingButton, - getRoleMappingPath, shouldShowAuthProvider, + initializeRoleMapping, + handleDeleteMapping, }) => { const [filterValue, updateValue] = useState(''); @@ -96,6 +98,23 @@ export const RoleMappingsTable: React.FC = ({ const getFirstAttributeName = (rules: RoleRules): string => Object.entries(rules)[0][0]; const getFirstAttributeValue = (rules: RoleRules): string => Object.entries(rules)[0][1]; + const rowActions = (id: string) => ( + <> + initializeRoleMapping(id)} + iconType="pencil" + aria-label={MANAGE_BUTTON_LABEL} + data-test-subj="ManageButton" + />{' '} + handleDeleteMapping(id)} + iconType="trash" + aria-label={DELETE_BUTTON_LABEL} + data-test-subj="DeleteButton" + /> + > + ); + return ( <> @@ -155,7 +174,7 @@ export const RoleMappingsTable: React.FC = ({ )} - {id && {MANAGE_BUTTON_LABEL}} + {id && rowActions(id)} {toolTip && } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.test.tsx new file mode 100644 index 0000000000000..7a998f20e4d8d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiSpacer } from '@elastic/eui'; + +import { RoleOptionLabel } from './role_option_label'; + +describe('RoleOptionLabel', () => { + it('renders with capitalized label ', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiText)).toHaveLength(2); + expect(wrapper.find(EuiText).first().prop('children')).toBe('Foo'); + expect(wrapper.find(EuiSpacer)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.tsx new file mode 100644 index 0000000000000..69d29c59dcd66 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiSpacer, EuiText } from '@elastic/eui'; + +interface Props { + label: string; + description: string; +} + +export const RoleOptionLabel: React.FC = ({ label, description }) => ( + <> + {label.charAt(0).toUpperCase() + label.toLowerCase().slice(1)} + + + {description} + + + > +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.test.tsx index c34a8939801ea..3f3c4abb04470 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.test.tsx @@ -9,46 +9,38 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiRadio } from '@elastic/eui'; +import { EuiRadioGroup } from '@elastic/eui'; import { RoleSelector } from './role_selector'; describe('RoleSelector', () => { const onChange = jest.fn(); + const roleOptions = [ + { + id: 'user', + description: 'User', + }, + ]; const props = { disabled: false, - disabledText: 'Disabled', roleType: 'user', - roleTypeOption: 'option', - description: 'This a thing', + roleOptions, + label: 'This a thing', onChange, }; it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiRadio)).toHaveLength(1); + expect(wrapper.find(EuiRadioGroup)).toHaveLength(1); }); it('calls method on change', () => { const wrapper = shallow(); - const radio = wrapper.find(EuiRadio); + const radio = wrapper.find(EuiRadioGroup); radio.simulate('change', { target: { value: 'bar' } }); expect(onChange).toHaveBeenCalled(); }); - - it('renders callout when disabled', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiRadio).prop('checked')).toEqual(false); - }); - - it('sets checked attribute on radio when option matched type', () => { - const wrapper = shallow(); - const radio = wrapper.find(EuiRadio); - - expect(radio.prop('checked')).toEqual(true); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.tsx index 9eef1a4e5d303..82780010632fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_selector.tsx @@ -7,45 +7,43 @@ import React from 'react'; -import { startCase } from 'lodash'; +import { EuiFormRow, EuiRadioGroup } from '@elastic/eui'; -import { EuiFormRow, EuiRadio, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { RoleOptionLabel } from './role_option_label'; + +interface RoleOption { + id: string; + description: string; + disabled?: boolean; +} interface Props { disabled?: boolean; - disabledText?: string; roleType?: string; - roleTypeOption: string; - description: string; - onChange(roleTypeOption: string): void; + roleOptions: RoleOption[]; + label: string; + onChange(id: string): void; } -export const RoleSelector: React.FC = ({ - disabled, - roleType, - roleTypeOption, - description, - onChange, -}) => ( - - { - onChange(roleTypeOption); - }} - label={ - <> - - {startCase(roleTypeOption)} - - - - {description} - - > - } - /> - -); +export const RoleSelector: React.FC = ({ label, roleType, roleOptions, onChange }) => { + const options = roleOptions.map(({ id, description, disabled }) => ({ + id, + label: , + disabled, + })); + + return ( + + r.id === roleType)[0].id} + onChange={(id) => { + onChange(id); + }} + legend={{ + children: {label}, + }} + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 54085a9cd4467..ba5fb7c9d377d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -37,7 +37,7 @@ import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; import { Overview } from './views/overview'; -import { RoleMappingsRouter } from './views/role_mappings'; +import { RoleMappings } from './views/role_mappings'; import { Security } from './views/security'; import { SettingsRouter } from './views/settings'; import { SettingsSubNav } from './views/settings/components/settings_sub_nav'; @@ -123,7 +123,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { } restrictWidth readOnlyMode={readOnlyMode}> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index 7d3e19dfe626a..a5a3d6b491bb9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -16,12 +16,10 @@ import { getGroupPath, getGroupSourcePrioritizationPath, getReindexJobRoute, - getRoleMappingPath, getSourcesPath, GROUPS_PATH, SOURCES_PATH, PERSONAL_SOURCES_PATH, - ROLE_MAPPINGS_PATH, SOURCE_DETAILS_PATH, } from './routes'; @@ -52,12 +50,6 @@ describe('getGroupPath', () => { }); }); -describe('getRoleMappingPath', () => { - it('should format path', () => { - expect(getRoleMappingPath('123')).toEqual(`${ROLE_MAPPINGS_PATH}/123`); - }); -}); - describe('getGroupSourcePrioritizationPath', () => { it('should format path', () => { expect(getGroupSourcePrioritizationPath('123')).toEqual( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 0a6b6ef89b2a4..5e5d6e2c82b31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -50,8 +50,6 @@ export const ENT_SEARCH_LICENSE_MANAGEMENT = `${docLinks.enterpriseSearchBase}/l export const PERSONAL_PATH = '/p'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; -export const ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/:roleId`; -export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const USERS_PATH = '/users'; export const SECURITY_PATH = '/security'; @@ -135,4 +133,3 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); -export const getRoleMappingPath = (roleId: string) => generatePath(ROLE_MAPPING_PATH, { roleId }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts index b4355ba7aa586..62494b447efa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts @@ -59,13 +59,6 @@ export const USER_ROLE_TYPE_DESCRIPTION = i18n.translate( } ); -export const GROUP_ASSIGNMENT_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentTitle', - { - defaultMessage: 'Group assignment', - } -); - export const GROUP_ASSIGNMENT_INVALID_ERROR = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError', { @@ -73,10 +66,10 @@ export const GROUP_ASSIGNMENT_INVALID_ERROR = i18n.translate( } ); -export const GROUP_ASSIGNMENT_ALL_GROUPS_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentAllGroupsLabel', +export const GROUP_ASSIGNMENT_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentLabel', { - defaultMessage: 'Include in all groups, including future groups', + defaultMessage: 'Group assignment', } ); @@ -94,3 +87,32 @@ export const ROLE_MAPPINGS_TABLE_HEADER = i18n.translate( defaultMessage: 'Group Access', } ); + +export const ALL_GROUPS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMapping.allGroupsLabel', + { + defaultMessage: 'Assign to all groups', + } +); + +export const ALL_GROUPS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMapping.allGroupsDescription', + { + defaultMessage: + 'Assigning to all groups includes all current and future groups as created and administered at a later date.', + } +); + +export const SPECIFIC_GROUPS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMapping.specificGroupsLabel', + { + defaultMessage: 'Assign to specific groups', + } +); + +export const SPECIFIC_GROUPS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMapping.specificGroupsDescription', + { + defaultMessage: 'Assign to a select set of groups statically.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/index.ts index ce4b1de6e399d..19062cf44c17a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { RoleMappingsRouter } from './role_mappings_router'; +export { RoleMappings } from './role_mappings'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.test.tsx index 4742b741c9640..c42a195bffba8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.test.tsx @@ -10,16 +10,12 @@ import { setMockActions, setMockValues } from '../../../__mocks__'; import React from 'react'; +import { waitFor } from '@testing-library/dom'; import { shallow } from 'enzyme'; -import { EuiCheckbox } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiRadioGroup } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; -import { - AttributeSelector, - DeleteMappingCallout, - RoleSelector, -} from '../../../shared/role_mapping'; +import { AttributeSelector, RoleSelector } from '../../../shared/role_mapping'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; import { RoleMapping } from './role_mapping'; @@ -80,42 +76,37 @@ describe('RoleMapping', () => { }); it('renders', () => { + setMockValues({ ...mockValues, roleMapping: wsRoleMapping }); const wrapper = shallow(); expect(wrapper.find(AttributeSelector)).toHaveLength(1); - expect(wrapper.find(RoleSelector)).toHaveLength(2); + expect(wrapper.find(RoleSelector)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); + it('sets initial selected state when includeInAllGroups is true', () => { + setMockValues({ ...mockValues, includeInAllGroups: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.find(EuiRadioGroup).prop('idSelected')).toBe('all'); }); - it('hides DeleteMappingCallout for new mapping', () => { - const wrapper = shallow(); - - expect(wrapper.find(DeleteMappingCallout)).toHaveLength(0); - }); - - it('handles group checkbox click', () => { + it('handles all/specific groups radio change', () => { const wrapper = shallow(); - wrapper - .find(EuiCheckbox) - .first() - .simulate('change', { target: { checked: true } }); + const radio = wrapper.find(EuiRadioGroup); + radio.simulate('change', { target: { checked: false } }); - expect(handleGroupSelectionChange).toHaveBeenCalledWith(groups[0].id, true); + expect(handleAllGroupsSelectionChange).toHaveBeenCalledWith(false); }); - it('handles all groups checkbox click', () => { + it('handles group checkbox click', async () => { const wrapper = shallow(); - wrapper - .find(EuiCheckbox) - .last() - .simulate('change', { target: { checked: true } }); - - expect(handleAllGroupsSelectionChange).toHaveBeenCalledWith(true); + await waitFor(() => + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: groups[0].name, value: groups[0].name }]) + ); + wrapper.update(); + + expect(handleGroupSelectionChange).toHaveBeenCalledWith([groups[0].name]); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx index fb366883601a6..9df0c82312d2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx @@ -5,91 +5,78 @@ * 2.0. */ -import React, { useEffect } from 'react'; - -import { useParams } from 'react-router-dom'; +import React from 'react'; import { useActions, useValues } from 'kea'; -import { - EuiButton, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiPanel, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFormRow, EuiHorizontalRule, EuiRadioGroup, EuiSpacer } from '@elastic/eui'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { AttributeSelector, - DeleteMappingCallout, RoleSelector, + RoleOptionLabel, + RoleMappingFlyout, } from '../../../shared/role_mapping'; -import { - ROLE_LABEL, - ROLE_MAPPINGS_TITLE, - ADD_ROLE_MAPPING_TITLE, - MANAGE_ROLE_MAPPING_TITLE, -} from '../../../shared/role_mapping/constants'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; + import { Role } from '../../types'; import { ADMIN_ROLE_TYPE_DESCRIPTION, USER_ROLE_TYPE_DESCRIPTION, - GROUP_ASSIGNMENT_TITLE, GROUP_ASSIGNMENT_INVALID_ERROR, - GROUP_ASSIGNMENT_ALL_GROUPS_LABEL, + GROUP_ASSIGNMENT_LABEL, + ALL_GROUPS_LABEL, + ALL_GROUPS_DESCRIPTION, + SPECIFIC_GROUPS_LABEL, + SPECIFIC_GROUPS_DESCRIPTION, } from './constants'; import { RoleMappingsLogic } from './role_mappings_logic'; interface RoleType { - type: Role; + id: Role; description: string; } -const roleTypes = [ +const roleOptions = [ { - type: 'admin', + id: 'admin', description: ADMIN_ROLE_TYPE_DESCRIPTION, }, { - type: 'user', + id: 'user', description: USER_ROLE_TYPE_DESCRIPTION, }, ] as RoleType[]; -interface RoleMappingProps { - isNew?: boolean; -} +const groupOptions = [ + { + id: 'all', + label: , + }, + { + id: 'specific', + label: ( + + ), + }, +]; -export const RoleMapping: React.FC = ({ isNew }) => { - const { roleId } = useParams() as { roleId: string }; +export const RoleMapping: React.FC = () => { const { - initializeRoleMappings, - initializeRoleMapping, handleSaveMapping, handleGroupSelectionChange, handleAllGroupsSelectionChange, handleAttributeValueChange, handleAttributeSelectorChange, - handleDeleteMapping, handleRoleChange, handleAuthProviderChange, - resetState, + closeRoleMappingFlyout, } = useActions(RoleMappingsLogic); const { attributes, elasticsearchRoles, - dataLoading, roleType, attributeValue, attributeName, @@ -99,117 +86,64 @@ export const RoleMapping: React.FC = ({ isNew }) => { availableAuthProviders, multipleAuthProvidersConfig, selectedAuthProviders, + selectedOptions, + roleMapping, } = useValues(RoleMappingsLogic); - useEffect(() => { - initializeRoleMappings(); - initializeRoleMapping(roleId); - return resetState; - }, [roleId]); - - if (dataLoading) return ; + const isNew = !roleMapping; const hasGroupAssignment = selectedGroups.size > 0 || includeInAllGroups; - const TITLE = isNew ? ADD_ROLE_MAPPING_TITLE : MANAGE_ROLE_MAPPING_TITLE; - const SAVE_ROLE_MAPPING_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.roleMapping.saveRoleMappingButtonMessage', - { - defaultMessage: '{operation} role mapping', - values: { operation: isNew ? 'Save' : 'Update' }, - } - ); - - const saveRoleMappingButton = ( - - {SAVE_ROLE_MAPPING_LABEL} - - ); - return ( - <> - - - - - - + + + + + + handleAllGroupsSelectionChange(id === 'all')} + legend={{ + children: {GROUP_ASSIGNMENT_LABEL}, + }} + /> + + + ({ label: name, value: id }))} + onChange={(options) => { + handleGroupSelectionChange(options.map(({ value }) => value as string)); + }} + fullWidth + isDisabled={includeInAllGroups} /> - - - - - - {ROLE_LABEL} - - - {roleTypes.map(({ type, description }) => ( - - ))} - - - - - - {GROUP_ASSIGNMENT_TITLE} - - - - - <> - {availableGroups.map(({ id, name }) => ( - { - handleGroupSelectionChange(id, e.target.checked); - }} - label={name} - disabled={includeInAllGroups} - /> - ))} - - { - handleAllGroupsSelectionChange(e.target.checked); - }} - label={GROUP_ASSIGNMENT_ALL_GROUPS_LABEL} - /> - > - - - - - - - {!isNew && } - - > + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx index c6da903e20912..d7be753eec173 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx @@ -12,16 +12,19 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { Loading } from '../../../shared/loading'; import { RoleMappingsTable } from '../../../shared/role_mapping'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); + const initializeRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); const mockValues = { roleMappings: [wsRoleMapping], dataLoading: false, @@ -31,6 +34,8 @@ describe('RoleMappings', () => { beforeEach(() => { setMockActions({ initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, }); setMockValues(mockValues); }); @@ -54,4 +59,19 @@ describe('RoleMappings', () => { expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); + + it('renders RoleMapping flyout', () => { + setMockValues({ ...mockValues, roleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(RoleMapping)).toHaveLength(1); + }); + + it('handles button click', () => { + setMockValues({ ...mockValues, roleMappings: [] }); + const wrapper = shallow(); + wrapper.find(EuiEmptyPrompt).dive().find(EuiButton).simulate('click'); + + expect(initializeRoleMapping).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 9ec0dfc0acefc..e5a9a31d463b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -9,28 +9,36 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; -import { AddRoleMappingButton, RoleMappingsTable } from '../../../shared/role_mapping'; +import { RoleMappingsTable } from '../../../shared/role_mapping'; import { EMPTY_ROLE_MAPPINGS_TITLE, + ROLE_MAPPING_ADD_BUTTON, ROLE_MAPPINGS_TITLE, ROLE_MAPPINGS_DESCRIPTION, } from '../../../shared/role_mapping/constants'; import { ViewContentHeader } from '../../components/shared/view_content_header'; -import { getRoleMappingPath, ROLE_MAPPING_NEW_PATH } from '../../routes'; import { EMPTY_ROLE_MAPPINGS_BODY, ROLE_MAPPINGS_TABLE_HEADER } from './constants'; +import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; export const RoleMappings: React.FC = () => { - const { initializeRoleMappings } = useActions(RoleMappingsLogic); + const { initializeRoleMappings, initializeRoleMapping, handleDeleteMapping } = useActions( + RoleMappingsLogic + ); - const { roleMappings, dataLoading, multipleAuthProvidersConfig } = useValues(RoleMappingsLogic); + const { + roleMappings, + dataLoading, + multipleAuthProvidersConfig, + roleMappingFlyoutOpen, + } = useValues(RoleMappingsLogic); useEffect(() => { initializeRoleMappings(); @@ -38,7 +46,11 @@ export const RoleMappings: React.FC = () => { if (dataLoading) return ; - const addMappingButton = ; + const addMappingButton = ( + initializeRoleMapping()}> + {ROLE_MAPPING_ADD_BUTTON} + + ); const emptyPrompt = ( { accessItemKey="groups" accessHeader={ROLE_MAPPINGS_TABLE_HEADER} addMappingButton={addMappingButton} - getRoleMappingPath={getRoleMappingPath} shouldShowAuthProvider={multipleAuthProvidersConfig} + initializeRoleMapping={initializeRoleMapping} + handleDeleteMapping={handleDeleteMapping} /> ); @@ -64,6 +77,8 @@ export const RoleMappings: React.FC = () => { <> + + {roleMappingFlyoutOpen && } {roleMappings.length === 0 ? emptyPrompt : roleMappingsTable} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts index 3c9cf01bbb4a9..f45491334567f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; import { LogicMounter } from '../../../__mocks__/kea.mock'; import { groups } from '../../__mocks__/groups.mock'; @@ -13,20 +13,20 @@ import { groups } from '../../__mocks__/groups.mock'; import { nextTick } from '@kbn/test/jest'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; -import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; - const { navigateToUrl } = mockKibanaValues; - const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers; + const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; const { mount } = new LogicMounter(RoleMappingsLogic); const defaultValues = { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], roleMapping: null, + roleMappingFlyoutOpen: false, roleMappings: [], roleType: 'admin', attributeValue: '', @@ -37,6 +37,7 @@ describe('RoleMappingsLogic', () => { selectedGroups: new Set(), includeInAllGroups: false, selectedAuthProviders: [ANY_AUTH_PROVIDER], + selectedOptions: [], }; const roleGroup = { id: '123', @@ -92,6 +93,7 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.selectedGroups).toEqual( new Set([wsRoleMapping.groups[0].id]) ); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([]); }); it('sets default group with new role mapping', () => { @@ -121,10 +123,13 @@ describe('RoleMappingsLogic', () => { }, }); - RoleMappingsLogic.actions.handleGroupSelectionChange(otherGroup.id, true); + RoleMappingsLogic.actions.handleGroupSelectionChange([group.id, otherGroup.id]); expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([group.id, otherGroup.id])); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([ + { label: roleGroup.name, value: roleGroup.id }, + ]); - RoleMappingsLogic.actions.handleGroupSelectionChange(otherGroup.id, false); + RoleMappingsLogic.actions.handleGroupSelectionChange([group.id]); expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([group.id])); }); @@ -223,6 +228,25 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.attributeName).toEqual('username'); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('openRoleMappingFlyout', () => { + mount(mappingServerProps); + RoleMappingsLogic.actions.openRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeRoleMappingFlyout', () => { + mount({ + ...mappingServerProps, + roleMappingFlyoutOpen: true, + }); + RoleMappingsLogic.actions.closeRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); + expect(clearFlashMessages).toHaveBeenCalled(); + }); }); describe('listeners', () => { @@ -275,17 +299,21 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - it('redirects when there is a 404 status', async () => { + it('shows error when there is a 404 status', async () => { http.get.mockReturnValue(Promise.reject({ status: 404 })); RoleMappingsLogic.actions.initializeRoleMapping(); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(setErrorMessage).toHaveBeenCalledWith(ROLE_MAPPING_NOT_FOUND); }); }); describe('handleSaveMapping', () => { - it('calls API and navigates when new mapping', async () => { + it('calls API and refreshes list when new mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); http.post.mockReturnValue(Promise.resolve(mappingServerProps)); @@ -304,10 +332,14 @@ describe('RoleMappingsLogic', () => { }); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); }); - it('calls API and navigates when existing mapping', async () => { + it('calls API and refreshes list when existing mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); http.put.mockReturnValue(Promise.resolve(mappingServerProps)); @@ -329,7 +361,7 @@ describe('RoleMappingsLogic', () => { ); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); }); it('handles error', async () => { @@ -343,6 +375,7 @@ describe('RoleMappingsLogic', () => { describe('handleDeleteMapping', () => { let confirmSpy: any; + const roleMappingId = 'r1'; beforeEach(() => { confirmSpy = jest.spyOn(window, 'confirm'); @@ -353,29 +386,27 @@ describe('RoleMappingsLogic', () => { confirmSpy.mockRestore(); }); - it('returns when no mapping', () => { - RoleMappingsLogic.actions.handleDeleteMapping(); - - expect(http.delete).not.toHaveBeenCalled(); - }); - - it('calls API and navigates', async () => { + it('calls API and refreshes list', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); http.delete.mockReturnValue(Promise.resolve({})); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); expect(http.delete).toHaveBeenCalledWith( - `/api/workplace_search/org/role_mappings/${wsRoleMapping.id}` + `/api/workplace_search/org/role_mappings/${roleMappingId}` ); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); }); it('handles error', async () => { RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); http.delete.mockReturnValue(Promise.reject('this is an error')); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); @@ -384,7 +415,7 @@ describe('RoleMappingsLogic', () => { it('will do nothing if not confirmed', async () => { RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); window.confirm = () => false; - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); expect(http.delete).not.toHaveBeenCalled(); await nextTick(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index 0df7f0ba37569..85f3bb49ee508 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -7,16 +7,17 @@ import { kea, MakeLogicType } from 'kea'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + import { clearFlashMessages, flashAPIErrors, setSuccessMessage, + setErrorMessage, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; -import { KibanaLogic } from '../../../shared/kibana'; -import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; import { AttributeName } from '../../../shared/types'; -import { ROLE_MAPPINGS_PATH } from '../../routes'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; import { @@ -54,18 +55,17 @@ interface RoleMappingsActions { firstElasticsearchRole: string ): { value: AttributeName; firstElasticsearchRole: string }; handleAttributeValueChange(value: string): { value: string }; - handleDeleteMapping(): void; - handleGroupSelectionChange( - groupId: string, - selected: boolean - ): { groupId: string; selected: boolean }; + handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; + handleGroupSelectionChange(groupIds: string[]): { groupIds: string[] }; handleRoleChange(roleType: Role): { roleType: Role }; handleSaveMapping(): void; - initializeRoleMapping(roleId?: string): { roleId?: string }; + initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + openRoleMappingFlyout(): void; + closeRoleMappingFlyout(): void; } interface RoleMappingsValues { @@ -83,6 +83,8 @@ interface RoleMappingsValues { roleType: Role; selectedAuthProviders: string[]; selectedGroups: Set; + roleMappingFlyoutOpen: boolean; + selectedOptions: EuiComboBoxOptionOption[]; } export const RoleMappingsLogic = kea>({ @@ -92,7 +94,7 @@ export const RoleMappingsLogic = kea data, handleAuthProviderChange: (value: string[]) => ({ value }), handleRoleChange: (roleType: Role) => ({ roleType }), - handleGroupSelectionChange: (groupId: string, selected: boolean) => ({ groupId, selected }), + handleGroupSelectionChange: (groupIds: string[]) => ({ groupIds }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, firstElasticsearchRole, @@ -101,9 +103,11 @@ export const RoleMappingsLogic = kea ({ selected }), resetState: true, initializeRoleMappings: true, - initializeRoleMapping: (roleId?: string) => ({ roleId }), - handleDeleteMapping: true, + initializeRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), + handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + openRoleMappingFlyout: true, + closeRoleMappingFlyout: false, }, reducers: { dataLoading: [ @@ -152,6 +156,7 @@ export const RoleMappingsLogic = kea roleMapping || null, resetState: () => null, + closeRoleMappingFlyout: () => null, }, ], roleType: [ @@ -178,6 +183,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', + closeRoleMappingFlyout: () => '', }, ], attributeName: [ @@ -187,6 +193,7 @@ export const RoleMappingsLogic = kea value, resetState: () => 'username', + closeRoleMappingFlyout: () => 'username', }, ], selectedGroups: [ @@ -200,13 +207,10 @@ export const RoleMappingsLogic = kea group.name === DEFAULT_GROUP_NAME) .map((group) => group.id) ), - handleGroupSelectionChange: (groups, { groupId, selected }) => { - const newSelectedGroupNames = new Set(groups as Set); - if (selected) { - newSelectedGroupNames.add(groupId); - } else { - newSelectedGroupNames.delete(groupId); - } + handleGroupSelectionChange: (_, { groupIds }) => { + const newSelectedGroupNames = new Set() as Set; + groupIds.forEach((groupId) => newSelectedGroupNames.add(groupId)); + return newSelectedGroupNames; }, }, @@ -234,7 +238,27 @@ export const RoleMappingsLogic = kea true, + closeRoleMappingFlyout: () => false, + initializeRoleMappings: () => false, + initializeRoleMapping: () => true, + }, + ], }, + selectors: ({ selectors }) => ({ + selectedOptions: [ + () => [selectors.selectedGroups, selectors.availableGroups], + (selectedGroups, availableGroups) => { + const selectedIds = Array.from(selectedGroups.values()); + return availableGroups + .filter(({ id }: { id: string }) => selectedIds.includes(id)) + .map(({ id, name }: { id: string; name: string }) => ({ label: name, value: id })); + }, + ], + }), listeners: ({ actions, values }) => ({ initializeRoleMappings: async () => { const { http } = HttpLogic.values; @@ -247,11 +271,10 @@ export const RoleMappingsLogic = kea { + initializeRoleMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const route = roleId - ? `/api/workplace_search/org/role_mappings/${roleId}` + const route = roleMappingId + ? `/api/workplace_search/org/role_mappings/${roleMappingId}` : '/api/workplace_search/org/role_mappings/new'; try { @@ -259,23 +282,20 @@ export const RoleMappingsLogic = kea { - const { roleMapping } = values; - if (!roleMapping) return; - + handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const route = `/api/workplace_search/org/role_mappings/${roleMapping.id}`; + const route = `/api/workplace_search/org/role_mappings/${roleMappingId}`; if (window.confirm(DELETE_ROLE_MAPPING_MESSAGE)) { try { await http.delete(route); - navigateToUrl(ROLE_MAPPINGS_PATH); + actions.initializeRoleMappings(); setSuccessMessage(ROLE_MAPPING_DELETED_MESSAGE); } catch (e) { flashAPIErrors(e); @@ -284,7 +304,6 @@ export const RoleMappingsLogic = kea { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; const { attributeName, attributeValue, @@ -315,7 +334,7 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, + closeRoleMappingFlyout: () => { + clearFlashMessages(); + }, + openRoleMappingFlyout: () => { + clearFlashMessages(); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx deleted file mode 100644 index e9fc40ba1dbb4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Route, Switch } from 'react-router-dom'; - -import { shallow } from 'enzyme'; - -import { RoleMapping } from './role_mapping'; -import { RoleMappings } from './role_mappings'; -import { RoleMappingsRouter } from './role_mappings_router'; - -describe('RoleMappingsRouter', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(3); - expect(wrapper.find(RoleMapping)).toHaveLength(2); - expect(wrapper.find(RoleMappings)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx deleted file mode 100644 index fa5ab12c8afc0..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { Route, Switch } from 'react-router-dom'; - -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { NAV } from '../../constants'; -import { ROLE_MAPPING_NEW_PATH, ROLE_MAPPING_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; - -import { RoleMapping } from './role_mapping'; -import { RoleMappings } from './role_mappings'; - -export const RoleMappingsRouter: React.FC = () => ( - <> - - - - - - - - - - - - - > -); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts new file mode 100644 index 0000000000000..626a107b6942b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__'; + +import { registerCrawlerRoutes } from './crawler'; + +describe('crawler routes', () => { + describe('GET /api/app_search/engines/{name}/crawler', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{name}/crawler', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:name/crawler', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts new file mode 100644 index 0000000000000..15b8340b07d4e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCrawlerRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{name}/crawler', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:name/crawler', + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 6ccdce0935d93..2442b61c632c1 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; import { registerApiLogsRoutes } from './api_logs'; +import { registerCrawlerRoutes } from './crawler'; import { registerCredentialsRoutes } from './credentials'; import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; @@ -42,4 +43,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerResultSettingsRoutes(dependencies); registerApiLogsRoutes(dependencies); registerOnboardingRoutes(dependencies); + registerCrawlerRoutes(dependencies); }; diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index f2515d0a6a8fb..da04db1086aa8 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -275,6 +275,10 @@ "type": { "type": "keyword", "ignore_above": 1024 + }, + "type_id": { + "type": "keyword", + "ignore_above": 1024 } } } diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 31d8b7201cfc6..a13b304e8adab 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -116,6 +116,7 @@ export const EventSchema = schema.maybe( namespace: ecsString(), id: ecsString(), type: ecsString(), + type_id: ecsString(), }) ) ), diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index a7e5f4ae6cb1e..f2020e76b46ba 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -60,6 +60,10 @@ exports.EcsCustomPropertyMappings = { type: 'keyword', ignore_above: 1024, }, + type_id: { + type: 'keyword', + ignore_above: 1024, + }, }, }, }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts index 601d54ec56c46..7b3ddaada8001 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts @@ -11,6 +11,8 @@ import type { GetOneEnrollmentAPIKeyResponse, GetEnrollmentAPIKeysResponse, GetEnrollmentAPIKeysRequest, + PostEnrollmentAPIKeyRequest, + PostEnrollmentAPIKeyResponse, } from '../../types'; import { useRequest, sendRequest, useConditionalRequest } from './use_request'; @@ -65,3 +67,11 @@ export function useGetEnrollmentAPIKeys( ...options, }); } + +export function sendCreateEnrollmentAPIKey(body: PostEnrollmentAPIKeyRequest['body']) { + return sendRequest({ + method: 'post', + path: enrollmentAPIKeyRouteService.getCreatePath(), + body, + }); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx index bcedb23b32d5d..4edc1121b1091 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -8,21 +8,25 @@ import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButton, EuiCallOut, EuiSelect, EuiSpacer, EuiText } from '@elastic/eui'; import { SO_SEARCH_LIMIT } from '../../../../constants'; import type { AgentPolicy, GetEnrollmentAPIKeysResponse } from '../../../../types'; -import { sendGetEnrollmentAPIKeys, useStartServices } from '../../../../hooks'; +import { + sendGetEnrollmentAPIKeys, + useStartServices, + sendCreateEnrollmentAPIKey, +} from '../../../../hooks'; import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; type Props = { agentPolicies?: AgentPolicy[]; - onAgentPolicyChange?: (key: string) => void; + onAgentPolicyChange?: (key?: string) => void; excludeFleetServer?: boolean; } & ( | { withKeySelection: true; - onKeyChange?: (key: string) => void; + onKeyChange?: (key?: string) => void; } | { withKeySelection: false; @@ -38,6 +42,8 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( [] ); + const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false); + const [selectedState, setSelectedState] = useState<{ agentPolicyId?: string; enrollmentAPIKeyId?: string; @@ -45,7 +51,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { useEffect( function triggerOnAgentPolicyChangeEffect() { - if (onAgentPolicyChange && selectedState.agentPolicyId) { + if (onAgentPolicyChange) { onAgentPolicyChange(selectedState.agentPolicyId); } }, @@ -58,7 +64,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { return; } - if (selectedState.enrollmentAPIKeyId) { + if (onKeyChange) { onKeyChange(selectedState.enrollmentAPIKeyId); } }, @@ -94,6 +100,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { return; } if (!selectedState.agentPolicyId) { + setIsAuthenticationSettingsOpen(true); setEnrollmentAPIKeys([]); return; } @@ -204,28 +211,89 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { {isAuthenticationSettingsOpen && ( <> - ({ - value: key.id, - text: key.name, - }))} - value={selectedState.enrollmentAPIKeyId || undefined} - prepend={ - + {enrollmentAPIKeys.length && selectedState.enrollmentAPIKeyId ? ( + ({ + value: key.id, + text: key.name, + }))} + value={selectedState.enrollmentAPIKeyId || undefined} + prepend={ + + + + } + onChange={(e) => { + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: e.target.value, + }); + }} + /> + ) : ( + + + + + + { + setIsLoadingEnrollmentKey(true); + if (selectedState.agentPolicyId) { + sendCreateEnrollmentAPIKey({ policy_id: selectedState.agentPolicyId }) + .then((res) => { + if (res.error) { + throw res.error; + } + setIsLoadingEnrollmentKey(false); + if (res.data?.item) { + setEnrollmentAPIKeys([res.data.item]); + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: res.data.item.id, + }); + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { + defaultMessage: 'Enrollment token created', + }) + ); + } + }) + .catch((error) => { + setIsLoadingEnrollmentKey(false); + notifications.toasts.addError(error, { + title: 'Error', + }); + }); + } + }} + > - - } - onChange={(e) => { - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: e.target.value, - }); - }} - /> + + + )} > )} > diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 0158af2d78470..df1630abfab47 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -18,6 +18,8 @@ import { useLink, useFleetStatus, } from '../../../../hooks'; +import { NewEnrollmentTokenModal } from '../../enrollment_token_list_page/components/new_enrollment_key_modal'; + import { ManualInstructions } from '../../../../components/enrollment_instructions'; import { FleetServerRequirementPage, @@ -99,7 +101,7 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { defaultMessage: 'Enroll and start the Elastic Agent', }), - children: apiKey.data && ( + children: selectedAPIKeyId && apiKey.data && ( ), }); @@ -107,12 +109,18 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { return baseSteps; }, [ agentPolicies, + selectedAPIKeyId, apiKey.data, isFleetServerPolicySelected, settings.data?.item?.fleet_server_hosts, fleetServerInstructions, ]); + const [isModalOpen, setModalOpen] = useState(false); + const closeModal = () => { + setModalOpen(false); + }; + return ( <> {fleetStatus.isReady ? ( @@ -125,6 +133,10 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { + + {isModalOpen && ( + + )} > ) : fleetStatus.missingRequirements?.length === 1 && fleetStatus.missingRequirements[0] === 'fleet_server' ? ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx index 6a446e888a19f..8ba0098b3d277 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx @@ -54,8 +54,8 @@ export const AgentPolicySelectionStep = ({ excludeFleetServer, }: { agentPolicies?: AgentPolicy[]; - setSelectedAPIKeyId?: (key: string) => void; - setSelectedPolicyId?: (policyId: string) => void; + setSelectedAPIKeyId?: (key?: string) => void; + setSelectedPolicyId?: (policyId?: string) => void; setIsFleetServerPolicySelected?: (selected: boolean) => void; excludeFleetServer?: boolean; }) => { @@ -67,11 +67,11 @@ export const AgentPolicySelectionStep = ({ : []; const onAgentPolicyChange = useCallback( - async (policyId: string) => { + async (policyId?: string) => { if (setSelectedPolicyId) { setSelectedPolicyId(policyId); } - if (setIsFleetServerPolicySelected) { + if (policyId && setIsFleetServerPolicySelected) { const agentPolicyRequest = await sendGetOneAgentPolicy(policyId); if ( agentPolicyRequest.data?.item && diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx similarity index 50% rename from x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx index 7fae295d0d5b4..29e130f5583ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx @@ -7,32 +7,16 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiFlyoutFooter, - EuiForm, - EuiFormRow, - EuiFieldText, - EuiSelect, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiConfirmModal, EuiForm, EuiFormRow, EuiFieldText, EuiSelect } from '@elastic/eui'; -import type { AgentPolicy } from '../../../../types'; -import { useInput, useStartServices, sendRequest } from '../../../../hooks'; -import { enrollmentAPIKeyRouteService } from '../../../../services'; +import type { AgentPolicy, EnrollmentAPIKey } from '../../../../types'; +import { useInput, useStartServices, sendCreateEnrollmentAPIKey } from '../../../../hooks'; function useCreateApiKeyForm( policyIdDefaultValue: string | undefined, - onSuccess: (keyId: string) => void + onSuccess: (key: EnrollmentAPIKey) => void, + onError: (error: Error) => void ) { - const { notifications } = useStartServices(); const [isLoading, setIsLoading] = useState(false); const apiKeyNameInput = useInput(''); const policyIdInput = useInput(policyIdDefaultValue); @@ -41,31 +25,23 @@ function useCreateApiKeyForm( event.preventDefault(); setIsLoading(true); try { - const res = await sendRequest({ - method: 'post', - path: enrollmentAPIKeyRouteService.getCreatePath(), - body: JSON.stringify({ - name: apiKeyNameInput.value, - policy_id: policyIdInput.value, - }), + const res = await sendCreateEnrollmentAPIKey({ + name: apiKeyNameInput.value, + policy_id: policyIdInput.value, }); + if (res.error) { throw res.error; } policyIdInput.clear(); apiKeyNameInput.clear(); setIsLoading(false); - onSuccess(res.data.item.id); - notifications.toasts.addSuccess( - i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { - defaultMessage: 'Enrollment token created.', - }) - ); - } catch (err) { - notifications.toasts.addError(err as Error, { - title: 'Error', - }); + if (res.data?.item) { + onSuccess(res.data.item); + } + } catch (error) { setIsLoading(false); + onError(error); } }; @@ -78,18 +54,32 @@ function useCreateApiKeyForm( } interface Props { - onClose: () => void; - agentPolicies: AgentPolicy[]; + onClose: (key?: EnrollmentAPIKey) => void; + agentPolicies?: AgentPolicy[]; } -export const NewEnrollmentTokenFlyout: React.FunctionComponent = ({ +export const NewEnrollmentTokenModal: React.FunctionComponent = ({ onClose, agentPolicies = [], }) => { + const { notifications } = useStartServices(); const policyIdDefaultValue = agentPolicies.find((agentPolicy) => agentPolicy.is_default)?.id; - const form = useCreateApiKeyForm(policyIdDefaultValue, () => { - onClose(); - }); + const form = useCreateApiKeyForm( + policyIdDefaultValue, + (key: EnrollmentAPIKey) => { + onClose(key); + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { + defaultMessage: 'Enrollment token created', + }) + ); + }, + (error: Error) => { + notifications.toasts.addError(error, { + title: 'Error', + }); + } + ); const body = ( @@ -124,41 +114,26 @@ export const NewEnrollmentTokenFlyout: React.FunctionComponent = ({ }))} /> - - - ); return ( - - - - - - - - - {body} - - - - - - - - - - + onClose()} + cancelButtonText={i18n.translate('xpack.fleet.newEnrollmentKey.cancelButtonLabel', { + defaultMessage: 'Cancel', + })} + onConfirm={form.onSubmit} + confirmButtonText={i18n.translate('xpack.fleet.newEnrollmentKey.submitButton', { + defaultMessage: 'Create enrollment token', + })} + > + {body} + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx index 6d141b0c9ebf1..66e0c338dbbbc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx @@ -34,7 +34,7 @@ import { import type { EnrollmentAPIKey, GetAgentPoliciesResponseItem } from '../../../types'; import { SearchBar } from '../../../components/search_bar'; -import { NewEnrollmentTokenFlyout } from './components/new_enrollment_key_flyout'; +import { NewEnrollmentTokenModal } from './components/new_enrollment_key_modal'; import { ConfirmEnrollmentTokenDelete } from './components/confirm_delete_modal'; const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => { @@ -156,7 +156,7 @@ const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh: export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { useBreadcrumbs('fleet_enrollment_tokens'); - const [flyoutOpen, setFlyoutOpen] = useState(false); + const [isModalOpen, setModalOpen] = useState(false); const [search, setSearch] = useState(''); const { pagination, setPagination, pageSizeOptions } = usePagination(); @@ -270,11 +270,11 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { return ( <> - {flyoutOpen && ( - { - setFlyoutOpen(false); + onClose={(key?: EnrollmentAPIKey) => { + setModalOpen(false); enrollmentAPIKeysRequest.resendRequest(); }} /> @@ -301,7 +301,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { /> - setFlyoutOpen(true)}> + setModalOpen(true)}> ) => ( + + {title ? {title} : null} + + + +); diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index fb3ba62405904..b0ecc412c357f 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -18,6 +18,7 @@ export * from './datatable_visualization/datatable_visualization'; export * from './metric_visualization/metric_visualization'; export * from './pie_visualization/pie_visualization'; export * from './xy_visualization/xy_visualization'; +export * from './heatmap_visualization/heatmap_visualization'; export * from './indexpattern_datasource/indexpattern'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index 9f4b60b6d3c67..3fafa8b37a42f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -11,6 +11,10 @@ color: $euiTextSubduedColor; } +.lnsChartSwitch__append { + display: inline-flex; +} + // Targeting img as this won't target normal EuiIcon's only the custom svgs's img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying-type // The large icons aren't square so max out the width to fill the height @@ -19,4 +23,4 @@ img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying .lnsChartSwitch__search { width: 7 * $euiSizeXXL; -} \ No newline at end of file +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 5538dd26d0323..ba0e09bdd894c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -16,6 +16,7 @@ import { EuiSelectable, EuiIconTip, EuiSelectableOption, + EuiBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -286,6 +287,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { .map( (v): SelectableEntry => ({ 'aria-label': v.fullLabel || v.label, + className: 'lnsChartSwitch__option', isGroupLabel: false, key: `${v.visualizationId}:${v.id}`, value: `${v.visualizationId}:${v.id}`, @@ -295,22 +297,45 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ), append: - v.selection.dataLoss !== 'nothing' ? ( - + v.selection.dataLoss !== 'nothing' || v.showBetaBadge ? ( + + {v.selection.dataLoss !== 'nothing' ? ( + + + + ) : null} + {v.showBetaBadge ? ( + + + + + + ) : null} + ) : null, // Apparently checked: null is not valid for TS ...(subVisualizationId === v.id && { checked: 'on' }), @@ -363,6 +388,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { = ({ + data, + args, + timeZone, + formatFactory, + chartsThemeService, + onClickValue, + onSelectRange, +}) => { + const chartTheme = chartsThemeService.useChartsTheme(); + const isDarkTheme = chartsThemeService.useDarkMode(); + + const tableId = Object.keys(data.tables)[0]; + const table = data.tables[tableId]; + + const xAxisColumnIndex = table.columns.findIndex((v) => v.id === args.xAccessor); + const yAxisColumnIndex = table.columns.findIndex((v) => v.id === args.yAccessor); + + const xAxisColumn = table.columns[xAxisColumnIndex]; + const yAxisColumn = table.columns[yAxisColumnIndex]; + const valueColumn = table.columns.find((v) => v.id === args.valueAccessor); + + if (!xAxisColumn || !valueColumn) { + // Chart is not ready + return null; + } + + let chartData = table.rows.filter((v) => typeof v[args.valueAccessor!] === 'number'); + + if (!yAxisColumn) { + // required for tooltip + chartData = chartData.map((row) => { + return { + ...row, + unifiedY: '', + }; + }); + } + + const xAxisMeta = xAxisColumn.meta; + const isTimeBasedSwimLane = xAxisMeta.type === 'date'; + + // Fallback to the ordinal scale type when a single row of data is provided. + // Related issue https://github.com/elastic/elastic-charts/issues/1184 + const xScaleType = + isTimeBasedSwimLane && chartData.length > 1 ? ScaleType.Time : ScaleType.Ordinal; + + const xValuesFormatter = formatFactory(xAxisMeta.params); + const valueFormatter = formatFactory(valueColumn.meta.params); + + const onElementClick = ((e: HeatmapElementEvent[]) => { + const cell = e[0][0]; + const { x, y } = cell.datum; + + const xAxisFieldName = xAxisColumn.meta.field; + const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; + + const points = [ + { + row: table.rows.findIndex((r) => r[xAxisColumn.id] === x), + column: xAxisColumnIndex, + value: x, + }, + ...(yAxisColumn + ? [ + { + row: table.rows.findIndex((r) => r[yAxisColumn.id] === y), + column: yAxisColumnIndex, + value: y, + }, + ] + : []), + ]; + + const context: LensFilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(desanitizeFilterContext(context)); + }) as ElementClickListener; + + const onBrushEnd = (e: HeatmapBrushEvent) => { + const { x, y } = e; + + const xAxisFieldName = xAxisColumn.meta.field; + const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; + + if (isTimeBasedSwimLane) { + const context: LensBrushEvent['data'] = { + range: x as number[], + table, + column: xAxisColumnIndex, + timeFieldName, + }; + onSelectRange(context); + } else { + const points: Array<{ row: number; column: number; value: string | number }> = []; + + if (yAxisColumn) { + (y as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[yAxisColumn.id] === v), + column: yAxisColumnIndex, + value: v, + }); + }); + } + + (x as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[xAxisColumn.id] === v), + column: xAxisColumnIndex, + value: v, + }); + }); + + const context: LensFilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(desanitizeFilterContext(context)); + } + }; + + const config: HeatmapSpec['config'] = { + onBrushEnd, + grid: { + stroke: { + width: + args.gridConfig.strokeWidth ?? chartTheme.axes?.gridLine?.horizontal?.strokeWidth ?? 1, + color: + args.gridConfig.strokeColor ?? chartTheme.axes?.gridLine?.horizontal?.stroke ?? '#D3DAE6', + }, + cellHeight: { + max: 'fill', + min: 1, + }, + }, + cell: { + maxWidth: 'fill', + maxHeight: 'fill', + label: { + visible: args.gridConfig.isCellLabelVisible ?? false, + }, + border: { + strokeWidth: 0, + }, + }, + yAxisLabel: { + visible: !!yAxisColumn && args.gridConfig.isYAxisLabelVisible, + // eui color subdued + fill: chartTheme.axes?.tickLabel?.fill ?? '#6a717d', + padding: yAxisColumn?.name ? 8 : 0, + name: yAxisColumn?.name ?? '', + ...(yAxisColumn + ? { + formatter: (v: number | string) => formatFactory(yAxisColumn.meta.params).convert(v), + } + : {}), + }, + xAxisLabel: { + visible: args.gridConfig.isXAxisLabelVisible, + // eui color subdued + fill: chartTheme.axes?.tickLabel?.fill ?? `#6a717d`, + formatter: (v: number | string) => xValuesFormatter.convert(v), + name: xAxisColumn.name, + }, + brushMask: { + fill: isDarkTheme ? 'rgb(30,31,35,80%)' : 'rgb(247,247,247,50%)', + }, + brushArea: { + stroke: isDarkTheme ? 'rgb(255, 255, 255)' : 'rgb(105, 112, 125)', + }, + timeZone, + }; + + if (!chartData || !chartData.length) { + return ; + } + + const colorPalette = euiPaletteForTemperature(5); + + return ( + + + valueFormatter.convert(v)} + xScaleType={xScaleType} + ySortPredicate="dataIndex" + config={config} + xSortPredicate="dataIndex" + /> + + ); +}; + +const MemoizedChart = React.memo(HeatmapComponent); + +export function HeatmapChartReportable(props: HeatmapRenderProps) { + const [state, setState] = useState({ + isReady: false, + }); + + // It takes a cycle for the XY chart to render. This prevents + // reporting from printing a blank chart placeholder. + useEffect(() => { + setState({ isReady: true }); + }, [setState]); + + return ( + + + + ); +} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/constants.ts b/x-pack/plugins/lens/public/heatmap_visualization/constants.ts new file mode 100644 index 0000000000000..ee1be917f5bfd --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/constants.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { LensIconChartHeatmap } from '../assets/chart_heatmap'; + +export const LENS_HEATMAP_RENDERER = 'lens_heatmap_renderer'; + +export const LENS_HEATMAP_ID = 'lnsHeatmap'; + +const groupLabel = i18n.translate('xpack.lens.heatmap.groupLabel', { + defaultMessage: 'Heatmap', +}); + +export const CHART_SHAPES = { + HEATMAP: 'heatmap', +} as const; + +export const CHART_NAMES = { + heatmap: { + shapeType: CHART_SHAPES.HEATMAP, + icon: LensIconChartHeatmap, + label: i18n.translate('xpack.lens.heatmap.heatmapLabel', { + defaultMessage: 'Heatmap', + }), + groupLabel, + }, +}; + +export const GROUP_ID = { + X: 'x', + Y: 'y', + CELL: 'cell', +} as const; + +export const FUNCTION_NAME = 'lens_heatmap'; + +export const LEGEND_FUNCTION = 'lens_heatmap_legendConfig'; + +export const HEATMAP_GRID_FUNCTION = 'lens_heatmap_grid'; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx b/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx new file mode 100644 index 0000000000000..f0521dadf88bf --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx @@ -0,0 +1,275 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import { Position } from '@elastic/charts'; +import { + ExpressionFunctionDefinition, + IInterpreterRenderHandlers, +} from '../../../../../src/plugins/expressions'; +import { FormatFactory, LensBrushEvent, LensFilterEvent, LensMultiTable } from '../types'; +import { + FUNCTION_NAME, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, + LENS_HEATMAP_RENDERER, +} from './constants'; +import type { + HeatmapExpressionArgs, + HeatmapExpressionProps, + HeatmapGridConfig, + HeatmapGridConfigResult, + HeatmapRender, + LegendConfigResult, +} from './types'; +import { HeatmapLegendConfig } from './types'; +import { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public'; +import { HeatmapChartReportable } from './chart_component'; + +export const heatmapGridConfig: ExpressionFunctionDefinition< + typeof HEATMAP_GRID_FUNCTION, + null, + HeatmapGridConfig, + HeatmapGridConfigResult +> = { + name: HEATMAP_GRID_FUNCTION, + aliases: [], + type: HEATMAP_GRID_FUNCTION, + help: `Configure the heatmap layout `, + inputTypes: ['null'], + args: { + // grid + strokeWidth: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.strokeWidth.help', { + defaultMessage: 'Specifies the grid stroke width', + }), + required: false, + }, + strokeColor: { + types: ['string'], + help: i18n.translate('xpack.lens.heatmapChart.config.strokeColor.help', { + defaultMessage: 'Specifies the grid stroke color', + }), + required: false, + }, + cellHeight: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.cellHeight.help', { + defaultMessage: 'Specifies the grid cell height', + }), + required: false, + }, + cellWidth: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.cellWidth.help', { + defaultMessage: 'Specifies the grid cell width', + }), + required: false, + }, + // cells + isCellLabelVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.config.isCellLabelVisible.help', { + defaultMessage: 'Specifies whether or not the cell label is visible.', + }), + }, + // Y-axis + isYAxisLabelVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.config.isYAxisLabelVisible.help', { + defaultMessage: 'Specifies whether or not the Y-axis labels are visible.', + }), + }, + yAxisLabelWidth: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.yAxisLabelWidth.help', { + defaultMessage: 'Specifies the width of the Y-axis labels.', + }), + required: false, + }, + yAxisLabelColor: { + types: ['string'], + help: i18n.translate('xpack.lens.heatmapChart.config.yAxisLabelColor.help', { + defaultMessage: 'Specifies the color of the Y-axis labels.', + }), + required: false, + }, + // X-axis + isXAxisLabelVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.config.isXAxisLabelVisible.help', { + defaultMessage: 'Specifies whether or not the X-axis labels are visible.', + }), + }, + }, + fn(input, args) { + return { + type: HEATMAP_GRID_FUNCTION, + ...args, + }; + }, +}; + +/** + * TODO check if it's possible to make a shared function + * based on the XY chart + */ +export const heatmapLegendConfig: ExpressionFunctionDefinition< + typeof LEGEND_FUNCTION, + null, + HeatmapLegendConfig, + LegendConfigResult +> = { + name: LEGEND_FUNCTION, + aliases: [], + type: LEGEND_FUNCTION, + help: `Configure the heatmap chart's legend`, + inputTypes: ['null'], + args: { + isVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.legend.isVisible.help', { + defaultMessage: 'Specifies whether or not the legend is visible.', + }), + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: i18n.translate('xpack.lens.heatmapChart.legend.position.help', { + defaultMessage: 'Specifies the legend position.', + }), + }, + }, + fn(input, args) { + return { + type: LEGEND_FUNCTION, + ...args, + }; + }, +}; + +export const heatmap: ExpressionFunctionDefinition< + typeof FUNCTION_NAME, + LensMultiTable, + HeatmapExpressionArgs, + HeatmapRender +> = { + name: FUNCTION_NAME, + type: 'render', + help: i18n.translate('xpack.lens.heatmap.expressionHelpLabel', { + defaultMessage: 'Heatmap renderer', + }), + args: { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.heatmap.titleLabel', { + defaultMessage: 'Title', + }), + }, + description: { + types: ['string'], + help: '', + }, + xAccessor: { + types: ['string'], + help: '', + }, + yAccessor: { + types: ['string'], + help: '', + }, + valueAccessor: { + types: ['string'], + help: '', + }, + shape: { + types: ['string'], + help: '', + }, + palette: { + default: `{theme "palette" default={system_palette name="default"} }`, + help: '', + types: ['palette'], + }, + legend: { + types: [LEGEND_FUNCTION], + help: i18n.translate('xpack.lens.heatmapChart.legend.help', { + defaultMessage: 'Configure the chart legend.', + }), + }, + gridConfig: { + types: [HEATMAP_GRID_FUNCTION], + help: i18n.translate('xpack.lens.heatmapChart.gridConfig.help', { + defaultMessage: 'Configure the heatmap layout.', + }), + }, + }, + inputTypes: ['lens_multitable'], + fn(data: LensMultiTable, args: HeatmapExpressionArgs) { + return { + type: 'render', + as: LENS_HEATMAP_RENDERER, + value: { + data, + args, + }, + }; + }, +}; + +export const getHeatmapRenderer = (dependencies: { + formatFactory: Promise; + chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; + timeZone: string; +}) => ({ + name: LENS_HEATMAP_RENDERER, + displayName: i18n.translate('xpack.lens.heatmap.visualizationName', { + defaultMessage: 'Heatmap', + }), + help: '', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: HeatmapExpressionProps, + handlers: IInterpreterRenderHandlers + ) => { + const formatFactory = await dependencies.formatFactory; + const onClickValue = (data: LensFilterEvent['data']) => { + handlers.event({ name: 'filter', data }); + }; + const onSelectRange = (data: LensBrushEvent['data']) => { + handlers.event({ name: 'brush', data }); + }; + + ReactDOM.render( + + { + + } + , + domNode, + () => { + handlers.done(); + } + ); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts b/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts similarity index 52% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts rename to x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts index 109d3de1b86db..894b003b4b371 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { ROLE_MAPPING_PATH } from '../../routes'; -import { generateEncodedPath } from '../../utils/encode_path_params'; - -export const generateRoleMappingPath = (roleId: string) => - generateEncodedPath(ROLE_MAPPING_PATH, { roleId }); +export * from './expression'; +export * from './types'; +export * from './visualization'; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/index.scss b/x-pack/plugins/lens/public/heatmap_visualization/index.scss new file mode 100644 index 0000000000000..e72356b1a3d7e --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/index.scss @@ -0,0 +1,8 @@ +.lnsHeatmapExpression__container { + height: 100%; + width: 100%; + // the FocusTrap is adding extra divs which are making the visualization redraw twice + // with a visible glitch. This make the chart library resilient to this extra reflow + overflow-x: hidden; + +} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/index.ts b/x-pack/plugins/lens/public/heatmap_visualization/index.ts new file mode 100644 index 0000000000000..4599bd8d2a208 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/index.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { EditorFrameSetup, FormatFactory } from '../types'; +import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { getTimeZone } from '../utils'; + +export interface HeatmapVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + formatFactory: Promise; + editorFrame: EditorFrameSetup; + charts: ChartsPluginSetup; +} + +export class HeatmapVisualization { + constructor() {} + + setup( + core: CoreSetup, + { expressions, formatFactory, editorFrame, charts }: HeatmapVisualizationPluginSetupPlugins + ) { + editorFrame.registerVisualization(async () => { + const timeZone = getTimeZone(core.uiSettings); + + const { + getHeatmapVisualization, + heatmap, + heatmapLegendConfig, + heatmapGridConfig, + getHeatmapRenderer, + } = await import('../async_services'); + const palettes = await charts.palettes.getPalettes(); + + expressions.registerFunction(() => heatmap); + expressions.registerFunction(() => heatmapLegendConfig); + expressions.registerFunction(() => heatmapGridConfig); + + expressions.registerRenderer( + getHeatmapRenderer({ + formatFactory, + chartsThemeService: charts.theme, + paletteService: palettes, + timeZone, + }) + ); + return getHeatmapVisualization({ paletteService: palettes }); + }); + } +} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts new file mode 100644 index 0000000000000..c11078be6c8b9 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts @@ -0,0 +1,330 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getSuggestions } from './suggestions'; +import { HeatmapVisualizationState } from './types'; +import { HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; +import { Position } from '@elastic/charts'; + +describe('heatmap suggestions', () => { + describe('rejects suggestions', () => { + test('when currently active and unchanged data', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'heatmap', + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + test('when there are 3 or more buckets', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column-01', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'date-column-02', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'another-bucket-column', + operation: { + isBucketed: true, + dataType: 'string', + scale: 'ratio', + label: 'Bucket', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([]); + }); + + test('when currently active with partial configuration and not extended change type', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'initial', + }, + state: { + shape: 'heatmap', + layerId: 'first', + xAccessor: 'some-field', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + }); + + describe('hides suggestions', () => { + test('when table is reduced', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'reduced', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + hide: true, + previewIcon: 'empty', + score: 0, + }, + ]); + }); + test('for tables with a single bucket dimension', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'test-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + ], + changeType: 'reduced', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + xAccessor: 'test-column', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + hide: true, + previewIcon: 'empty', + score: 0.3, + }, + ]); + }); + }); + + describe('shows suggestions', () => { + test('when at least one axis and value accessor are available', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + xAccessor: 'date-column', + valueAccessor: 'metric-column', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + // Temp hide all suggestions while heatmap is in beta + hide: true, + previewIcon: 'empty', + score: 0.6, + }, + ]); + }); + + test('when complete configuration has been resolved', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + }, + }, + { + columnId: 'group-column', + operation: { + isBucketed: true, + dataType: 'string', + scale: 'ratio', + label: 'Group', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + xAccessor: 'date-column', + yAccessor: 'group-column', + valueAccessor: 'metric-column', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + // Temp hide all suggestions while heatmap is in beta + hide: true, + previewIcon: 'empty', + score: 0.9, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts new file mode 100644 index 0000000000000..5cddebe2cc230 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { partition } from 'lodash'; +import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { Visualization } from '../types'; +import { HeatmapVisualizationState } from './types'; +import { CHART_SHAPES, HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; + +export const getSuggestions: Visualization['getSuggestions'] = ({ + table, + state, + keptLayerIds, +}) => { + if ( + state?.shape === CHART_SHAPES.HEATMAP && + (state.xAccessor || state.yAccessor || state.valueAccessor) && + table.changeType !== 'extended' + ) { + return []; + } + + const isUnchanged = state && table.changeType === 'unchanged'; + + if ( + isUnchanged || + keptLayerIds.length > 1 || + (keptLayerIds.length && table.layerId !== keptLayerIds[0]) + ) { + return []; + } + + /** + * The score gets increased based on the config completion. + */ + let score = 0; + + const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed); + + if (groups.length >= 3) { + return []; + } + + const isSingleBucketDimension = groups.length === 1 && metrics.length === 0; + + /** + * Hide for: + * - reduced and reorder tables + * - tables with just a single bucket dimension + */ + const hide = + table.changeType === 'reduced' || table.changeType === 'reorder' || isSingleBucketDimension; + + const newState: HeatmapVisualizationState = { + shape: CHART_SHAPES.HEATMAP, + layerId: table.layerId, + legend: { + isVisible: state?.legend?.isVisible ?? true, + position: state?.legend?.position ?? Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + }; + + const numberMetric = metrics.find((m) => m.operation.dataType === 'number'); + + if (numberMetric) { + score += 0.3; + newState.valueAccessor = numberMetric.columnId; + } + + const [histogram, ordinal] = partition(groups, (g) => g.operation.scale === 'interval'); + + newState.xAccessor = histogram[0]?.columnId || ordinal[0]?.columnId; + newState.yAccessor = groups.find((g) => g.columnId !== newState.xAccessor)?.columnId; + + if (newState.xAccessor) { + score += 0.3; + } + if (newState.yAccessor) { + score += 0.3; + } + + return [ + { + state: newState, + title: i18n.translate('xpack.lens.heatmap.heatmapLabel', { + defaultMessage: 'Heatmap', + }), + // Temp hide all suggestions while heatmap is in beta + hide: true || hide, + previewIcon: 'empty', + score: Number(score.toFixed(1)), + }, + ]; +}; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx new file mode 100644 index 0000000000000..6fd863ba91936 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { VisualizationToolbarProps } from '../types'; +import { LegendSettingsPopover } from '../shared_components'; +import { HeatmapVisualizationState } from './types'; + +const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ + { + id: `heatmap_legend_show`, + value: 'show', + label: i18n.translate('xpack.lens.heatmapChart.legendVisibility.show', { + defaultMessage: 'Show', + }), + }, + { + id: `heatmap_legend_hide`, + value: 'hide', + label: i18n.translate('xpack.lens.heatmapChart.legendVisibility.hide', { + defaultMessage: 'Hide', + }), + }, +]; + +export const HeatmapToolbar = memo( + (props: VisualizationToolbarProps) => { + const { state, setState } = props; + + const legendMode = state.legend.isVisible ? 'show' : 'hide'; + + return ( + + + + { + const newMode = legendOptions.find(({ id }) => id === optionId)!.value; + if (newMode === 'show') { + setState({ + ...state, + legend: { ...state.legend, isVisible: true }, + }); + } else if (newMode === 'hide') { + setState({ + ...state, + legend: { ...state.legend, isVisible: false }, + }); + } + }} + position={state?.legend.position} + onPositionChange={(id) => { + setState({ + ...state, + legend: { ...state.legend, position: id as Position }, + }); + }} + /> + + + + ); + } +); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts new file mode 100644 index 0000000000000..734fe7f5be754 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Position } from '@elastic/charts'; +import { PaletteOutput } from '../../../../../src/plugins/charts/common'; +import { FormatFactory, LensBrushEvent, LensFilterEvent, LensMultiTable } from '../types'; +import { + CHART_SHAPES, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, + LENS_HEATMAP_RENDERER, +} from './constants'; +import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; + +export type ChartShapes = typeof CHART_SHAPES[keyof typeof CHART_SHAPES]; + +export interface SharedHeatmapLayerState { + shape: ChartShapes; + xAccessor?: string; + yAccessor?: string; + valueAccessor?: string; + legend: LegendConfigResult; + gridConfig: HeatmapGridConfigResult; +} + +export type HeatmapLayerState = SharedHeatmapLayerState & { + layerId: string; +}; + +export type HeatmapVisualizationState = HeatmapLayerState & { + palette?: PaletteOutput; +}; + +export type HeatmapExpressionArgs = SharedHeatmapLayerState & { + title?: string; + description?: string; + palette: PaletteOutput; +}; + +export interface HeatmapRender { + type: 'render'; + as: typeof LENS_HEATMAP_RENDERER; + value: HeatmapExpressionProps; +} + +export interface HeatmapExpressionProps { + data: LensMultiTable; + args: HeatmapExpressionArgs; +} + +export type HeatmapRenderProps = HeatmapExpressionProps & { + timeZone: string; + formatFactory: FormatFactory; + chartsThemeService: ChartsPluginSetup['theme']; + onClickValue: (data: LensFilterEvent['data']) => void; + onSelectRange: (data: LensBrushEvent['data']) => void; +}; + +export interface HeatmapLegendConfig { + /** + * Flag whether the legend should be shown. If there is just a single series, it will be hidden + */ + isVisible: boolean; + /** + * Position of the legend relative to the chart + */ + position: Position; +} + +export type LegendConfigResult = HeatmapLegendConfig & { type: typeof LEGEND_FUNCTION }; + +export interface HeatmapGridConfig { + // grid + strokeWidth?: number; + strokeColor?: string; + cellHeight?: number; + cellWidth?: number; + // cells + isCellLabelVisible: boolean; + // Y-axis + isYAxisLabelVisible: boolean; + yAxisLabelWidth?: number; + yAxisLabelColor?: string; + // X-axis + isXAxisLabelVisible: boolean; +} + +export type HeatmapGridConfigResult = HeatmapGridConfig & { type: typeof HEATMAP_GRID_FUNCTION }; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts new file mode 100644 index 0000000000000..3ed82bef06105 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -0,0 +1,486 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + filterOperationsAxis, + getHeatmapVisualization, + isCellValueSupported, +} from './visualization'; +import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { + CHART_SHAPES, + FUNCTION_NAME, + GROUP_ID, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, +} from './constants'; +import { Position } from '@elastic/charts'; +import { HeatmapVisualizationState } from './types'; +import { DatasourcePublicAPI, Operation } from '../types'; + +function exampleState(): HeatmapVisualizationState { + return { + layerId: 'test-layer', + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + shape: CHART_SHAPES.HEATMAP, + }; +} + +describe('heatmap', () => { + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + }); + + describe('#intialize', () => { + test('returns a default state', () => { + expect(getHeatmapVisualization({}).initialize(frame)).toEqual({ + layerId: '', + title: 'Empty Heatmap chart', + shape: CHART_SHAPES.HEATMAP, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + }); + }); + + test('returns persisted state', () => { + expect(getHeatmapVisualization({}).initialize(frame, exampleState())).toEqual(exampleState()); + }); + }); + + describe('#getConfiguration', () => { + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('resolves configuration from complete state', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + yAccessor: 'y-accessor', + valueAccessor: 'v-accessor', + }; + + expect( + getHeatmapVisualization({}).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.X, + groupLabel: 'Horizontal axis', + accessors: [{ columnId: 'x-accessor' }], + filterOperations: filterOperationsAxis, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsHeatmap_xDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.Y, + groupLabel: 'Vertical axis', + accessors: [{ columnId: 'y-accessor' }], + filterOperations: filterOperationsAxis, + supportsMoreColumns: false, + required: false, + dataTestSubj: 'lnsHeatmap_yDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.CELL, + groupLabel: 'Cell value', + accessors: [{ columnId: 'v-accessor' }], + filterOperations: isCellValueSupported, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsHeatmap_cellPanel', + }, + ], + }); + }); + + test('resolves configuration from partial state', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + }; + + expect( + getHeatmapVisualization({}).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.X, + groupLabel: 'Horizontal axis', + accessors: [{ columnId: 'x-accessor' }], + filterOperations: filterOperationsAxis, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsHeatmap_xDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.Y, + groupLabel: 'Vertical axis', + accessors: [], + filterOperations: filterOperationsAxis, + supportsMoreColumns: true, + required: false, + dataTestSubj: 'lnsHeatmap_yDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.CELL, + groupLabel: 'Cell value', + accessors: [], + filterOperations: isCellValueSupported, + supportsMoreColumns: true, + required: true, + dataTestSubj: 'lnsHeatmap_cellPanel', + }, + ], + }); + }); + }); + + describe('#setDimension', () => { + test('set dimension correctly', () => { + const prevState: HeatmapVisualizationState = { + ...exampleState(), + xAccessor: 'x-accessor', + yAccessor: 'y-accessor', + }; + expect( + getHeatmapVisualization({}).setDimension({ + prevState, + layerId: 'first', + columnId: 'new-x-accessor', + groupId: 'x', + }) + ).toEqual({ + ...prevState, + xAccessor: 'new-x-accessor', + }); + }); + }); + + describe('#removeDimension', () => { + test('removes dimension correctly', () => { + const prevState: HeatmapVisualizationState = { + ...exampleState(), + xAccessor: 'x-accessor', + yAccessor: 'y-accessor', + }; + expect( + getHeatmapVisualization({}).removeDimension({ + prevState, + layerId: 'first', + columnId: 'x-accessor', + }) + ).toEqual({ + ...exampleState(), + yAccessor: 'y-accessor', + }); + }); + }); + + describe('#toExpression', () => { + let datasourceLayers: Record; + + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('creates an expression based on state and attributes', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + valueAccessor: 'value-accessor', + }; + const attributes = { + title: 'Test', + }; + + expect(getHeatmapVisualization({}).toExpression(state, datasourceLayers, attributes)).toEqual( + { + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: ['Test'], + description: [''], + xAccessor: ['x-accessor'], + yAccessor: [''], + valueAccessor: ['value-accessor'], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [true], + position: [Position.Right], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: [], + strokeColor: [], + cellHeight: [], + cellWidth: [], + // cells + isCellLabelVisible: [false], + // Y-axis + isYAxisLabelVisible: [true], + yAxisLabelWidth: [], + yAxisLabelColor: [], + // X-axis + isXAxisLabelVisible: [true], + }, + }, + ], + }, + ], + }, + }, + ], + } + ); + }); + + test('returns null with a missing value accessor', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + }; + const attributes = { + title: 'Test', + }; + + expect(getHeatmapVisualization({}).toExpression(state, datasourceLayers, attributes)).toEqual( + null + ); + }); + }); + + describe('#toPreviewExpression', () => { + let datasourceLayers: Record; + + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('creates a preview expression based on state and attributes', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + }; + + expect(getHeatmapVisualization({}).toPreviewExpression!(state, datasourceLayers)).toEqual({ + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: [''], + description: [''], + xAccessor: ['x-accessor'], + yAccessor: [''], + valueAccessor: [''], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [false], + position: [], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: [1], + // cells + isCellLabelVisible: [false], + // Y-axis + isYAxisLabelVisible: [false], + // X-axis + isXAxisLabelVisible: [false], + }, + }, + ], + }, + ], + }, + }, + ], + }); + }); + }); + + describe('#getErrorMessages', () => { + test('should not return an error when chart has empty configuration', () => { + const mockState = { + shape: CHART_SHAPES.HEATMAP, + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getErrorMessages(mockState)).toEqual(undefined); + }); + + test('should return an error when the X accessor is missing', () => { + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getErrorMessages(mockState)).toEqual([ + { + longMessage: 'Configuration for the horizontal axis is missing.', + shortMessage: 'Missing Horizontal axis.', + }, + ]); + }); + }); + + describe('#getWarningMessages', () => { + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('should not return warning messages when the layer it not configured', () => { + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toEqual(undefined); + }); + + test('should not return warning messages when the data table is empty', () => { + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + layerId: 'first', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toEqual(undefined); + }); + + test('should return a warning message when cell value data contains arrays', () => { + frame.activeData = { + first: { + type: 'datatable', + rows: [ + { + 'v-accessor': [1, 2, 3], + }, + ], + columns: [], + }, + }; + + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + layerId: 'first', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx new file mode 100644 index 0000000000000..54f9c70824831 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -0,0 +1,415 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { Ast } from '@kbn/interpreter/common'; +import { Position } from '@elastic/charts'; +import { PaletteRegistry } from '../../../../../src/plugins/charts/public'; +import { OperationMetadata, Visualization } from '../types'; +import { HeatmapVisualizationState } from './types'; +import { getSuggestions } from './suggestions'; +import { + CHART_NAMES, + CHART_SHAPES, + FUNCTION_NAME, + GROUP_ID, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, + LENS_HEATMAP_ID, +} from './constants'; +import { HeatmapToolbar } from './toolbar_component'; +import { LensIconChartHeatmap } from '../assets/chart_heatmap'; + +const groupLabelForBar = i18n.translate('xpack.lens.heatmapVisualization.heatmapGroupLabel', { + defaultMessage: 'Heatmap', +}); + +interface HeatmapVisualizationDeps { + paletteService?: PaletteRegistry; +} + +function getAxisName(axis: 'x' | 'y') { + const vertical = i18n.translate('xpack.lens.heatmap.verticalAxisLabel', { + defaultMessage: 'Vertical axis', + }); + const horizontal = i18n.translate('xpack.lens.heatmap.horizontalAxisLabel', { + defaultMessage: 'Horizontal axis', + }); + if (axis === 'x') { + return horizontal; + } + return vertical; +} + +export const isBucketed = (op: OperationMetadata) => op.isBucketed && op.scale === 'ordinal'; +const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number'; + +export const filterOperationsAxis = (op: OperationMetadata) => + isBucketed(op) || op.scale === 'interval'; + +export const isCellValueSupported = (op: OperationMetadata) => { + return !isBucketed(op) && (op.scale === 'ordinal' || op.scale === 'ratio') && isNumericMetric(op); +}; + +function getInitialState(): Omit { + return { + shape: CHART_SHAPES.HEATMAP, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + }; +} + +export const getHeatmapVisualization = ({ + paletteService, +}: HeatmapVisualizationDeps): Visualization => ({ + id: LENS_HEATMAP_ID, + + visualizationTypes: [ + { + id: 'heatmap', + icon: LensIconChartHeatmap, + label: i18n.translate('xpack.lens.heatmapVisualization.heatmapLabel', { + defaultMessage: 'Heatmap', + }), + groupLabel: groupLabelForBar, + showBetaBadge: true, + }, + ], + + getVisualizationTypeId(state) { + return state.shape; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + + clearLayer(state) { + const newState = { ...state }; + delete newState.valueAccessor; + delete newState.xAccessor; + delete newState.yAccessor; + return newState; + }, + + switchVisualizationType: (visualizationTypeId, state) => { + return { + ...state, + shape: visualizationTypeId as typeof CHART_SHAPES.HEATMAP, + }; + }, + + getDescription(state) { + return CHART_NAMES.heatmap; + }, + + initialize(frame, state, mainPalette) { + return ( + state || { + layerId: frame.addNewLayer(), + title: 'Empty Heatmap chart', + ...getInitialState(), + } + ); + }, + + getSuggestions, + + getConfiguration({ state, frame, layerId }) { + const datasourceLayer = frame.datasourceLayers[layerId]; + + const originalOrder = datasourceLayer.getTableSpec().map(({ columnId }) => columnId); + if (!originalOrder) { + return { groups: [] }; + } + + return { + groups: [ + { + layerId: state.layerId, + groupId: GROUP_ID.X, + groupLabel: getAxisName(GROUP_ID.X), + accessors: state.xAccessor ? [{ columnId: state.xAccessor }] : [], + filterOperations: filterOperationsAxis, + supportsMoreColumns: !state.xAccessor, + required: true, + dataTestSubj: 'lnsHeatmap_xDimensionPanel', + }, + { + layerId: state.layerId, + groupId: GROUP_ID.Y, + groupLabel: getAxisName(GROUP_ID.Y), + accessors: state.yAccessor ? [{ columnId: state.yAccessor }] : [], + filterOperations: filterOperationsAxis, + supportsMoreColumns: !state.yAccessor, + required: false, + dataTestSubj: 'lnsHeatmap_yDimensionPanel', + }, + { + layerId: state.layerId, + groupId: GROUP_ID.CELL, + groupLabel: i18n.translate('xpack.lens.heatmap.cellValueLabel', { + defaultMessage: 'Cell value', + }), + accessors: state.valueAccessor ? [{ columnId: state.valueAccessor }] : [], + filterOperations: isCellValueSupported, + supportsMoreColumns: !state.valueAccessor, + required: true, + dataTestSubj: 'lnsHeatmap_cellPanel', + }, + ], + }; + }, + + setDimension({ prevState, layerId, columnId, groupId, previousColumn }) { + const update: Partial = {}; + if (groupId === GROUP_ID.X) { + update.xAccessor = columnId; + } + if (groupId === GROUP_ID.Y) { + update.yAccessor = columnId; + } + if (groupId === GROUP_ID.CELL) { + update.valueAccessor = columnId; + } + return { + ...prevState, + ...update, + }; + }, + + removeDimension({ prevState, layerId, columnId }) { + const update = { ...prevState }; + + if (prevState.valueAccessor === columnId) { + delete update.valueAccessor; + } + if (prevState.xAccessor === columnId) { + delete update.xAccessor; + } + if (prevState.yAccessor === columnId) { + delete update.yAccessor; + } + + return update; + }, + + renderToolbar(domElement, props) { + render( + + + , + domElement + ); + }, + + toExpression(state, datasourceLayers, attributes): Ast | null { + const datasource = datasourceLayers[state.layerId]; + + const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); + // When we add a column it could be empty, and therefore have no order + + if (!originalOrder || !state.valueAccessor) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: [attributes?.title ?? ''], + description: [attributes?.description ?? ''], + xAccessor: [state.xAccessor ?? ''], + yAccessor: [state.yAccessor ?? ''], + valueAccessor: [state.valueAccessor ?? ''], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [state.legend.isVisible], + position: [state.legend.position], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: state.gridConfig.strokeWidth + ? [state.gridConfig.strokeWidth] + : [], + strokeColor: state.gridConfig.strokeColor + ? [state.gridConfig.strokeColor] + : [], + cellHeight: state.gridConfig.cellHeight ? [state.gridConfig.cellHeight] : [], + cellWidth: state.gridConfig.cellWidth ? [state.gridConfig.cellWidth] : [], + // cells + isCellLabelVisible: [state.gridConfig.isCellLabelVisible], + // Y-axis + isYAxisLabelVisible: [state.gridConfig.isYAxisLabelVisible], + yAxisLabelWidth: state.gridConfig.yAxisLabelWidth + ? [state.gridConfig.yAxisLabelWidth] + : [], + yAxisLabelColor: state.gridConfig.yAxisLabelColor + ? [state.gridConfig.yAxisLabelColor] + : [], + // X-axis + isXAxisLabelVisible: state.gridConfig.isXAxisLabelVisible + ? [state.gridConfig.isXAxisLabelVisible] + : [], + }, + }, + ], + }, + ], + }, + }, + ], + }; + }, + + toPreviewExpression(state, datasourceLayers): Ast | null { + const datasource = datasourceLayers[state.layerId]; + + const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); + // When we add a column it could be empty, and therefore have no order + + if (!originalOrder) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: [''], + description: [''], + xAccessor: [state.xAccessor ?? ''], + yAccessor: [state.yAccessor ?? ''], + valueAccessor: [state.valueAccessor ?? ''], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [false], + position: [], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: [1], + // cells + isCellLabelVisible: [false], + // Y-axis + isYAxisLabelVisible: [false], + // X-axis + isXAxisLabelVisible: [false], + }, + }, + ], + }, + ], + }, + }, + ], + }; + }, + + getErrorMessages(state) { + if (!state.yAccessor && !state.xAccessor && !state.valueAccessor) { + // nothing configured yet + return; + } + + const errors: ReturnType = []; + + if (!state.xAccessor) { + errors.push({ + shortMessage: i18n.translate( + 'xpack.lens.heatmapVisualization.missingXAccessorShortMessage', + { + defaultMessage: 'Missing Horizontal axis.', + } + ), + longMessage: i18n.translate('xpack.lens.heatmapVisualization.missingXAccessorLongMessage', { + defaultMessage: 'Configuration for the horizontal axis is missing.', + }), + }); + } + + return errors.length ? errors : undefined; + }, + + getWarningMessages(state, frame) { + if (!state?.layerId || !frame.activeData || !state.valueAccessor) { + return; + } + + const rows = frame.activeData[state.layerId] && frame.activeData[state.layerId].rows; + if (!rows) { + return; + } + + const hasArrayValues = rows.some((row) => Array.isArray(row[state.valueAccessor!])); + + const datasource = frame.datasourceLayers[state.layerId]; + const operation = datasource.getOperationForColumnId(state.valueAccessor); + + return hasArrayValues + ? [ + {operation?.label} }} + />, + ] + : undefined; + }, +}); diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 99e7199c2d802..fe225dba6f256 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -54,6 +54,7 @@ import { EmbeddableComponentProps, getEmbeddableComponent, } from './editor_frame_service/embeddable/embeddable_component'; +import { HeatmapVisualization } from './heatmap_visualization'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -119,6 +120,7 @@ export class LensPlugin { private xyVisualization: XyVisualization; private metricVisualization: MetricVisualization; private pieVisualization: PieVisualization; + private heatmapVisualization: HeatmapVisualization; private stopReportManager?: () => void; @@ -129,6 +131,7 @@ export class LensPlugin { this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); + this.heatmapVisualization = new HeatmapVisualization(); } setup( @@ -178,6 +181,7 @@ export class LensPlugin { this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); this.pieVisualization.setup(core, dependencies); + this.heatmapVisualization.setup(core, dependencies); visualizations.registerAlias(getLensAliasConfig()); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index 7acd5669b4ba5..23d4858c26263 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { ToolbarPopover } from '../shared_components'; +import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; export interface LegendSettingsPopoverProps { /** @@ -44,6 +45,10 @@ export interface LegendSettingsPopoverProps { * Callback on nested switch status change */ onNestedLegendChange?: (event: EuiSwitchEvent) => void; + /** + * Button group position + */ + groupPosition?: ToolbarButtonProps['groupPosition']; } const toggleButtonsIcons = [ @@ -86,6 +91,7 @@ export const LegendSettingsPopover: React.FunctionComponent {}, + groupPosition = 'right', }) => { return ( { diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index c1aab4c18f529..2706fe977c68e 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -7,6 +7,8 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public'; +import { IUiSettingsClient } from 'kibana/public'; +import moment from 'moment-timezone'; import { LensFilterEvent } from './types'; /** replaces the value `(empty) to empty string for proper filtering` */ @@ -63,6 +65,7 @@ export const getResolvedDateRange = function (timefilter: TimefilterContract) { export function containsDynamicMath(dateMathString: string) { return dateMathString.includes('now'); } + export const TIME_LAG_PERCENTAGE_LIMIT = 0.02; export async function getAllIndexPatterns( @@ -79,3 +82,12 @@ export async function getAllIndexPatterns( // return also the rejected ids in case we want to show something later on return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds }; } + +export function getTimeZone(uiSettings: IUiSettingsClient) { + const configuredTimeZone = uiSettings.get('dateFormat:tz'); + if (configuredTimeZone === 'Browser') { + return moment.tz.guess(); + } + + return configuredTimeZone; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 608971d281981..9b203faee3a64 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -222,7 +222,7 @@ export const xyChart: ExpressionFunctionDefinition< }, }; -export async function calculateMinInterval({ args: { layers }, data }: XYChartProps) { +export function calculateMinInterval({ args: { layers }, data }: XYChartProps) { const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) return; const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); @@ -280,7 +280,7 @@ export const getXyChartRenderer = (dependencies: { chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} timeZone={dependencies.timeZone} - minInterval={await calculateMinInterval(config)} + minInterval={calculateMinInterval(config)} onClickValue={onClickValue} onSelectRange={onSelectRange} renderMode={handlers.getRenderMode()} diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index bb9893bd058b5..f29d0f9280246 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { CoreSetup, IUiSettingsClient } from 'kibana/public'; -import moment from 'moment-timezone'; +import { CoreSetup } from 'kibana/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { EditorFrameSetup, FormatFactory } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { LensPluginStartDependencies } from '../plugin'; +import { getTimeZone } from '../utils'; export interface XyVisualizationPluginSetupPlugins { expressions: ExpressionsSetup; @@ -19,15 +19,6 @@ export interface XyVisualizationPluginSetupPlugins { charts: ChartsPluginSetup; } -function getTimeZone(uiSettings: IUiSettingsClient) { - const configuredTimeZone = uiSettings.get('dateFormat:tz'); - if (configuredTimeZone === 'Browser') { - return moment.tz.guess(); - } - - return configuredTimeZone; -} - export class XyVisualization { constructor() {} diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index c1041e1fefcfd..f2840b6d3844b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -52,7 +52,7 @@ describe('xy_visualization', () => { }; } - it('should show mixed xy chart when multilple series types', () => { + it('should show mixed xy chart when multiple series types', () => { const desc = xyVisualization.getDescription(mixedState('bar', 'line')); expect(desc.label).toEqual('Mixed XY'); @@ -332,7 +332,7 @@ describe('xy_visualization', () => { expect(options.map((o) => o.groupId)).toEqual(['x', 'y', 'breakdown']); }); - it('should return the correct labels for the 3 dimensios', () => { + it('should return the correct labels for the 3 dimensions', () => { const options = xyVisualization.getConfiguration({ state: exampleState(), frame, @@ -345,7 +345,7 @@ describe('xy_visualization', () => { ]); }); - it('should return the correct labels for the 3 dimensios for a horizontal chart', () => { + it('should return the correct labels for the 3 dimensions for a horizontal chart', () => { const initialState = exampleState(); const state = { ...initialState, diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index d8a4e1ce56bfa..1141437eae0ef 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -195,10 +195,14 @@ export class HeadlessChromiumDriverFactory { getBrowserLogger(page: puppeteer.Page, logger: LevelLogger): Rx.Observable { const consoleMessages$ = Rx.fromEvent(page, 'console').pipe( map((line) => { + const formatLine = () => `{ text: "${line.text()?.trim()}", url: ${line.location()?.url} }`; + if (line.type() === 'error') { - logger.error(line.text(), ['headless-browser-console']); + logger.error(`Error in browser console: ${formatLine()}`, ['headless-browser-console']); } else { - logger.debug(line.text(), [`headless-browser-console:${line.type()}`]); + logger.debug(`Message in browser console: ${formatLine()}`, [ + `headless-browser-console:${line.type()}`, + ]); } }) ); diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap index a2a36b3fe1d3b..84c8971e3d352 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap @@ -6,6 +6,7 @@ exports[`HeaderPage it renders 1`] = ` > - Test title + + Test title + css` + display: block; + + @media only screen and (min-width: ${theme.eui.euiBreakpoints.m}) { + max-width: 50%; + } + `} `; FlexItem.displayName = 'FlexItem'; @@ -112,7 +118,7 @@ const HeaderPageComponent: React.FC = ({ ); return ( - + {backOptions && ( diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx index 5ec05273d16f3..471d539ea03f4 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; import { DraggableArguments, BadgeOptions, TitleProp } from './types'; import { DefaultDraggable } from '../draggables'; +import { TruncatableText } from '../truncatable_text'; const StyledEuiBetaBadge = styled(EuiBetaBadge)` vertical-align: middle; @@ -33,7 +34,7 @@ const TitleComponent: React.FC = ({ draggableArguments, title, badgeOptio {!draggableArguments ? ( - title + {title} ) : ( - Hiding in plain sight - + + + + Hiding in plain sight + + + `; diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx index f54d9e4ed0b88..3bf9cf60afeab 100644 --- a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx @@ -5,14 +5,14 @@ * 2.0. */ -import { mount, shallow } from 'enzyme'; +import { mount } from 'enzyme'; import React from 'react'; import { TruncatableText } from '.'; describe('TruncatableText', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow({'Hiding in plain sight'}); + const wrapper = mount({'Hiding in plain sight'}); expect(wrapper).toMatchSnapshot(); }); @@ -33,4 +33,11 @@ describe('TruncatableText', () => { expect(wrapper).toHaveStyleRule('white-space', 'nowrap'); }); + + test('it can add tooltip', () => { + const testText = 'Some really really really really really long text.'; + const wrapper = mount({testText}); + + expect(wrapper.find('EuiToolTip').text()).toEqual(testText); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.ts b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.ts new file mode 100644 index 0000000000000..49ab0ac4defc3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './truncatable_text'; diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.tsx b/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx similarity index 54% rename from x-pack/plugins/security_solution/public/common/components/truncatable_text/index.tsx rename to x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx index 2dd3c35f731e9..4c068675aa5a0 100644 --- a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx @@ -5,7 +5,9 @@ * 2.0. */ +import React from 'react'; import styled from 'styled-components'; +import { EuiToolTip } from '@elastic/eui'; /** * Applies CSS styling to enable text to be truncated with an ellipsis. @@ -14,7 +16,7 @@ import styled from 'styled-components'; * Note: Requires a parent container with a defined width or max-width. */ -export const TruncatableText = styled.span` +const EllipsisText = styled.span` &, & * { display: inline-block; @@ -25,4 +27,19 @@ export const TruncatableText = styled.span` white-space: nowrap; } `; -TruncatableText.displayName = 'TruncatableText'; +EllipsisText.displayName = 'EllipsisText'; + +interface Props { + tooltipContent?: React.ReactNode; + children: React.ReactNode; +} + +export function TruncatableText({ tooltipContent, children, ...props }: Props) { + if (!tooltipContent) return {children}; + + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 1590a4f0fbb04..0fed141ca4dbc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -146,7 +146,7 @@ export const sampleDocWithSortId = ( export const sampleDocNoSortId = ( someUuid: string = sampleIdGuid, ip?: string -): SignalSourceHit => ({ +): SignalSourceHit & { _source: Required['_source'] } => ({ _index: 'myFakeSignalIndex', _type: 'doc', _score: 100, @@ -225,12 +225,12 @@ export const sampleWrappedSignalHit = (): WrappedSignalHit => { }; }; -export const sampleDocWithAncestors = (): SignalSearchResponse => { +export const sampleDocWithAncestors = (): SignalSearchResponse & { + hits: { hits: Array> }; +} => { const sampleDoc = sampleDocNoSortId(); delete sampleDoc.sort; - // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; - // @ts-expect-error @elastic/elasticsearch _source is optional sampleDoc._source.signal = { parent: { id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', @@ -562,7 +562,9 @@ export const sampleBulkCreateErrorResult = { export const sampleDocSearchResultsNoSortId = ( someUuid: string = sampleIdGuid -): SignalSearchResponse => ({ +): SignalSearchResponse & { + hits: { hits: Array> }; +} => ({ took: 10, timed_out: false, _shards: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 743d9580218a3..4d3ca26f5a71e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -24,6 +24,11 @@ import { SignalHit, SignalSourceHit } from './types'; import { SIGNALS_TEMPLATE_VERSION } from '../routes/index/get_signals_template'; import { getQueryRuleParams, getThresholdRuleParams } from '../schemas/rule_schemas.mock'; +// This allows us to not have to use ts-expect-error with delete in the code. +type SignalHitOptionalTimestamp = Omit & { + '@timestamp'?: SignalHit['@timestamp']; +}; + describe('buildBulkBody', () => { beforeEach(() => { jest.clearAllMocks(); @@ -32,11 +37,9 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; const expected: Omit & { someKey: 'someValue' } = { someKey: 'someValue', @@ -69,7 +72,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: expectedRule(), depth: 1, @@ -81,9 +84,8 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body with threshold results', () => { const ruleSO = sampleRuleSO(getThresholdRuleParams()); const baseDoc = sampleDocNoSortId(); - const doc: SignalSourceHit = { + const doc: SignalSourceHit & { _source: Required['_source'] } = { ...baseDoc, - // @ts-expect-error @elastic/elasticsearch _source is optional _source: { ...baseDoc._source, threshold_result: { @@ -96,11 +98,9 @@ describe('buildBulkBody', () => { }, }, }; - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; const expected: Omit & { someKey: 'someValue' } = { someKey: 'someValue', @@ -133,7 +133,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: { ...expectedRule(), @@ -167,18 +167,15 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', module: 'system', dataset: 'socket', kind: 'event', }; - const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; const expected: Omit & { someKey: 'someValue' } = { someKey: 'someValue', @@ -220,7 +217,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: expectedRule(), depth: 1, @@ -232,17 +229,14 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with but no kind information', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', module: 'system', dataset: 'socket', }; - const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; const expected: Omit & { someKey: 'someValue' } = { someKey: 'someValue', @@ -283,7 +277,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: expectedRule(), depth: 1, @@ -295,15 +289,12 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with with only kind information', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { kind: 'event', }; - const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; const expected: Omit & { someKey: 'someValue' } = { someKey: 'someValue', @@ -339,7 +330,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: expectedRule(), depth: 1, @@ -351,7 +342,6 @@ describe('buildBulkBody', () => { test('bulk body builds "original_signal" if it exists already as a numeric', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; const doc = ({ ...sampleDoc, @@ -393,7 +383,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: expectedRule(), depth: 1, @@ -405,7 +395,6 @@ describe('buildBulkBody', () => { test('bulk body builds "original_signal" if it exists already as an object', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; const doc = ({ ...sampleDoc, @@ -447,7 +436,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: expectedRule(), depth: 1, @@ -466,9 +455,8 @@ describe('buildSignalFromSequence', () => { block2._source.new_key = 'new_key_value'; const blocks = [block1, block2]; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal = buildSignalFromSequence(blocks, ruleSO); + const signal: SignalHitOptionalTimestamp = buildSignalFromSequence(blocks, ruleSO); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete signal['@timestamp']; const expected: Omit & { new_key: string } = { new_key: 'new_key_value', @@ -552,9 +540,8 @@ describe('buildSignalFromSequence', () => { block2._source['@timestamp'] = '2021-05-20T22:28:46+0000'; block2._source.someKey = 'someOtherValue'; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal = buildSignalFromSequence([block1, block2], ruleSO); + const signal: SignalHitOptionalTimestamp = buildSignalFromSequence([block1, block2], ruleSO); // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete signal['@timestamp']; const expected: Omit = { event: { @@ -635,12 +622,11 @@ describe('buildSignalFromSequence', () => { describe('buildSignalFromEvent', () => { test('builds a basic signal from a single event', () => { const ancestor = sampleDocWithAncestors().hits.hits[0]; - // @ts-expect-error @elastic/elasticsearch _source is optional delete ancestor._source.source; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal = buildSignalFromEvent(ancestor, ruleSO, true); + const signal: SignalHitOptionalTimestamp = buildSignalFromEvent(ancestor, ruleSO, true); + // Timestamp will potentially always be different so remove it for the test - // @ts-expect-error delete signal['@timestamp']; const expected: Omit & { someKey: 'someValue' } = { someKey: 'someValue', @@ -651,7 +637,7 @@ describe('buildSignalFromEvent', () => { _meta: { version: SIGNALS_TEMPLATE_VERSION, }, - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', parent: { id: sampleIdGuid, rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_event_type_signal.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_event_type_signal.test.ts index 185c165442921..0ae81770e83c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_event_type_signal.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_event_type_signal.test.ts @@ -16,7 +16,6 @@ describe('buildEventTypeSignal', () => { test('it returns the event appended of kind signal if it does not exist', () => { const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.event; const eventType = buildEventTypeSignal(doc); const expected: object = { kind: 'signal' }; @@ -25,7 +24,6 @@ describe('buildEventTypeSignal', () => { test('it returns the event appended of kind signal if it is an empty object', () => { const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = {}; const eventType = buildEventTypeSignal(doc); const expected: object = { kind: 'signal' }; @@ -34,7 +32,6 @@ describe('buildEventTypeSignal', () => { test('it returns the event with kind signal and other properties if they exist', () => { const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', module: 'system', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index 3f4a17dc091ab..28cea9ea22b0d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -24,12 +24,6 @@ describe('create_signals', () => { size: 100, ignore_unavailable: true, body: { - docvalue_fields: [ - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], query: { bool: { filter: [ @@ -67,6 +61,10 @@ describe('create_signals', () => { field: '*', include_unmapped: true, }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, ], sort: [ { @@ -96,16 +94,6 @@ describe('create_signals', () => { size: 100, ignore_unavailable: true, body: { - docvalue_fields: [ - { - field: 'event.ingested', - format: 'strict_date_optional_time', - }, - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], query: { bool: { filter: [ @@ -167,6 +155,14 @@ describe('create_signals', () => { field: '*', include_unmapped: true, }, + { + field: 'event.ingested', + format: 'strict_date_optional_time', + }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, ], sort: [ { @@ -203,12 +199,6 @@ describe('create_signals', () => { size: 100, ignore_unavailable: true, body: { - docvalue_fields: [ - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], query: { bool: { filter: [ @@ -246,6 +236,10 @@ describe('create_signals', () => { field: '*', include_unmapped: true, }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, ], search_after: [fakeSortId], sort: [ @@ -276,12 +270,6 @@ describe('create_signals', () => { size: 100, ignore_unavailable: true, body: { - docvalue_fields: [ - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], query: { bool: { filter: [ @@ -319,6 +307,10 @@ describe('create_signals', () => { field: '*', include_unmapped: true, }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, ], search_after: [fakeSortIdNumber], sort: [ @@ -348,12 +340,6 @@ describe('create_signals', () => { size: 100, ignore_unavailable: true, body: { - docvalue_fields: [ - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], query: { bool: { filter: [ @@ -391,6 +377,10 @@ describe('create_signals', () => { field: '*', include_unmapped: true, }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, ], sort: [ { @@ -427,7 +417,6 @@ describe('create_signals', () => { size: 100, ignore_unavailable: true, body: { - docvalue_fields: [{ field: '@timestamp', format: 'strict_date_optional_time' }], query: { bool: { filter: [ @@ -465,6 +454,10 @@ describe('create_signals', () => { field: '*', include_unmapped: true, }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, ], aggregations: { tags: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index 86fb51e4785ad..0414439580361 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -17,7 +17,7 @@ interface BuildEventsSearchQuery { index: string[]; from: string; to: string; - filter?: estypes.QueryContainer; + filter: estypes.QueryContainer; size: number; sortOrder?: SortOrderOrUndefined; searchAfterSortIds: SortResults | undefined; @@ -94,8 +94,6 @@ export const buildEventsSearchQuery = ({ ]; const filterWithTime: estypes.QueryContainer[] = [ - // but tests contain undefined, so I suppose it's desired behaviour - // @ts-expect-error undefined in not assignable to QueryContainer filter, { bool: { filter: [{ bool: { should: [...rangeFilter], minimum_should_match: 1 } }] } }, ]; @@ -106,7 +104,6 @@ export const buildEventsSearchQuery = ({ size, ignore_unavailable: true, body: { - docvalue_fields: docFields, query: { bool: { filter: [ @@ -122,6 +119,7 @@ export const buildEventsSearchQuery = ({ field: '*', include_unmapped: true, }, + ...docFields, ], ...(aggregations ? { aggregations } : {}), sort: [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts index 412ccf7a40e33..bd5444a325128 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts @@ -134,7 +134,6 @@ describe('buildRuleWithOverrides', () => { }, ]; const doc = sampleDocNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.new_risk_score = newRiskScore; const rule = buildRuleWithOverrides(ruleSO, doc._source!); const expected = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts index 6408b5fe9de10..3a30da170d3f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts @@ -28,7 +28,6 @@ describe('buildSignal', () => { test('it builds a signal as expected without original_event if event does not exist', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.event; const rule = getRulesSchemaMock(); const signal = { @@ -61,7 +60,7 @@ describe('buildSignal', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', status: 'open', rule: { author: [], @@ -105,7 +104,6 @@ describe('buildSignal', () => { test('it builds a signal as expected with original_event if is present', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', dataset: 'socket', @@ -143,7 +141,7 @@ describe('buildSignal', () => { depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', + original_time: '2020-04-20T21:27:45.000Z', original_event: { action: 'socket_opened', dataset: 'socket', @@ -193,7 +191,6 @@ describe('buildSignal', () => { test('it builds a ancestor correctly if the parent does not exist', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', dataset: 'socket', @@ -212,14 +209,12 @@ describe('buildSignal', () => { test('it builds a ancestor correctly if the parent does exist', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', dataset: 'socket', kind: 'event', module: 'system', }; - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.signal = { parents: [ { @@ -255,7 +250,6 @@ describe('buildSignal', () => { test('it builds a signal ancestor correctly if the parent does not exist', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', dataset: 'socket', @@ -276,14 +270,12 @@ describe('buildSignal', () => { test('it builds a signal ancestor correctly if the parent does exist', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.event = { action: 'socket_opened', dataset: 'socket', kind: 'event', module: 'system', }; - // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.signal = { parents: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts index 237536a99c0f0..a415c83e857c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts @@ -10,6 +10,7 @@ import { RulesSchema } from '../../../../common/detection_engine/schemas/respons import { SIGNALS_TEMPLATE_VERSION } from '../routes/index/get_signals_template'; import { isEventTypeSignal } from './build_event_type_signal'; import { Signal, Ancestor, BaseSignalHit, ThresholdResult } from './types'; +import { getValidDateFromDoc } from './utils'; /** * Takes a parent signal or event document and extracts the information needed for the corresponding entry in the child @@ -103,6 +104,7 @@ const isThresholdResult = (thresholdResult: SearchTypes): thresholdResult is Thr /** * Creates signal fields that are only available in the special case where a signal has only 1 parent signal/event. + * We copy the original time from the document as "original_time" since we override the timestamp with the current date time. * @param doc The parent signal/event of the new signal to be built. */ export const additionalSignalFields = (doc: BaseSignalHit) => { @@ -110,10 +112,13 @@ export const additionalSignalFields = (doc: BaseSignalHit) => { if (thresholdResult != null && !isThresholdResult(thresholdResult)) { throw new Error(`threshold_result failed to validate: ${thresholdResult}`); } + const originalTime = getValidDateFromDoc({ + doc, + timestampOverride: undefined, + }); return { parent: buildParent(removeClashes(doc)), - // @ts-expect-error @elastic/elasticsearch _source is optional - original_time: doc._source['@timestamp'], // This field has already been replaced with timestampOverride, if provided. + original_time: originalTime != null ? originalTime.toISOString() : undefined, original_event: doc._source?.event ?? undefined, threshold_result: thresholdResult, original_signal: diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/mappings/build_rule_name_from_mapping.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/mappings/build_rule_name_from_mapping.test.ts index b6281b637d434..23e5aecc5c553 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/mappings/build_rule_name_from_mapping.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/mappings/build_rule_name_from_mapping.test.ts @@ -15,7 +15,6 @@ describe('buildRuleNameFromMapping', () => { test('rule name defaults to provided if mapping is incomplete', () => { const ruleName = buildRuleNameFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional eventSource: sampleDocNoSortId()._source, ruleName: 'rule-name', ruleNameMapping: 'message', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts index a40459d312b9f..a67016491aaef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts @@ -41,7 +41,7 @@ describe('singleSearchAfter', () => { services: mockService, logger: mockLogger, pageSize: 1, - filter: undefined, + filter: {}, timestampOverride: undefined, buildRuleMessage, }); @@ -59,7 +59,7 @@ describe('singleSearchAfter', () => { services: mockService, logger: mockLogger, pageSize: 1, - filter: undefined, + filter: {}, timestampOverride: undefined, buildRuleMessage, }); @@ -109,7 +109,7 @@ describe('singleSearchAfter', () => { services: mockService, logger: mockLogger, pageSize: 1, - filter: undefined, + filter: {}, timestampOverride: undefined, buildRuleMessage, }); @@ -132,7 +132,7 @@ describe('singleSearchAfter', () => { services: mockService, logger: mockLogger, pageSize: 1, - filter: undefined, + filter: {}, timestampOverride: undefined, buildRuleMessage, }); @@ -152,7 +152,7 @@ describe('singleSearchAfter', () => { services: mockService, logger: mockLogger, pageSize: 1, - filter: undefined, + filter: {}, timestampOverride: undefined, buildRuleMessage, }) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index 57ed05bcb27cf..ae22964eced92 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -32,7 +32,7 @@ interface SingleSearchAfterParams { logger: Logger; pageSize: number; sortOrder?: SortOrderOrUndefined; - filter?: estypes.QueryContainer; + filter: estypes.QueryContainer; timestampOverride: TimestampOverrideOrUndefined; buildRuleMessage: BuildRuleMessage; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index f49492939eeb1..60bf0ec337f3d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -40,6 +40,7 @@ import { lastValidDate, calculateThresholdSignalUuid, buildChunkedOrFilter, + getValidDateFromDoc, } from './utils'; import { BulkResponseErrorAggregation, SearchAfterAndBulkCreateReturnType } from './types'; import { @@ -54,6 +55,7 @@ import { sampleDocSearchResultsNoSortIdNoHits, repeatedSearchResultsWithSortId, sampleDocSearchResultsNoSortId, + sampleDocNoSortId, } from './__mocks__/es_results'; import { ShardError } from '../../types'; @@ -1172,7 +1174,6 @@ describe('utils', () => { test('It will not set an invalid date time stamp from a non-existent @timestamp when the index is not 100% ECS compliant', () => { const searchResult = sampleDocSearchResultsNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = undefined; if (searchResult.hits.hits[0].fields != null) { (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = undefined; @@ -1186,7 +1187,6 @@ describe('utils', () => { test('It will not set an invalid date time stamp from a null @timestamp when the index is not 100% ECS compliant', () => { const searchResult = sampleDocSearchResultsNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = null; if (searchResult.hits.hits[0].fields != null) { (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = null; @@ -1200,7 +1200,6 @@ describe('utils', () => { test('It will not set an invalid date time stamp from an invalid @timestamp string', () => { const searchResult = sampleDocSearchResultsNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = 'invalid'; if (searchResult.hits.hits[0].fields != null) { (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = ['invalid']; @@ -1216,7 +1215,6 @@ describe('utils', () => { describe('lastValidDate', () => { test('It returns undefined if the search result contains a null timestamp', () => { const searchResult = sampleDocSearchResultsNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = null; if (searchResult.hits.hits[0].fields != null) { (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = null; @@ -1227,7 +1225,6 @@ describe('utils', () => { test('It returns undefined if the search result contains a undefined timestamp', () => { const searchResult = sampleDocSearchResultsNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = undefined; if (searchResult.hits.hits[0].fields != null) { (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = undefined; @@ -1238,7 +1235,6 @@ describe('utils', () => { test('It returns undefined if the search result contains an invalid string value', () => { const searchResult = sampleDocSearchResultsNoSortId(); - // @ts-expect-error @elastic/elasticsearch _source is optional (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = 'invalid value'; if (searchResult.hits.hits[0].fields != null) { (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = ['invalid value']; @@ -1294,6 +1290,84 @@ describe('utils', () => { }); }); + describe('getValidDateFromDoc', () => { + test('It returns undefined if the search result contains a null timestamp', () => { + const doc = sampleDocNoSortId(); + (doc._source['@timestamp'] as unknown) = null; + if (doc.fields != null) { + (doc.fields['@timestamp'] as unknown) = null; + } + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date).toEqual(undefined); + }); + + test('It returns undefined if the search result contains a undefined timestamp', () => { + const doc = sampleDocNoSortId(); + (doc._source['@timestamp'] as unknown) = undefined; + if (doc.fields != null) { + (doc.fields['@timestamp'] as unknown) = undefined; + } + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date).toEqual(undefined); + }); + + test('It returns undefined if the search result contains an invalid string value', () => { + const doc = sampleDocNoSortId(); + (doc._source['@timestamp'] as unknown) = 'invalid value'; + if (doc.fields != null) { + (doc.fields['@timestamp'] as unknown) = ['invalid value']; + } + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date).toEqual(undefined); + }); + + test('It returns normal date time if set', () => { + const doc = sampleDocNoSortId(); + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date?.toISOString()).toEqual('2020-04-20T21:27:45.000Z'); + }); + + test('It returns date time from field if set there', () => { + const timestamp = '2020-10-07T19:27:19.136Z'; + let doc = sampleDocNoSortId(); + if (doc == null) { + throw new TypeError('Test requires one element'); + } + doc = { + ...doc, + fields: { + '@timestamp': [timestamp], + }, + }; + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date?.toISOString()).toEqual(timestamp); + }); + + test('It returns timestampOverride date time if set', () => { + const override = '2020-10-07T19:20:28.049Z'; + const doc = sampleDocNoSortId(); + doc._source.different_timestamp = new Date(override).toISOString(); + const date = getValidDateFromDoc({ doc, timestampOverride: 'different_timestamp' }); + expect(date?.toISOString()).toEqual(override); + }); + + test('It returns timestampOverride date time from fields if set on it', () => { + const override = '2020-10-07T19:36:31.110Z'; + let doc = sampleDocNoSortId(); + if (doc == null) { + throw new TypeError('Test requires one element'); + } + doc = { + ...doc, + fields: { + different_timestamp: [override], + }, + }; + const date = getValidDateFromDoc({ doc, timestampOverride: 'different_timestamp' }); + expect(date?.toISOString()).toEqual(override); + }); + }); + describe('createSearchAfterReturnType', () => { test('createSearchAfterReturnType will return full object when nothing is passed', () => { const searchAfterReturnType = createSearchAfterReturnType(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index cc4ed6a45807b..dde9986e8bdf5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -38,6 +38,7 @@ import { Signal, WrappedSignalHit, RuleRangeTuple, + BaseSignalHit, } from './types'; import { BuildRuleMessage } from './rule_messages'; import { ShardError } from '../../types'; @@ -577,30 +578,49 @@ export const lastValidDate = ({ searchResult, timestampOverride, }: { - searchResult: estypes.SearchResponse; + searchResult: SignalSearchResponse; timestampOverride: TimestampOverrideOrUndefined; }): Date | undefined => { if (searchResult.hits.hits.length === 0) { return undefined; } else { const lastRecord = searchResult.hits.hits[searchResult.hits.hits.length - 1]; - const timestamp = timestampOverride ?? '@timestamp'; - const timestampValue = - lastRecord.fields != null && lastRecord.fields[timestamp] != null - ? lastRecord.fields[timestamp][0] - : // @ts-expect-error @elastic/elasticsearch _source is optional - lastRecord._source[timestamp]; - const lastTimestamp = - typeof timestampValue === 'string' || typeof timestampValue === 'number' - ? timestampValue - : undefined; - if (lastTimestamp != null) { - const tempMoment = moment(lastTimestamp); - if (tempMoment.isValid()) { - return tempMoment.toDate(); - } else { - return undefined; - } + return getValidDateFromDoc({ doc: lastRecord, timestampOverride }); + } +}; + +/** + * Given a search hit this will return a valid last date if it can find one, otherwise it + * will return undefined. This tries the "fields" first to get a formatted date time if it can, but if + * it cannot it will resort to using the "_source" fields second which can be problematic if the date time + * is not correctly ISO8601 or epoch milliseconds formatted. + * @param searchResult The result to try and parse out the timestamp. + * @param timestampOverride The timestamp override to use its values if we have it. + */ +export const getValidDateFromDoc = ({ + doc, + timestampOverride, +}: { + doc: BaseSignalHit; + timestampOverride: TimestampOverrideOrUndefined; +}): Date | undefined => { + const timestamp = timestampOverride ?? '@timestamp'; + const timestampValue = + doc.fields != null && doc.fields[timestamp] != null + ? doc.fields[timestamp][0] + : doc._source != null + ? doc._source[timestamp] + : undefined; + const lastTimestamp = + typeof timestampValue === 'string' || typeof timestampValue === 'number' + ? timestampValue + : undefined; + if (lastTimestamp != null) { + const tempMoment = moment(lastTimestamp); + if (tempMoment.isValid()) { + return tempMoment.toDate(); + } else { + return undefined; } } }; @@ -609,7 +629,7 @@ export const createSearchAfterReturnTypeFromResponse = ({ searchResult, timestampOverride, }: { - searchResult: estypes.SearchResponse; + searchResult: SignalSearchResponse; timestampOverride: TimestampOverrideOrUndefined; }): SearchAfterAndBulkCreateReturnType => { return createSearchAfterReturnType({ diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts index 8f1332ccd1f9f..ee185ac6ce6bb 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts @@ -20,10 +20,11 @@ import { mockLogger } from '../test_utils'; import { TaskClaiming, OwnershipClaimingOpts, TaskClaimingOpts } from './task_claiming'; import { Observable } from 'rxjs'; import { taskStoreMock } from '../task_store.mock'; +import apm from 'elastic-apm-node'; const taskManagerLogger = mockLogger(); -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => jest.clearAllMocks()); const mockedDate = new Date('2019-02-12T21:01:22.479Z'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -52,7 +53,19 @@ taskDefinitions.registerTaskDefinitions({ }, }); +const mockApmTrans = { + end: jest.fn(), +}; + describe('TaskClaiming', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(apm, 'startTransaction') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation(() => mockApmTrans as any); + }); + test(`should log when a certain task type is skipped due to having a zero concurency configuration`, () => { const definitions = new TaskTypeDictionary(mockLogger()); definitions.registerTaskDefinitions({ @@ -169,6 +182,12 @@ describe('TaskClaiming', () => { const results = await getAllAsPromise(taskClaiming.claimAvailableTasks(claimingOpts)); + expect(apm.startTransaction).toHaveBeenCalledWith( + 'markAvailableTasksAsClaimed', + 'taskManager markAvailableTasksAsClaimed' + ); + expect(mockApmTrans.end).toHaveBeenCalledWith('success'); + expect(store.updateByQuery.mock.calls[0][1]).toMatchObject({ max_docs: getCapacity(), }); @@ -187,6 +206,49 @@ describe('TaskClaiming', () => { })); } + test('makes calls to APM as expected when markAvailableTasksAsClaimed throws error', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts: { + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + }); + + store.updateByQuery.mockRejectedValue(new Error('Oh no')); + + await expect( + getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimOwnershipUntil: new Date(), + }) + ) + ).rejects.toMatchInlineSnapshot(`[Error: Oh no]`); + + expect(apm.startTransaction).toHaveBeenCalledWith( + 'markAvailableTasksAsClaimed', + 'taskManager markAvailableTasksAsClaimed' + ); + expect(mockApmTrans.end).toHaveBeenCalledWith('failure'); + }); + test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { const maxAttempts = _.random(2, 43); const customMaxAttempts = _.random(44, 100); @@ -1105,6 +1167,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: 'parent', }, { id: 'claimed-by-schedule', @@ -1121,6 +1184,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: 'newParent', }, { id: 'already-running', @@ -1137,6 +1201,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: '', }, ]; @@ -1222,6 +1287,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: 'parent', }) ) ); @@ -1277,6 +1343,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: '', }, ], // second cycle @@ -1296,6 +1363,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: '', }, ], ], @@ -1347,6 +1415,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: '', }), errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, }) @@ -1393,6 +1462,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: 'newParent', }) ) ); @@ -1437,6 +1507,7 @@ if (doc['task.runAt'].size()!=0) { startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: '', }), errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, }) @@ -1499,6 +1570,7 @@ function mockInstance(instance: Partial = {}) { status: 'idle', user: 'example', ownerId: null, + traceparent: '', }, instance ); diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts index dce7824281658..7f15707a14b30 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -379,34 +379,41 @@ export class TaskClaiming { sort.unshift('_score'); } - const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); - const result = await this.taskStore.updateByQuery( - { - query: matchesClauses( - claimTasksById && claimTasksById.length - ? mustBeAllOf(asPinnedQuery(claimTasksById, queryForScheduledTasks)) - : queryForScheduledTasks, - filterDownBy(InactiveTasks) - ), - script: updateFieldsAndMarkAsFailed( - { - ownerId: this.taskStore.taskManagerId, - retryAt: claimOwnershipUntil, - }, - claimTasksById || [], - taskTypesToClaim, - taskTypesToSkip, - pick(this.taskMaxAttempts, taskTypesToClaim) - ), - sort, - }, - { - max_docs: size, - } + const apmTrans = apm.startTransaction( + 'markAvailableTasksAsClaimed', + `taskManager markAvailableTasksAsClaimed` ); - - if (apmTrans) apmTrans.end(); - return result; + try { + const result = await this.taskStore.updateByQuery( + { + query: matchesClauses( + claimTasksById && claimTasksById.length + ? mustBeAllOf(asPinnedQuery(claimTasksById, queryForScheduledTasks)) + : queryForScheduledTasks, + filterDownBy(InactiveTasks) + ), + script: updateFieldsAndMarkAsFailed( + { + ownerId: this.taskStore.taskManagerId, + retryAt: claimOwnershipUntil, + }, + claimTasksById || [], + taskTypesToClaim, + taskTypesToSkip, + pick(this.taskMaxAttempts, taskTypesToClaim) + ), + sort, + }, + { + max_docs: size, + } + ); + apmTrans?.end('success'); + return result; + } catch (err) { + apmTrans?.end('failure'); + throw err; + } } /** diff --git a/x-pack/plugins/task_manager/server/saved_objects/mappings.json b/x-pack/plugins/task_manager/server/saved_objects/mappings.json index 1728c8f1c552b..d046a9266cce5 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/mappings.json +++ b/x-pack/plugins/task_manager/server/saved_objects/mappings.json @@ -29,6 +29,9 @@ "status": { "type": "keyword" }, + "traceparent": { + "type": "text" + }, "params": { "type": "text" }, diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index 4b86943ff8eca..8f515e1951ef5 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -257,6 +257,11 @@ export interface TaskInstance { // eslint-disable-next-line @typescript-eslint/no-explicit-any state: Record; + /** + * The serialized traceparent string of the current APM transaction or span. + */ + traceparent?: string; + /** * The id of the user who scheduled this task. */ @@ -364,6 +369,7 @@ export type SerializedConcreteTaskInstance = Omit< > & { state: string; params: string; + traceparent: string; scheduledAt: string; startedAt: string | null; retryAt: string | null; diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index 5a36d6affe686..d5a86b532b0ae 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -18,6 +18,7 @@ import { TaskDefinitionRegistry, TaskTypeDictionary } from '../task_type_diction import { mockLogger } from '../test_utils'; import { throwUnrecoverableError } from './errors'; import { taskStoreMock } from '../task_store.mock'; +import apm from 'elastic-apm-node'; const minutesFromNow = (mins: number): Date => secondsFromNow(mins * 60); @@ -32,8 +33,70 @@ afterAll(() => fakeTimer.restore()); describe('TaskManagerRunner', () => { const pendingStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.PENDING, opts); const readyToRunStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.READY_TO_RUN, opts); + const mockApmTrans = { + end: jest.fn(), + }; describe('Pending Stage', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(apm, 'startTransaction') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation(() => mockApmTrans as any); + }); + test('makes calls to APM as expected when task markedAsRunning is success', async () => { + const { runner } = await pendingStageSetup({ + instance: { + schedule: { + interval: '10m', + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); + await runner.markTaskAsRunning(); + expect(apm.startTransaction).toHaveBeenCalledWith( + 'taskManager', + 'taskManager markTaskAsRunning' + ); + expect(mockApmTrans.end).toHaveBeenCalledWith('success'); + }); + test('makes calls to APM as expected when task markedAsRunning fails', async () => { + const { runner, store } = await pendingStageSetup({ + instance: { + schedule: { + interval: '10m', + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); + // await runner.markTaskAsRunning(); + expect(apm.startTransaction).toHaveBeenCalledWith( + 'taskManager', + 'taskManager markTaskAsRunning' + ); + expect(mockApmTrans.end).toHaveBeenCalledWith('failure'); + }); test('provides details about the task that is running', async () => { const { runner } = await pendingStageSetup({ instance: { @@ -572,6 +635,55 @@ describe('TaskManagerRunner', () => { }); describe('Ready To Run Stage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('makes calls to APM as expected when task runs successfully', async () => { + const { runner } = await readyToRunStageSetup({ + instance: { + params: { a: 'b' }, + state: { hey: 'there' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); + await runner.run(); + expect(apm.startTransaction).toHaveBeenCalledWith('bar', 'taskManager run', { + childOf: 'apmTraceparent', + }); + expect(mockApmTrans.end).toHaveBeenCalledWith('success'); + }); + test('makes calls to APM as expected when task fails', async () => { + const { runner } = await readyToRunStageSetup({ + instance: { + params: { a: 'b' }, + state: { hey: 'there' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw new Error('rar'); + }, + }), + }, + }, + }); + await runner.run(); + expect(apm.startTransaction).toHaveBeenCalledWith('bar', 'taskManager run', { + childOf: 'apmTraceparent', + }); + expect(mockApmTrans.end).toHaveBeenCalledWith('failure'); + }); test('queues a reattempt if the task fails', async () => { const initialAttempts = _.random(0, 2); const id = Date.now().toString(); @@ -1275,6 +1387,7 @@ describe('TaskManagerRunner', () => { status: 'idle', user: 'example', ownerId: null, + traceparent: 'apmTraceparent', }, instance ); diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index 8e061eae46028..fc88a66329170 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -12,6 +12,7 @@ */ import apm from 'elastic-apm-node'; +import { withSpan } from '@kbn/apm-utils'; import { performance } from 'perf_hooks'; import { identity, defaults, flow } from 'lodash'; import { Logger, SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -242,30 +243,38 @@ export class TaskManagerRunner implements TaskRunner { ); } this.logger.debug(`Running task ${this}`); + + const apmTrans = apm.startTransaction(this.taskType, 'taskManager run', { + childOf: this.instance.task.traceparent, + }); + const modifiedContext = await this.beforeRun({ taskInstance: this.instance.task, }); const stopTaskTimer = startTaskTimer(); - const apmTrans = apm.startTransaction(`taskManager run`, 'taskManager'); - apmTrans?.addLabels({ - taskType: this.taskType, - }); + try { this.task = this.definition.createTaskRunner(modifiedContext); - const result = await this.task.run(); + const result = await withSpan({ name: 'run', type: 'task manager' }, () => this.task!.run()); const validatedResult = this.validateResult(result); + const processedResult = await withSpan({ name: 'process result', type: 'task manager' }, () => + this.processResult(validatedResult, stopTaskTimer()) + ); if (apmTrans) apmTrans.end('success'); - return this.processResult(validatedResult, stopTaskTimer()); + return processedResult; } catch (err) { this.logger.error(`Task ${this} failed: ${err}`); // in error scenario, we can not get the RunResult // re-use modifiedContext's state, which is correct as of beforeRun - if (apmTrans) apmTrans.end('error'); - return this.processResult( - asErr({ error: err, state: modifiedContext.taskInstance.state }), - stopTaskTimer() + const processedResult = await withSpan({ name: 'process result', type: 'task manager' }, () => + this.processResult( + asErr({ error: err, state: modifiedContext.taskInstance.state }), + stopTaskTimer() + ) ); + if (apmTrans) apmTrans.end('failure'); + return processedResult; } } @@ -285,10 +294,7 @@ export class TaskManagerRunner implements TaskRunner { } performance.mark('markTaskAsRunning_start'); - const apmTrans = apm.startTransaction(`taskManager markTaskAsRunning`, 'taskManager'); - apmTrans?.addLabels({ - taskType: this.taskType, - }); + const apmTrans = apm.startTransaction('taskManager', 'taskManager markTaskAsRunning'); const now = new Date(); try { diff --git a/x-pack/plugins/task_manager/server/task_scheduling.test.ts b/x-pack/plugins/task_manager/server/task_scheduling.test.ts index b142f2091291e..3445bd18de102 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.test.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.test.ts @@ -27,6 +27,10 @@ import { TaskRunResult } from './task_running'; import { mockLogger } from './test_utils'; import { TaskTypeDictionary } from './task_type_dictionary'; +jest.mock('elastic-apm-node', () => ({ + currentTraceparent: 'parent', +})); + describe('TaskScheduling', () => { const mockTaskStore = taskStoreMock.create({}); const mockTaskManager = taskPollingLifecycleMock.create({}); @@ -60,6 +64,12 @@ describe('TaskScheduling', () => { }; await taskScheduling.schedule(task); expect(mockTaskStore.schedule).toHaveBeenCalled(); + expect(mockTaskStore.schedule).toHaveBeenCalledWith({ + ...task, + id: undefined, + schedule: undefined, + traceparent: 'parent', + }); }); test('allows scheduling existing tasks that may have already been scheduled', async () => { @@ -420,6 +430,7 @@ function mockTask(overrides: Partial = {}): ConcreteTaskIn startedAt: null, retryAt: null, scheduledAt: new Date(), + traceparent: 'taskTraceparent', ...overrides, }; } diff --git a/x-pack/plugins/task_manager/server/task_scheduling.ts b/x-pack/plugins/task_manager/server/task_scheduling.ts index 29e83ec911b79..153c16f5c4bf7 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.ts @@ -10,6 +10,7 @@ import { filter } from 'rxjs/operators'; import { pipe } from 'fp-ts/lib/pipeable'; import { Option, map as mapOptional, getOrElse, isSome } from 'fp-ts/lib/Option'; +import agent from 'elastic-apm-node'; import { Logger } from '../../../../src/core/server'; import { asOk, either, map, mapErr, promiseResult } from './lib/result_type'; import { @@ -85,7 +86,10 @@ export class TaskScheduling { ...options, taskInstance: ensureDeprecatedFieldsAreCorrected(taskInstance, this.logger), }); - return await this.store.schedule(modifiedTask); + return await this.store.schedule({ + ...modifiedTask, + traceparent: agent.currentTraceparent ?? '', + }); } /** diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index a44bddcdb8201..2d2bd81af96e5 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -94,6 +94,7 @@ describe('TaskStore', () => { params: { hello: 'world' }, state: { foo: 'bar' }, taskType: 'report', + traceparent: 'apmTraceparent', }; const result = await testSchedule(task); @@ -112,6 +113,7 @@ describe('TaskStore', () => { status: 'idle', taskType: 'report', user: undefined, + traceparent: 'apmTraceparent', }, { id: 'id', @@ -134,6 +136,7 @@ describe('TaskStore', () => { taskType: 'report', user: undefined, version: '123', + traceparent: 'apmTraceparent', }); }); @@ -285,6 +288,7 @@ describe('TaskStore', () => { status: 'idle' as TaskStatus, version: '123', ownerId: null, + traceparent: 'myTraceparent', }; savedObjectsClient.update.mockImplementation( @@ -318,6 +322,7 @@ describe('TaskStore', () => { taskType: task.taskType, user: undefined, ownerId: null, + traceparent: 'myTraceparent', }, { version: '123', refresh: false } ); @@ -347,6 +352,7 @@ describe('TaskStore', () => { status: 'idle' as TaskStatus, version: '123', ownerId: null, + traceparent: '', }; const firstErrorPromise = store.errors$.pipe(first()).toPromise(); @@ -384,6 +390,7 @@ describe('TaskStore', () => { status: 'idle' as TaskStatus, version: '123', ownerId: null, + traceparent: '', }; const firstErrorPromise = store.errors$.pipe(first()).toPromise(); @@ -500,6 +507,7 @@ describe('TaskStore', () => { status: status as TaskStatus, version: '123', ownerId: null, + traceparent: 'myTraceparent', }; savedObjectsClient.get.mockImplementation(async (type: string, objectId: string) => ({ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 92ffe3419ba17..fb9c3ef740793 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7532,7 +7532,6 @@ "xpack.enterpriseSearch.actions.updateButtonLabel": "更新", "xpack.enterpriseSearch.appSearch.actions.restoreDefaultsButonLabel": "デフォルトを復元", "xpack.enterpriseSearch.appSearch.adminRoleTypeDescription": "アカウント設定の管理を除き、管理者はすべての操作を実行できます。", - "xpack.enterpriseSearch.appSearch.advancedRoleSelectorsTitle": "完全または限定エンジンアクセス", "xpack.enterpriseSearch.appSearch.analystRoleTypeDescription": "アナリストは、ドキュメント、クエリテスト、分析のみを表示できます。", "xpack.enterpriseSearch.appSearch.credentials.apiEndpoint": "エンドポイント", "xpack.enterpriseSearch.appSearch.credentials.apiKeys": "API キー", @@ -7831,7 +7830,6 @@ "xpack.enterpriseSearch.appSearch.engine.searchUI.title": "Search UI", "xpack.enterpriseSearch.appSearch.engine.synonyms.title": "同義語", "xpack.enterpriseSearch.appSearch.engine.universalLanguage": "ユニバーサル", - "xpack.enterpriseSearch.appSearch.engineAccessTitle": "エンジンアクセス", "xpack.enterpriseSearch.appSearch.engineCreation.form.engineLanguage.label": "エンジン言語", "xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.allowedCharactersHelpText": "エンジン名には、小文字、数字、ハイフンのみを使用できます。", "xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.label": "エンジン名", @@ -7873,10 +7871,6 @@ "xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language": "言語", "xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name": "名前", "xpack.enterpriseSearch.appSearch.enginesOverview.title": "エンジン概要", - "xpack.enterpriseSearch.appSearch.fullEngineAccessDescription": "すべての現在のエンジンと将来のエンジンにアクセスします。", - "xpack.enterpriseSearch.appSearch.fullEngineAccessTitle": "完全エンジンアクセス", - "xpack.enterpriseSearch.appSearch.limitedEngineAccessDescription": "ユーザーアクセスを特定のエンジンに制限します。", - "xpack.enterpriseSearch.appSearch.limitedEngineAccessTitle": "限定エンジンアクセス", "xpack.enterpriseSearch.appSearch.logRetention.callout.description.manageSettingsDetail": "分析とログを管理するには、{visitSettingsLink}してください。", "xpack.enterpriseSearch.appSearch.logRetention.callout.description.manageSettingsLinkText": "設定を表示", "xpack.enterpriseSearch.appSearch.logRetention.callout.disabledSinceTitle": "{logsTitle}は、{disabledDate}以降に無効にされました。", @@ -7917,8 +7911,6 @@ "xpack.enterpriseSearch.appSearch.result.hideAdditionalFields": "追加フィールドを非表示", "xpack.enterpriseSearch.appSearch.result.title": "ドキュメント{id}", "xpack.enterpriseSearch.appSearch.roleMapping.emptyRoleMappingsBody": "認証が成功したすべてのユーザーには所有者ロールが割り当てられ、すべてのエンジンにアクセスできます。デフォルト設定を無効にするには、新しいロールを追加します。", - "xpack.enterpriseSearch.appSearch.roleMapping.saveRoleMappingButtonLabel": "ロールマッピングの保存", - "xpack.enterpriseSearch.appSearch.roleMapping.updateRoleMappingButtonLabel": "ロールマッピングを更新", "xpack.enterpriseSearch.appSearch.roleMappingCreatedMessage": "ロールマッピングが正常に作成されました。", "xpack.enterpriseSearch.appSearch.roleMappingDeletedMessage": "ロールマッピングが正常に削除されました", "xpack.enterpriseSearch.appSearch.roleMappingsEngineAccessHeading": "エンジンアクセス", @@ -7927,7 +7919,6 @@ "xpack.enterpriseSearch.appSearch.roleMappingsResetConfirmButton": "ロールマッピングをリセット", "xpack.enterpriseSearch.appSearch.roleMappingsResetConfirmTitle": "ロールマッピングをリセットしますか?", "xpack.enterpriseSearch.appSearch.roleMappingUpdatedMessage": "ロールマッピングが正常に更新されました。", - "xpack.enterpriseSearch.appSearch.roleTitle": "ロール", "xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.buttonLabel": "サンプルエンジンを試す", "xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.description": "サンプルデータでエンジンをテストします。", "xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.title": "確認している場合", @@ -8019,6 +8010,8 @@ "xpack.enterpriseSearch.roleMapping.roleLabel": "ロール", "xpack.enterpriseSearch.roleMapping.roleMappingsDescription": "elasticsearch-nativeおよびelasticsearch-saml認証のロールマッピングを定義します。", "xpack.enterpriseSearch.roleMapping.roleMappingsTitle": "ユーザーとロール", + "xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel": "ロールマッピングの保存", + "xpack.enterpriseSearch.roleMapping.updateRoleMappingButtonLabel": "ロールマッピングを更新", "xpack.enterpriseSearch.schema.addFieldModal.fieldNameNote.correct": "フィールド名には、小文字、数字、アンダースコアのみを使用できます。", "xpack.enterpriseSearch.schema.errorsTable.control.review": "見直し", "xpack.enterpriseSearch.schema.errorsTable.heading.error": "エラー", @@ -8324,11 +8317,8 @@ "xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName": "デフォルト", "xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage": "このマッピングを完全に削除しますか?このアクションは元に戻せません。一部のユーザーがアクセスを失う可能性があります。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.emptyRoleMappingsBody": "新しいチームメンバーにはデフォルトで管理者ロールが割り当てられます。管理者はすべてにアクセスできます。デフォルト設定を無効にするには、新しいロールを作成します。", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentAllGroupsLabel": "将来のグループを含むすべてのグループにあります。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError": "1つ以上の割り当てられたグループが必要です。", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentTitle": "グループ割り当て", "xpack.enterpriseSearch.workplaceSearch.roleMapping.roleMappingsTableHeader": "グループアクセス", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.saveRoleMappingButtonMessage": "{operation}ロールマッピング", "xpack.enterpriseSearch.workplaceSearch.roleMapping.userRoleTypeDescription": "ユーザーの機能アクセスは検索インターフェースと個人設定管理に制限されます。", "xpack.enterpriseSearch.workplaceSearch.roleMappingCreatedMessage": "ロールマッピングが正常に作成されました。", "xpack.enterpriseSearch.workplaceSearch.roleMappingDeletedMessage": "ロールマッピングが正常に削除されました", @@ -9246,7 +9236,6 @@ "xpack.fleet.namespaceValidation.requiredErrorMessage": "名前空間は必須です", "xpack.fleet.namespaceValidation.tooLongErrorMessage": "名前空間は100バイト以下でなければなりません", "xpack.fleet.newEnrollmentKey.cancelButtonLabel": "キャンセル", - "xpack.fleet.newEnrollmentKey.flyoutTitle": "登録トークンを作成", "xpack.fleet.newEnrollmentKey.keyCreatedToasts": "登録トークンが作成されました。", "xpack.fleet.newEnrollmentKey.nameLabel": "名前", "xpack.fleet.newEnrollmentKey.policyLabel": "ポリシー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 324b8463db21c..5bb918ecd2d67 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7591,7 +7591,6 @@ "xpack.enterpriseSearch.actions.updateButtonLabel": "更新", "xpack.enterpriseSearch.appSearch.actions.restoreDefaultsButonLabel": "还原默认值", "xpack.enterpriseSearch.appSearch.adminRoleTypeDescription": "管理员可以执行任何操作,但不包括管理帐户设置。", - "xpack.enterpriseSearch.appSearch.advancedRoleSelectorsTitle": "完全或有限的引擎访问", "xpack.enterpriseSearch.appSearch.analystRoleTypeDescription": "分析人员仅可以查看文档、查询测试器和分析。", "xpack.enterpriseSearch.appSearch.credentials.apiEndpoint": "终端", "xpack.enterpriseSearch.appSearch.credentials.apiKeys": "API 密钥", @@ -7896,7 +7895,6 @@ "xpack.enterpriseSearch.appSearch.engine.searchUI.title": "搜索 UI", "xpack.enterpriseSearch.appSearch.engine.synonyms.title": "同义词", "xpack.enterpriseSearch.appSearch.engine.universalLanguage": "通用", - "xpack.enterpriseSearch.appSearch.engineAccessTitle": "引擎访问", "xpack.enterpriseSearch.appSearch.engineCreation.form.engineLanguage.label": "引擎语言", "xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.allowedCharactersHelpText": "引擎名称只能包含小写字母、数字和连字符", "xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.label": "引擎名称", @@ -7939,10 +7937,6 @@ "xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language": "语言", "xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name": "名称", "xpack.enterpriseSearch.appSearch.enginesOverview.title": "引擎概览", - "xpack.enterpriseSearch.appSearch.fullEngineAccessDescription": "对所有当前和未来引擎的访问权限。", - "xpack.enterpriseSearch.appSearch.fullEngineAccessTitle": "完全的引擎访问权限", - "xpack.enterpriseSearch.appSearch.limitedEngineAccessDescription": "将用户访问限定于特定引擎:", - "xpack.enterpriseSearch.appSearch.limitedEngineAccessTitle": "有限的引擎访问权限", "xpack.enterpriseSearch.appSearch.logRetention.callout.description.manageSettingsDetail": "要管理分析和日志记录,请{visitSettingsLink}。", "xpack.enterpriseSearch.appSearch.logRetention.callout.description.manageSettingsLinkText": "访问您的设置", "xpack.enterpriseSearch.appSearch.logRetention.callout.disabledSinceTitle": "自 {disabledDate}后,{logsTitle} 已禁用。", @@ -7985,8 +7979,6 @@ "xpack.enterpriseSearch.appSearch.result.showAdditionalFields": "显示其他 {numberOfAdditionalFields, number} 个{numberOfAdditionalFields, plural, other {字段}}", "xpack.enterpriseSearch.appSearch.result.title": "文档 {id}", "xpack.enterpriseSearch.appSearch.roleMapping.emptyRoleMappingsBody": "成功验证的所有用户将被分配所有者角色,可访问所有引擎。添加新角色以覆盖默认值。", - "xpack.enterpriseSearch.appSearch.roleMapping.saveRoleMappingButtonLabel": "保存角色映射", - "xpack.enterpriseSearch.appSearch.roleMapping.updateRoleMappingButtonLabel": "更新角色映射", "xpack.enterpriseSearch.appSearch.roleMappingCreatedMessage": "角色映射已成功创建。", "xpack.enterpriseSearch.appSearch.roleMappingDeletedMessage": "已成功删除角色映射", "xpack.enterpriseSearch.appSearch.roleMappingsEngineAccessHeading": "引擎访问", @@ -7995,7 +7987,6 @@ "xpack.enterpriseSearch.appSearch.roleMappingsResetConfirmButton": "重置角色映射", "xpack.enterpriseSearch.appSearch.roleMappingsResetConfirmTitle": "确定要重置角色映射?", "xpack.enterpriseSearch.appSearch.roleMappingUpdatedMessage": "角色映射已成功更新。", - "xpack.enterpriseSearch.appSearch.roleTitle": "角色", "xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.buttonLabel": "试用示例引擎", "xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.description": "使用示例数据测试引擎。", "xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.title": "刚做过测试?", @@ -8087,6 +8078,8 @@ "xpack.enterpriseSearch.roleMapping.roleLabel": "角色", "xpack.enterpriseSearch.roleMapping.roleMappingsDescription": "为 elasticsearch-native 和 elasticsearch-saml 身份验证定义角色映射。", "xpack.enterpriseSearch.roleMapping.roleMappingsTitle": "用户和角色", + "xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel": "保存角色映射", + "xpack.enterpriseSearch.roleMapping.updateRoleMappingButtonLabel": "更新角色映射", "xpack.enterpriseSearch.schema.addFieldModal.fieldNameNote.correct": "字段名称只能包含小写字母、数字和下划线", "xpack.enterpriseSearch.schema.errorsTable.control.review": "复查", "xpack.enterpriseSearch.schema.errorsTable.heading.error": "错误", @@ -8392,11 +8385,8 @@ "xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName": "默认", "xpack.enterpriseSearch.workplaceSearch.roleMapping.deleteRoleMappingButtonMessage": "确定要永久删除此映射?此操作不可逆转,且某些用户可能会失去访问权限。", "xpack.enterpriseSearch.workplaceSearch.roleMapping.emptyRoleMappingsBody": "默认情况下,会为新团队成员分配管理员角色。管理员可以访问任何内容。超级新角色以覆盖默认值。", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentAllGroupsLabel": "加入所有组,包括未来组", "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentInvalidError": "至少需要一个分配的组。", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.groupAssignmentTitle": "组分配", "xpack.enterpriseSearch.workplaceSearch.roleMapping.roleMappingsTableHeader": "组访问权限", - "xpack.enterpriseSearch.workplaceSearch.roleMapping.saveRoleMappingButtonMessage": "{operation}角色映射", "xpack.enterpriseSearch.workplaceSearch.roleMapping.userRoleTypeDescription": "用户的功能访问权限仅限于搜索界面和个人设置管理。", "xpack.enterpriseSearch.workplaceSearch.roleMappingCreatedMessage": "角色映射已成功创建。", "xpack.enterpriseSearch.workplaceSearch.roleMappingDeletedMessage": "已成功删除角色映射", @@ -9331,7 +9321,6 @@ "xpack.fleet.namespaceValidation.requiredErrorMessage": "“命名空间”必填", "xpack.fleet.namespaceValidation.tooLongErrorMessage": "命名空间不能超过 100 个字节", "xpack.fleet.newEnrollmentKey.cancelButtonLabel": "取消", - "xpack.fleet.newEnrollmentKey.flyoutTitle": "创建注册令牌", "xpack.fleet.newEnrollmentKey.keyCreatedToasts": "注册令牌已创建。", "xpack.fleet.newEnrollmentKey.nameLabel": "名称", "xpack.fleet.newEnrollmentKey.policyLabel": "策略", diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts index 03ae0e6daf933..f7d7c1df8fd46 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -119,6 +119,7 @@ export default function ({ getService }: FtrProviderContext) { spaceId: space.id, connectorId: createdAction.id, outcome: 'success', + actionTypeId: 'test.index-record', message: `action executed: test.index-record:${createdAction.id}: My action`, }); break; @@ -502,13 +503,14 @@ export default function ({ getService }: FtrProviderContext) { interface ValidateEventLogParams { spaceId: string; connectorId: string; + actionTypeId: string; outcome: string; message: string; errorMessage?: string; } async function validateEventLog(params: ValidateEventLogParams): Promise { - const { spaceId, connectorId, outcome, message, errorMessage } = params; + const { spaceId, connectorId, actionTypeId, outcome, message, errorMessage } = params; const events: IValidatedEvent[] = await retry.try(async () => { return await getEventLog({ @@ -549,6 +551,7 @@ export default function ({ getService }: FtrProviderContext) { rel: 'primary', type: 'action', id: connectorId, + type_id: actionTypeId, namespace: spaceId, }, ]); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 24799888ac5b2..4aa6ed830059e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -214,6 +214,7 @@ instanceStateValue: true await validateEventLog({ spaceId: space.id, alertId, + ruleTypeId: 'test.always-firing', outcome: 'success', message: `alert executed: test.always-firing:${alertId}: 'abc'`, }); @@ -1244,13 +1245,14 @@ instanceStateValue: true interface ValidateEventLogParams { spaceId: string; alertId: string; + ruleTypeId: string; outcome: string; message: string; errorMessage?: string; } async function validateEventLog(params: ValidateEventLogParams): Promise { - const { spaceId, alertId, outcome, message, errorMessage } = params; + const { spaceId, alertId, ruleTypeId, outcome, message, errorMessage } = params; const events: IValidatedEvent[] = await retry.try(async () => { return await getEventLog({ @@ -1291,6 +1293,7 @@ instanceStateValue: true type: 'alert', id: alertId, namespace: spaceId, + type_id: ruleTypeId, }, ]); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts index 825ade55cb4b0..d5e55a66ecf08 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts @@ -75,7 +75,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { validateEvent(event, { spaceId, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + savedObjects: [{ type: 'alert', id: alertId, rel: 'primary', type_id: 'test.noop' }], outcome: 'failure', message: `test.noop:${alertId}: execution failed`, errorMessage: 'Unable to decrypt attribute "apiKey"', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index fbdde2104dd61..147b6abfb88d1 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { await validateEventLog({ spaceId: Spaces.space1.id, actionId: createdAction.id, + actionTypeId: 'test.index-record', outcome: 'success', message: `action executed: test.index-record:${createdAction.id}: My action`, }); @@ -138,6 +139,7 @@ export default function ({ getService }: FtrProviderContext) { await validateEventLog({ spaceId: Spaces.space1.id, actionId: createdAction.id, + actionTypeId: 'test.failing', outcome: 'failure', message: `action execution failure: test.failing:${createdAction.id}: failing action`, errorMessage: `an error occurred while running the action executor: expected failure for .kibana-alerting-test-data actions-failure-1:space1`, @@ -330,13 +332,14 @@ export default function ({ getService }: FtrProviderContext) { interface ValidateEventLogParams { spaceId: string; actionId: string; + actionTypeId: string; outcome: string; message: string; errorMessage?: string; } async function validateEventLog(params: ValidateEventLogParams): Promise { - const { spaceId, actionId, outcome, message, errorMessage } = params; + const { spaceId, actionId, actionTypeId, outcome, message, errorMessage } = params; const events: IValidatedEvent[] = await retry.try(async () => { return await getEventLog({ @@ -377,6 +380,7 @@ export default function ({ getService }: FtrProviderContext) { type: 'action', id: actionId, namespace: 'space1', + type_id: actionTypeId, }, ]); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index 5d54fe3d2b1f7..40c0fe398bc57 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -128,7 +128,9 @@ export default function eventLogTests({ getService }: FtrProviderContext) { case 'execute': validateEvent(event, { spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + savedObjects: [ + { type: 'alert', id: alertId, rel: 'primary', type_id: 'test.patternFiring' }, + ], outcome: 'success', message: `alert executed: test.patternFiring:${alertId}: 'abc'`, status: executeStatuses[executeCount++], @@ -138,8 +140,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { validateEvent(event, { spaceId: Spaces.space1.id, savedObjects: [ - { type: 'alert', id: alertId, rel: 'primary' }, - { type: 'action', id: createdAction.id }, + { type: 'alert', id: alertId, rel: 'primary', type_id: 'test.patternFiring' }, + { type: 'action', id: createdAction.id, type_id: 'test.noop' }, ], message: `alert: test.patternFiring:${alertId}: 'abc' instanceId: 'instance' scheduled actionGroup: 'default' action: test.noop:${createdAction.id}`, instanceId: 'instance', @@ -164,7 +166,9 @@ export default function eventLogTests({ getService }: FtrProviderContext) { function validateInstanceEvent(event: IValidatedEvent, subMessage: string) { validateEvent(event, { spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + savedObjects: [ + { type: 'alert', id: alertId, rel: 'primary', type_id: 'test.patternFiring' }, + ], message: `test.patternFiring:${alertId}: 'abc' ${subMessage}`, instanceId: 'instance', actionGroupId: 'default', @@ -260,7 +264,9 @@ export default function eventLogTests({ getService }: FtrProviderContext) { case 'execute': validateEvent(event, { spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + savedObjects: [ + { type: 'alert', id: alertId, rel: 'primary', type_id: 'test.patternFiring' }, + ], outcome: 'success', message: `alert executed: test.patternFiring:${alertId}: 'abc'`, status: executeStatuses[executeCount++], @@ -273,8 +279,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { validateEvent(event, { spaceId: Spaces.space1.id, savedObjects: [ - { type: 'alert', id: alertId, rel: 'primary' }, - { type: 'action', id: createdAction.id }, + { type: 'alert', id: alertId, rel: 'primary', type_id: 'test.patternFiring' }, + { type: 'action', id: createdAction.id, type_id: 'test.noop' }, ], message: `alert: test.patternFiring:${alertId}: 'abc' instanceId: 'instance' scheduled actionGroup(subgroup): 'default(${event?.kibana?.alerting?.action_subgroup})' action: test.noop:${createdAction.id}`, instanceId: 'instance', @@ -305,7 +311,9 @@ export default function eventLogTests({ getService }: FtrProviderContext) { function validateInstanceEvent(event: IValidatedEvent, subMessage: string) { validateEvent(event, { spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + savedObjects: [ + { type: 'alert', id: alertId, rel: 'primary', type_id: 'test.patternFiring' }, + ], message: `test.patternFiring:${alertId}: 'abc' ${subMessage}`, instanceId: 'instance', actionGroupId: 'default', @@ -345,7 +353,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { validateEvent(event, { spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + savedObjects: [{ type: 'alert', id: alertId, rel: 'primary', type_id: 'test.throw' }], outcome: 'failure', message: `alert execution failure: test.throw:${alertId}: 'abc'`, errorMessage: 'this alert is intended to fail', @@ -360,6 +368,7 @@ interface SavedObject { type: string; id: string; rel?: string; + type_id: string; } interface ValidateEventLogParams { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts new file mode 100644 index 0000000000000..d21253199d733 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + interface HostAlias { + name: string; + } + + describe('Tests involving aliases of source indexes and the signals index', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load('security_solution/alias'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('security_solution/alias'); + }); + + it('Should keep the original alias value such as "host_alias" from a source index when the value is indexed', async () => { + const rule = getRuleForSignalTesting(['alias']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map( + (signal) => (signal._source.host_alias as HostAlias).name + ); + expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); + }); + + // TODO: Make aliases work to where we can have ECS fields such as host.name filled out + it.skip('Should copy alias data from a source index into the signals index in the same position when the target is ECS compatible', async () => { + const rule = getRuleForSignalTesting(['alias']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((signal) => (signal._source.host as HostAlias).name); + expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index f9f378bc4bfa8..8638f6c1bd7ed 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -1616,119 +1616,6 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('Signals generated from events with timestamp override field and ensures search_after continues to work when documents are missing timestamp override field', () => { - beforeEach(async () => { - await createSignalsIndex(supertest); - await esArchiver.load('auditbeat/hosts'); - }); - - afterEach(async () => { - await deleteSignalsIndex(supertest); - await deleteAllAlerts(supertest); - await esArchiver.unload('auditbeat/hosts'); - }); - - /** - * This represents our worst case scenario where this field is not mapped on any index - * We want to check that our logic continues to function within the constraints of search after - * Elasticsearch returns java's long.MAX_VALUE for unmapped date fields - * Javascript does not support numbers this large, but without passing in a number of this size - * The search_after will continue to return the same results and not iterate to the next set - * So to circumvent this limitation of javascript we return the stringified version of Java's - * Long.MAX_VALUE so that search_after does not enter into an infinite loop. - * - * ref: https://github.com/elastic/elasticsearch/issues/28806#issuecomment-369303620 - */ - it('should generate 200 signals when timestamp override does not exist', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting(['auditbeat-*']), - timestamp_override: 'event.fakeingested', - max_signals: 200, - }; - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); - await waitForSignalsToBePresent(supertest, 200, [id]); - const signalsResponse = await getSignalsByIds(supertest, [id], 200); - const signals = signalsResponse.hits.hits.map((hit) => hit._source); - - expect(signals.length).equal(200); - }); - }); - - /** - * Here we test the functionality of timestamp overrides. If the rule specifies a timestamp override, - * then the documents will be queried and sorted using the timestamp override field. - * If no timestamp override field exists in the indices but one was provided to the rule, - * the rule's query will additionally search for events using the `@timestamp` field - */ - describe('Signals generated from events with timestamp override field', async () => { - beforeEach(async () => { - await deleteSignalsIndex(supertest); - await createSignalsIndex(supertest); - await esArchiver.load('security_solution/timestamp_override_1'); - await esArchiver.load('security_solution/timestamp_override_2'); - await esArchiver.load('security_solution/timestamp_override_3'); - await esArchiver.load('security_solution/timestamp_override_4'); - }); - - afterEach(async () => { - await deleteSignalsIndex(supertest); - await deleteAllAlerts(supertest); - await esArchiver.unload('security_solution/timestamp_override_1'); - await esArchiver.unload('security_solution/timestamp_override_2'); - await esArchiver.unload('security_solution/timestamp_override_3'); - await esArchiver.unload('security_solution/timestamp_override_4'); - }); - - it('should generate signals with event.ingested, @timestamp and (event.ingested + timestamp)', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting(['myfa*']), - timestamp_override: 'event.ingested', - }; - - const { id } = await createRule(supertest, rule); - - await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); - await waitForSignalsToBePresent(supertest, 3, [id]); - const signalsResponse = await getSignalsByIds(supertest, [id], 3); - const signals = signalsResponse.hits.hits.map((hit) => hit._source); - const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); - - expect(signalsOrderedByEventId.length).equal(3); - }); - - it('should generate 2 signals with @timestamp', async () => { - const rule: QueryCreateSchema = getRuleForSignalTesting(['myfa*']); - - const { id } = await createRule(supertest, rule); - - await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); - await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsResponse = await getSignalsByIds(supertest, [id]); - const signals = signalsResponse.hits.hits.map((hit) => hit._source); - const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); - - expect(signalsOrderedByEventId.length).equal(2); - }); - - it('should generate 2 signals when timestamp override does not exist', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting(['myfa*']), - timestamp_override: 'event.fakeingestfield', - }; - const { id } = await createRule(supertest, rule); - - await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); - await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsResponse = await getSignalsByIds(supertest, [id, id]); - const signals = signalsResponse.hits.hits.map((hit) => hit._source); - const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); - - expect(signalsOrderedByEventId.length).equal(2); - }); - }); - describe('Signals generated from events with name override field', async () => { beforeEach(async () => { await deleteSignalsIndex(supertest); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index 57b24f6de2a48..01fa2765ba0f0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -13,6 +13,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { describe('', function () { this.tags('ciGroup11'); + loadTestFile(require.resolve('./aliases')); loadTestFile(require.resolve('./add_actions')); loadTestFile(require.resolve('./update_actions')); loadTestFile(require.resolve('./add_prepackaged_rules')); @@ -42,6 +43,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./create_signals_migrations')); loadTestFile(require.resolve('./finalize_signals_migrations')); loadTestFile(require.resolve('./delete_signals_migrations')); + loadTestFile(require.resolve('./timestamps')); }); // That split here enable us on using a different ciGroup to run the tests @@ -49,5 +51,11 @@ export default ({ loadTestFile }: FtrProviderContext): void => { describe('', function () { loadTestFile(require.resolve('./exception_operators_data_types/index')); }); + + // That split here enable us on using a different ciGroup to run the tests + // listed on ./keyword_family/index + describe('', function () { + loadTestFile(require.resolve('./keyword_family/index')); + }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/README.md b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/README.md new file mode 100644 index 0000000000000..ab7f2ec862b0d --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/README.md @@ -0,0 +1,11 @@ +These are tests for the [keyword](https://www.elastic.co/guide/en/elasticsearch/reference/7.12/keyword.html) family where we test +* keyword +* const keyword +* alias fields against each one + +Against mock rules which contain the ECS values of: +* event.module +* even.dataset + +This is to ensure that if you have field aliases we will still correctly have detections occur. This also ensures that if you have +`keyword` mixed with `const keyword` across multiple indexes we will still have detections occur. \ No newline at end of file diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts new file mode 100644 index 0000000000000..43366915f154e --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { + EqlCreateSchema, + ThresholdCreateSchema, +} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request'; + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + interface EventModule { + module: string; + dataset: string; + } + + describe('Rule detects against a keyword of event.dataset', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load('rule_keyword_family/const_keyword'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('rule_keyword_family/const_keyword'); + }); + + describe('"kql" rule type', () => { + it('should detect the "dataset_name_1" from "event.dataset" and have 4 signals', async () => { + const rule = { + ...getRuleForSignalTesting(['const_keyword']), + query: 'event.dataset: "dataset_name_1"', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + expect(signalsOpen.hits.hits.length).to.eql(4); + }); + + // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source + it.skip('should copy the dataset_name_1 from the index into the signal', async () => { + const rule = { + ...getRuleForSignalTesting(['const_keyword']), + query: 'event.dataset: "dataset_name_1"', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => (hit._source.event as EventModule).dataset) + .sort(); + expect(hits).to.eql([ + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + ]); + }); + }); + + describe('"eql" rule type', () => { + it('should detect the "dataset_name_1" from "event.dataset" and have 4 signals', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['const_keyword']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'any where event.dataset=="dataset_name_1"', + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + expect(signalsOpen.hits.hits.length).to.eql(4); + }); + + // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source + it.skip('should copy the "dataset_name_1" from "event.dataset"', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['const_keyword']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'any where event.dataset=="dataset_name_1"', + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => (hit._source.event as EventModule).dataset) + .sort(); + expect(hits).to.eql([ + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + ]); + }); + }); + + describe('"threshold" rule type', async () => { + it('should detect the "dataset_name_1" from "event.dataset"', async () => { + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['const_keyword']), + rule_id: 'threshold-rule', + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'event.dataset', + value: 1, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => hit._source.signal.threshold_result ?? null) + .sort(); + expect(hits).to.eql([ + { + count: 4, + from: '1900-01-01T00:00:00.000Z', + terms: [ + { + field: 'event.dataset', + value: 'dataset_name_1', + }, + ], + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/index.ts new file mode 100644 index 0000000000000..4855524d650ef --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Detection keyword family data types', function () { + describe('', function () { + this.tags('ciGroup11'); + + loadTestFile(require.resolve('./keyword')); + loadTestFile(require.resolve('./const_keyword')); + loadTestFile(require.resolve('./keyword_mixed_with_const')); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts new file mode 100644 index 0000000000000..7ba013184548b --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../utils'; +import { + EqlCreateSchema, + ThresholdCreateSchema, +} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + interface EventModule { + module: string; + dataset: string; + } + + describe('Rule detects against a keyword of event.dataset', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load('rule_keyword_family/keyword'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('rule_keyword_family/keyword'); + }); + + describe('"kql" rule type', () => { + it('should detect the "dataset_name_1" from "event.dataset"', async () => { + const rule = { + ...getRuleForSignalTesting(['keyword']), + query: 'event.dataset: "dataset_name_1"', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => (hit._source.event as EventModule).dataset) + .sort(); + expect(hits).to.eql([ + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + ]); + }); + }); + + describe('"eql" rule type', () => { + it('should detect the "dataset_name_1" from "event.dataset"', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['keyword']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'any where event.dataset=="dataset_name_1"', + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => (hit._source.event as EventModule).dataset) + .sort(); + expect(hits).to.eql([ + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + ]); + }); + }); + + describe('"threshold" rule type', async () => { + it('should detect the "dataset_name_1" from "event.dataset"', async () => { + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['keyword']), + rule_id: 'threshold-rule', + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'event.dataset', + value: 1, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => hit._source.signal.threshold_result ?? null) + .sort(); + expect(hits).to.eql([ + { + count: 4, + from: '1900-01-01T00:00:00.000Z', + terms: [ + { + field: 'event.dataset', + value: 'dataset_name_1', + }, + ], + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts new file mode 100644 index 0000000000000..a5c69f98c3fe2 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { + EqlCreateSchema, + ThresholdCreateSchema, +} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request'; + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + interface EventModule { + module: string; + dataset: string; + } + + describe('Rule detects against a keyword and constant_keyword of event.dataset', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load('rule_keyword_family/const_keyword'); + await esArchiver.load('rule_keyword_family/keyword'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('rule_keyword_family/const_keyword'); + await esArchiver.unload('rule_keyword_family/keyword'); + }); + + describe('"kql" rule type', () => { + it('should detect the "dataset_name_1" from "event.dataset" and have 8 signals, 4 from each index', async () => { + const rule = { + ...getRuleForSignalTesting(['keyword', 'const_keyword']), + query: 'event.dataset: "dataset_name_1"', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 8, [id]); + const signalsOpen = await getSignalsById(supertest, id); + expect(signalsOpen.hits.hits.length).to.eql(8); + }); + + // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source + it.skip('should copy the dataset_name_1 from the index into the signal', async () => { + const rule = { + ...getRuleForSignalTesting(['keyword', 'const_keyword']), + query: 'event.dataset: "dataset_name_1"', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 8, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => (hit._source.event as EventModule).dataset) + .sort(); + expect(hits).to.eql([ + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + ]); + }); + }); + + describe('"eql" rule type', () => { + it('should detect the "dataset_name_1" from "event.dataset" and have 8 signals, 4 from each index', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['keyword', 'const_keyword']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'any where event.dataset=="dataset_name_1"', + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 8, [id]); + const signalsOpen = await getSignalsById(supertest, id); + expect(signalsOpen.hits.hits.length).to.eql(8); + }); + + // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source + it.skip('should copy the "dataset_name_1" from "event.dataset"', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['keyword', 'const_keyword']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'any where event.dataset=="dataset_name_1"', + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 8, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => (hit._source.event as EventModule).dataset) + .sort(); + expect(hits).to.eql([ + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + 'dataset_name_1', + ]); + }); + }); + + describe('"threshold" rule type', async () => { + it('should detect the "dataset_name_1" from "event.dataset"', async () => { + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['keyword', 'const_keyword']), + rule_id: 'threshold-rule', + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'event.dataset', + value: 1, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits + .map((hit) => hit._source.signal.threshold_result ?? null) + .sort(); + expect(hits).to.eql([ + { + count: 8, + from: '1900-01-01T00:00:00.000Z', + terms: [ + { + field: 'event.dataset', + value: 'dataset_name_1', + }, + ], + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts new file mode 100644 index 0000000000000..16610e6a44915 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { orderBy } from 'lodash'; +import { QueryCreateSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/request'; + +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + createRule, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, + getRuleForSignalTesting, + getSignalsByIds, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + /** + * Tests around timestamps within signals such as the copying of timestamps correctly into + * the "signal.original_time" field, ensuring that timestamp overrides operate, and ensuring that + * partial errors happen correctly + */ + describe('timestamp tests', () => { + describe('Signals generated from events with a timestamp in seconds is converted correctly into the forced ISO8601 format when copying', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load('security_solution/timestamp_in_seconds'); + await esArchiver.load('security_solution/timestamp_override_5'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('security_solution/timestamp_in_seconds'); + await esArchiver.unload('security_solution/timestamp_override_5'); + }); + + it('should convert the @timestamp which is epoch_seconds into the correct ISO format', async () => { + const rule = getRuleForSignalTesting(['timestamp_in_seconds']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.signal.original_time).sort(); + expect(hits).to.eql(['2021-06-02T23:33:15.000Z']); + }); + + it('should still use the @timestamp field even with an override field. It should never use the override field', async () => { + const rule: QueryCreateSchema = { + ...getRuleForSignalTesting(['myfakeindex-5']), + timestamp_override: 'event.ingested', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.signal.original_time).sort(); + expect(hits).to.eql(['2020-12-16T15:16:18.000Z']); + }); + }); + + /** + * Here we test the functionality of timestamp overrides. If the rule specifies a timestamp override, + * then the documents will be queried and sorted using the timestamp override field. + * If no timestamp override field exists in the indices but one was provided to the rule, + * the rule's query will additionally search for events using the `@timestamp` field + */ + describe('Signals generated from events with timestamp override field', async () => { + beforeEach(async () => { + await deleteSignalsIndex(supertest); + await createSignalsIndex(supertest); + await esArchiver.load('security_solution/timestamp_override_1'); + await esArchiver.load('security_solution/timestamp_override_2'); + await esArchiver.load('security_solution/timestamp_override_3'); + await esArchiver.load('security_solution/timestamp_override_4'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('security_solution/timestamp_override_1'); + await esArchiver.unload('security_solution/timestamp_override_2'); + await esArchiver.unload('security_solution/timestamp_override_3'); + await esArchiver.unload('security_solution/timestamp_override_4'); + }); + + it('should generate signals with event.ingested, @timestamp and (event.ingested + timestamp)', async () => { + const rule: QueryCreateSchema = { + ...getRuleForSignalTesting(['myfa*']), + timestamp_override: 'event.ingested', + }; + + const { id } = await createRule(supertest, rule); + + await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsResponse = await getSignalsByIds(supertest, [id], 3); + const signals = signalsResponse.hits.hits.map((hit) => hit._source); + const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); + + expect(signalsOrderedByEventId.length).equal(3); + }); + + it('should generate 2 signals with @timestamp', async () => { + const rule: QueryCreateSchema = getRuleForSignalTesting(['myfa*']); + + const { id } = await createRule(supertest, rule); + + await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsResponse = await getSignalsByIds(supertest, [id]); + const signals = signalsResponse.hits.hits.map((hit) => hit._source); + const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); + + expect(signalsOrderedByEventId.length).equal(2); + }); + + it('should generate 2 signals when timestamp override does not exist', async () => { + const rule: QueryCreateSchema = { + ...getRuleForSignalTesting(['myfa*']), + timestamp_override: 'event.fakeingestfield', + }; + const { id } = await createRule(supertest, rule); + + await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsResponse = await getSignalsByIds(supertest, [id, id]); + const signals = signalsResponse.hits.hits.map((hit) => hit._source); + const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); + + expect(signalsOrderedByEventId.length).equal(2); + }); + + /** + * We should not use the timestamp override as the "original_time" as that can cause + * confusion if you have both a timestamp and an override in the source event. Instead the "original_time" + * field should only be overridden by the "timestamp" since when we generate a signal + * and we add a new timestamp to the signal. + */ + it('should NOT use the timestamp override as the "original_time"', async () => { + const rule: QueryCreateSchema = { + ...getRuleForSignalTesting(['myfakeindex-2']), + timestamp_override: 'event.ingested', + }; + const { id } = await createRule(supertest, rule); + + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsResponse = await getSignalsByIds(supertest, [id, id]); + const hits = signalsResponse.hits.hits + .map((hit) => hit._source.signal.original_time) + .sort(); + expect(hits).to.eql([undefined]); + }); + }); + + describe('Signals generated from events with timestamp override field and ensures search_after continues to work when documents are missing timestamp override field', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load('auditbeat/hosts'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); + + /** + * This represents our worst case scenario where this field is not mapped on any index + * We want to check that our logic continues to function within the constraints of search after + * Elasticsearch returns java's long.MAX_VALUE for unmapped date fields + * Javascript does not support numbers this large, but without passing in a number of this size + * The search_after will continue to return the same results and not iterate to the next set + * So to circumvent this limitation of javascript we return the stringified version of Java's + * Long.MAX_VALUE so that search_after does not enter into an infinite loop. + * + * ref: https://github.com/elastic/elasticsearch/issues/28806#issuecomment-369303620 + */ + it('should generate 200 signals when timestamp override does not exist', async () => { + const rule: QueryCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + timestamp_override: 'event.fakeingested', + max_signals: 200, + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id, 'partial failure'); + await waitForSignalsToBePresent(supertest, 200, [id]); + const signalsResponse = await getSignalsByIds(supertest, [id], 200); + const signals = signalsResponse.hits.hits.map((hit) => hit._source); + + expect(signals.length).equal(200); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/lens/chart_data.ts b/x-pack/test/functional/apps/lens/chart_data.ts index b87d4e999d597..24aaab9807494 100644 --- a/x-pack/test/functional/apps/lens/chart_data.ts +++ b/x-pack/test/functional/apps/lens/chart_data.ts @@ -76,12 +76,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it.skip('should render treemap chart', async () => { - await PageObjects.lens.switchToVisualization('treemap'); + await PageObjects.lens.switchToVisualization('treemap', 'treemap'); await PageObjects.lens.waitForVisualization(); const data = await PageObjects.lens.getCurrentChartDebugState(); assertMatchesExpectedData(data!); }); + it('should render heatmap chart', async () => { + await PageObjects.lens.switchToVisualization('heatmap', 'heatmap'); + await PageObjects.lens.waitForVisualization(); + const debugState = await PageObjects.lens.getCurrentChartDebugState(); + + if (!debugState) { + throw new Error('Debug state is not available'); + } + + // assert axes + expect(debugState.axes!.x[0].labels).to.eql([ + '97.220.3.248', + '169.228.188.120', + '78.83.247.30', + '226.82.228.233', + '93.28.27.24', + 'Other', + ]); + expect(debugState.axes!.y[0].labels).to.eql(['']); + + // assert cells + expect(debugState.heatmap!.cells.length).to.eql(6); + + // assert legend + expect(debugState.legend!.items).to.eql([ + { key: '6000', name: '> 6000', color: '#6092c0' }, + { key: '8000', name: '> 8000', color: '#6092c0' }, + { key: '10000', name: '> 10000', color: '#a8bfda' }, + { key: '12000', name: '> 12000', color: '#ebeff5' }, + { key: '14000', name: '> 14000', color: '#ebeff5' }, + { key: '16000', name: '> 16000', color: '#ecb385' }, + { key: '18000', name: '> 18000', color: '#e7664c' }, + ]); + }); + it('should render datatable', async () => { await PageObjects.lens.switchToVisualization('lnsDatatable'); await PageObjects.lens.waitForVisualization(); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index acc783ad36bf1..2a4d56bbea791 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -407,6 +407,38 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('6,011.351'); }); + it('should create a heatmap chart and transition to barchart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('heatmap', 'heatmap'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsHeatmap_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsHeatmap_yDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.dest', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsHeatmap_cellPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + expect(await PageObjects.lens.hasChartSwitchWarning('bar')).to.eql(false); + await PageObjects.lens.switchToVisualization('bar'); + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel')).to.eql( + '@timestamp' + ); + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql( + 'Average of bytes' + ); + }); + it('should create a valid XY chart with references', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/es_archives/rule_keyword_family/README.md b/x-pack/test/functional/es_archives/rule_keyword_family/README.md new file mode 100644 index 0000000000000..b6849e7ea5915 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_keyword_family/README.md @@ -0,0 +1,20 @@ +Within this folder is input test data for tests within the folder: + +```ts +x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family +``` + +where these are small ECS compliant input indexes that try to express tests that exercise different parts of +the detection engine around creating and validating that the keyword family and field aliases all will work +with the detection engine. These indexes might contain extra fields or different fields but should not directly +clash with ECS or minimally clash. Nothing is stopping anyone from being ECS strict and not having additional +extra fields but the extra fields and mappings are to just try and keep these tests simple and small. + +Most of these tests center around the two fields of: +* event.module +* event.dataset + +To ensure that if mix and match between `keyword`, `const keyword` and field aliases within them, everything should +still be ok. It is alright if other use cases are added here if they fit within the `keyword` family as described here: +https://www.elastic.co/guide/en/elasticsearch/reference/7.12/keyword.html + diff --git a/x-pack/test/functional/es_archives/rule_keyword_family/const_keyword/data.json b/x-pack/test/functional/es_archives/rule_keyword_family/const_keyword/data.json new file mode 100644 index 0000000000000..2d0359c6ff828 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_keyword_family/const_keyword/data.json @@ -0,0 +1,47 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "const_keyword", + "source": { + "@timestamp": "2020-10-27T05:00:53.000Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "const_keyword", + "source": { + "@timestamp": "2020-10-27T05:01:53.000Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "const_keyword", + "source": { + "@timestamp": "2020-10-27T05:02:53.000Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "const_keyword", + "source": { + "@timestamp": "2020-10-27T05:03:53.000Z" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_keyword_family/const_keyword/mappings.json b/x-pack/test/functional/es_archives/rule_keyword_family/const_keyword/mappings.json new file mode 100644 index 0000000000000..7e3d74f840142 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_keyword_family/const_keyword/mappings.json @@ -0,0 +1,48 @@ +{ + "type": "index", + "value": { + "index": "const_keyword", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "constant_keyword", + "value": "dataset_name_1" + }, + "module": { + "type": "constant_keyword", + "value": "module_name_1" + } + } + }, + "event": { + "properties": { + "category": { + "type": "keyword" + }, + "dataset": { + "type": "alias", + "path": "data_stream.dataset" + }, + "module": { + "type": "alias", + "path": "data_stream.module" + } + } + } + } + }, + "settings": { + "index": { + "refresh_interval": "1s", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_keyword_family/keyword/data.json b/x-pack/test/functional/es_archives/rule_keyword_family/keyword/data.json new file mode 100644 index 0000000000000..40118aa0e2ba8 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_keyword_family/keyword/data.json @@ -0,0 +1,63 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "event": { + "module": "module_name_1", + "dataset": "dataset_name_1" + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "event": { + "module": "module_name_1", + "dataset": "dataset_name_1" + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "event": { + "module": "module_name_1", + "dataset": "dataset_name_1" + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "event": { + "module": "module_name_1", + "dataset": "dataset_name_1" + } + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_keyword_family/keyword/mappings.json b/x-pack/test/functional/es_archives/rule_keyword_family/keyword/mappings.json new file mode 100644 index 0000000000000..9d5274a002279 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_keyword_family/keyword/mappings.json @@ -0,0 +1,34 @@ +{ + "type": "index", + "value": { + "index": "keyword", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date" + }, + "event": { + "properties": { + "category": { + "type": "keyword" + }, + "module": { + "type": "keyword" + }, + "dataset": { + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "refresh_interval": "1s", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/README.md b/x-pack/test/functional/es_archives/security_solution/README.md new file mode 100644 index 0000000000000..c832e0835bbbc --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/README.md @@ -0,0 +1,11 @@ +Collection of data sets for use within various tests. Most of the tests to these live in either: + +``` +x-pack/test/detection_engine_api_integrations/security_and_spaces/tests +``` + +or + +``` +x-pack/test/api_integration/apis/security_solution +``` diff --git a/x-pack/test/functional/es_archives/security_solution/alias/data.json b/x-pack/test/functional/es_archives/security_solution/alias/data.json new file mode 100644 index 0000000000000..a8bd64cb044eb --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/alias/data.json @@ -0,0 +1,59 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "alias", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "host_alias": { + "name": "host name 1" + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "alias", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "host_alias": { + "name": "host name 2" + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "alias", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "host_alias": { + "name": "host name 3" + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "alias", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "host_alias": { + "name": "host name 4" + } + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/alias/mappings.json b/x-pack/test/functional/es_archives/security_solution/alias/mappings.json new file mode 100644 index 0000000000000..280ec9377df64 --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/alias/mappings.json @@ -0,0 +1,36 @@ +{ + "type": "index", + "value": { + "index": "host_alias", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "alias", + "path": "host_alias.name" + } + } + }, + "host_alias": { + "properties": { + "name": { + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "refresh_interval": "1s", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_in_seconds/data.json b/x-pack/test/functional/es_archives/security_solution/timestamp_in_seconds/data.json new file mode 100644 index 0000000000000..46b30b239bbc7 --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_in_seconds/data.json @@ -0,0 +1,10 @@ +{ + "type": "doc", + "value": { + "index": "timestamp_in_seconds", + "source": { + "@timestamp": 1622676795 + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_in_seconds/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_in_seconds/mappings.json new file mode 100644 index 0000000000000..fd8880fe0bc49 --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_in_seconds/mappings.json @@ -0,0 +1,22 @@ +{ + "type": "index", + "value": { + "index": "timestamp_in_seconds", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date", + "format": "epoch_second" + } + } + }, + "settings": { + "index": { + "refresh_interval": "1s", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override/mappings.json index 085ab34a3d58a..092519a792863 100644 --- a/x-pack/test/functional/es_archives/security_solution/timestamp_override/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override/mappings.json @@ -3,6 +3,7 @@ "value": { "index": "myfakeindex-1", "mappings": { + "dynamic": "strict", "properties": { "message": { "type": "text", diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_1/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_1/mappings.json index 085ab34a3d58a..092519a792863 100644 --- a/x-pack/test/functional/es_archives/security_solution/timestamp_override_1/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_1/mappings.json @@ -3,6 +3,7 @@ "value": { "index": "myfakeindex-1", "mappings": { + "dynamic": "strict", "properties": { "message": { "type": "text", diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_2/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_2/mappings.json index 49a27a423cdaa..1f1c1673fe1a2 100644 --- a/x-pack/test/functional/es_archives/security_solution/timestamp_override_2/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_2/mappings.json @@ -3,6 +3,7 @@ "value": { "index": "myfakeindex-2", "mappings": { + "dynamic": "strict", "properties": { "message": { "type": "text", diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_3/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_3/mappings.json index 736584386a705..a0409280c34eb 100644 --- a/x-pack/test/functional/es_archives/security_solution/timestamp_override_3/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_3/mappings.json @@ -3,6 +3,7 @@ "value": { "index": "myfakeindex-3", "mappings": { + "dynamic": "strict", "properties": { "message": { "type": "text", diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/data.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/data.json index ca7025b36154c..ad0e7cbab7d2b 100644 --- a/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/data.json +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/data.json @@ -6,7 +6,7 @@ "message": "hello world 4", "@timestamp": "2020-12-16T15:16:18.570Z", "event": { - "ingested": "2020-12-16T15:16:18.570Z" + "ingested": "2020-12-16T16:16:18.570Z" } }, "type": "_doc" diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/mappings.json index ab4edc9f300e1..a4e021e45ff9e 100644 --- a/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_4/mappings.json @@ -3,6 +3,7 @@ "value": { "index": "myfakeindex-4", "mappings": { + "dynamic": "strict", "properties": { "@timestamp": { "type": "date" diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_5/data.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_5/data.json new file mode 100644 index 0000000000000..f2c81e9b5e45e --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_5/data.json @@ -0,0 +1,14 @@ +{ + "type": "doc", + "value": { + "index": "myfakeindex-5", + "source": { + "@timestamp": 1608131778, + "message": "hello world 4", + "event": { + "ingested": 1622676795 + } + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/timestamp_override_5/mappings.json b/x-pack/test/functional/es_archives/security_solution/timestamp_override_5/mappings.json new file mode 100644 index 0000000000000..a9735aaeca8ef --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/timestamp_override_5/mappings.json @@ -0,0 +1,39 @@ +{ + "type": "index", + "value": { + "index": "myfakeindex-5", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date", + "format": "epoch_second" + }, + "message": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "event": { + "properties": { + "ingested": { + "type": "date", + "format": "epoch_second" + } + } + } + } + }, + "settings": { + "index": { + "refresh_interval": "1s", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/yarn.lock b/yarn.lock index c5255bc4d0d30..83ab15d1f68d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1204,10 +1204,10 @@ resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.15.10.tgz#cf0cff1aec6d8e7bb23e1fc618d09fbd39b7a13f" integrity sha512-0v+OwCQ6fsGFa50r6MXWbUkSGuWOoZ22K4pMSdtWiL5LKFIE4kfmMmtQS+M7/ICNwk2EIYob+NRreyi/DGUz5A== -"@bazel/typescript@^3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.5.0.tgz#605493f4f0a5297df8a7fcccb86a1a80ea2090bb" - integrity sha512-BtGFp4nYFkQTmnONCzomk7dkmOwaINBL3piq+lykBlcc6UxLe9iCAnZpOyPypB1ReN3k3SRNAa53x6oGScQxMg== +"@bazel/typescript@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.5.1.tgz#c6027d683adeefa2c3cebfa3ed5efa17c405a63b" + integrity sha512-dU5sGgaGdFWV1dJ1B+9iFbttgcKtmob+BvlM8mY7Nxq4j7/wVbgPjiVLOBeOD7kpzYep8JHXfhAokHt486IG+Q== dependencies: protobufjs "6.8.8" semver "5.6.0"
{FULL_ENGINE_ACCESS_DESCRIPTION}
{LIMITED_ENGINE_ACCESS_DESCRIPTION}
{DELETE_ROLE_MAPPING_DESCRIPTION}
{ROLE_MAPPING_FLYOUT_DESCRIPTION}
{description}