diff --git a/.i18nrc.json b/.i18nrc.json index ad32edb67b83f..bdfe444bb99b5 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -18,6 +18,7 @@ "devTools": "src/plugins/dev_tools", "expressions": "src/plugins/expressions", "expressionError": "src/plugins/expression_error", + "expressionRepeatImage": "src/plugins/expression_repeat_image", "expressionRevealImage": "src/plugins/expression_reveal_image", "expressionShape": "src/plugins/expression_shape", "inputControl": "src/plugins/input_control_vis", diff --git a/docs/developer/advanced/running-elasticsearch.asciidoc b/docs/developer/advanced/running-elasticsearch.asciidoc index e5c86fafd1ce7..324d2af2ed3af 100644 --- a/docs/developer/advanced/running-elasticsearch.asciidoc +++ b/docs/developer/advanced/running-elasticsearch.asciidoc @@ -25,7 +25,7 @@ See all available options, like how to specify a specific license, with the `--h yarn es snapshot --help ---- -`trial` will give you access to all capabilities. +`--license trial` will give you access to all capabilities. **Keeping data between snapshots** diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 8e08e5f4db1f9..77f16a9d69d46 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -76,6 +76,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |Expression Error plugin adds an error renderer to the expression plugin. The renderer will display the error image. +|{kib-repo}blob/{branch}/src/plugins/expression_repeat_image/README.md[expressionRepeatImage] +|Expression Repeat Image plugin adds a repeatImage function to the expression plugin and an associated renderer. The renderer will display the given image in mutliple instances. + + |{kib-repo}blob/{branch}/src/plugins/expression_reveal_image/README.md[expressionRevealImage] |Expression Reveal Image plugin adds a revealImage function to the expression plugin and an associated renderer. The renderer will display the given percentage of a given image. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7a2c9095e2011..0bf15d236bc9c 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -113,5 +113,6 @@ pageLoadAssetSize: expressionRevealImage: 25675 cases: 144442 expressionError: 22127 - userSetup: 18532 + expressionRepeatImage: 22341 expressionShape: 30033 + userSetup: 18532 diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 51ed25bfc69f6..7aca25d2013d2 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,6 +18,7 @@ export const storybookAliases = { data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', embeddable: 'src/plugins/embeddable/.storybook', expression_error: 'src/plugins/expression_error/.storybook', + expression_repeat_image: 'src/plugins/expression_repeat_image/.storybook', expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', expression_shape: 'src/plugins/expression_shape/.storybook', infra: 'x-pack/plugins/infra/.storybook', diff --git a/src/plugins/data/common/search/expressions/kibana_context.test.ts b/src/plugins/data/common/search/expressions/kibana_context.test.ts new file mode 100644 index 0000000000000..77d89792b63c3 --- /dev/null +++ b/src/plugins/data/common/search/expressions/kibana_context.test.ts @@ -0,0 +1,264 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FilterStateStore, buildFilter, FILTERS } from '@kbn/es-query'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { ExecutionContext } from 'src/plugins/expressions/common'; +import { KibanaContext } from './kibana_context_type'; + +import { + getKibanaContextFn, + ExpressionFunctionKibanaContext, + KibanaContextStartDependencies, +} from './kibana_context'; + +type StartServicesMock = DeeplyMockedKeys; + +const createExecutionContextMock = (): DeeplyMockedKeys => ({ + abortSignal: {} as any, + getExecutionContext: jest.fn(), + getSearchContext: jest.fn(), + getSearchSessionId: jest.fn(), + inspectorAdapters: jest.fn(), + types: {}, + variables: {}, + getKibanaRequest: jest.fn(), +}); + +const emptyArgs = { q: null, timeRange: null, savedSearchId: null }; + +describe('kibanaContextFn', () => { + let kibanaContextFn: ExpressionFunctionKibanaContext; + let startServicesMock: StartServicesMock; + + const getStartServicesMock = (): Promise => Promise.resolve(startServicesMock); + + beforeEach(async () => { + kibanaContextFn = getKibanaContextFn(getStartServicesMock); + startServicesMock = { + savedObjectsClient: { + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }, + }; + }); + + it('merges and deduplicates queries from different sources', async () => { + const { fn } = kibanaContextFn; + startServicesMock.savedObjectsClient.get.mockResolvedValue({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + query: [ + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something1', + }, + }, + }, + ], + }), + }, + }, + } as any); + const args = { + ...emptyArgs, + q: { + type: 'kibana_query' as 'kibana_query', + language: 'test', + query: { + type: 'test', + match_phrase: { + test: 'something2', + }, + }, + }, + savedSearchId: 'test', + }; + const input: KibanaContext = { + type: 'kibana_context', + query: [ + { + language: 'kuery', + query: [ + // TODO: Is it expected that if we pass in an array that the values in the array are not deduplicated? + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something3', + }, + }, + }, + ], + }, + ], + timeRange: { + from: 'now-24h', + to: 'now', + }, + }; + + const { query } = await fn(input, args, createExecutionContextMock()); + + expect(query).toEqual([ + { + language: 'kuery', + query: [ + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something3', + }, + }, + }, + ], + }, + { + type: 'kibana_query', + language: 'test', + query: { + type: 'test', + match_phrase: { + test: 'something2', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something1', + }, + }, + }, + ]); + }); + + it('deduplicates duplicated filters and keeps the first enabled filter', async () => { + const { fn } = kibanaContextFn; + const filter1 = buildFilter( + { fields: [] }, + { name: 'test', type: 'test' }, + FILTERS.PHRASE, + false, + true, + { + query: 'JetBeats', + }, + null, + FilterStateStore.APP_STATE + ); + const filter2 = buildFilter( + { fields: [] }, + { name: 'test', type: 'test' }, + FILTERS.PHRASE, + false, + false, + { + query: 'JetBeats', + }, + null, + FilterStateStore.APP_STATE + ); + + const filter3 = buildFilter( + { fields: [] }, + { name: 'test', type: 'test' }, + FILTERS.PHRASE, + false, + false, + { + query: 'JetBeats', + }, + null, + FilterStateStore.APP_STATE + ); + + const input: KibanaContext = { + type: 'kibana_context', + query: [ + { + language: 'kuery', + query: '', + }, + ], + filters: [filter1, filter2, filter3], + timeRange: { + from: 'now-24h', + to: 'now', + }, + }; + + const { filters } = await fn(input, emptyArgs, createExecutionContextMock()); + expect(filters!.length).toBe(1); + expect(filters![0]).toBe(filter2); + }); +}); diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 9c1c78604ea83..8112777b9b0f3 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -146,7 +146,7 @@ export const getKibanaContextFn = ( return { type: 'kibana_context', query: queries, - filters: uniqFilters(filters).filter((f: any) => !f.meta?.disabled), + filters: uniqFilters(filters.filter((f: any) => !f.meta?.disabled)), timeRange, }; }, diff --git a/src/plugins/expression_error/public/components/error/error.tsx b/src/plugins/expression_error/public/components/error/error.tsx index 99318357d8602..637309448da23 100644 --- a/src/plugins/expression_error/public/components/error/error.tsx +++ b/src/plugins/expression_error/public/components/error/error.tsx @@ -12,6 +12,12 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { ShowDebugging } from './show_debugging'; +export interface Props { + payload: { + error: Error; + }; +} + const strings = { getDescription: () => i18n.translate('expressionError.errorComponent.description', { @@ -23,12 +29,6 @@ const strings = { }), }; -export interface Props { - payload: { - error: Error; - }; -} - export const Error: FC = ({ payload }) => { const message = get(payload, 'error.message'); diff --git a/src/plugins/expression_repeat_image/.storybook/main.js b/src/plugins/expression_repeat_image/.storybook/main.js new file mode 100644 index 0000000000000..742239e638b8a --- /dev/null +++ b/src/plugins/expression_repeat_image/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line import/no-commonjs +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/expression_repeat_image/README.md b/src/plugins/expression_repeat_image/README.md new file mode 100755 index 0000000000000..11f4f9847c39a --- /dev/null +++ b/src/plugins/expression_repeat_image/README.md @@ -0,0 +1,9 @@ +# expressionRepeatImage + +Expression Repeat Image plugin adds a `repeatImage` function to the expression plugin and an associated renderer. The renderer will display the given image in mutliple instances. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/expression_repeat_image/common/constants.ts b/src/plugins/expression_repeat_image/common/constants.ts new file mode 100644 index 0000000000000..878d5da742562 --- /dev/null +++ b/src/plugins/expression_repeat_image/common/constants.ts @@ -0,0 +1,14 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'expressionRepeatImage'; +export const PLUGIN_NAME = 'expressionRepeatImage'; + +export const CONTEXT = '_context_'; +export const BASE64 = '`base64`'; +export const URL = 'URL'; diff --git a/src/plugins/expression_repeat_image/common/expression_functions/index.ts b/src/plugins/expression_repeat_image/common/expression_functions/index.ts new file mode 100644 index 0000000000000..84695c58c7f29 --- /dev/null +++ b/src/plugins/expression_repeat_image/common/expression_functions/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { repeatImageFunction } from './repeat_image_function'; + +export const functions = [repeatImageFunction]; + +export { repeatImageFunction }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js b/src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.test.ts similarity index 62% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js rename to src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.test.ts index 42569e26e426c..4c7e4771a8e0a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js +++ b/src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.test.ts @@ -1,29 +1,31 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ +import { ExecutionContext } from 'src/plugins/expressions'; import { getElasticLogo, getElasticOutline, functionWrapper, -} from '../../../../../../src/plugins/presentation_util/common/lib'; -import { repeatImage } from './repeat_image'; +} from '../../../presentation_util/common/lib'; +import { repeatImageFunction } from './repeat_image_function'; describe('repeatImage', () => { - const fn = functionWrapper(repeatImage); + const fn = functionWrapper(repeatImageFunction); - let elasticLogo; - let elasticOutline; + let elasticLogo: string; + let elasticOutline: string; beforeEach(async () => { elasticLogo = await (await getElasticLogo()).elasticLogo; elasticOutline = await (await getElasticOutline()).elasticOutline; }); it('returns a render as repeatImage', async () => { - const result = await fn(10); + const result = await fn(10, {}, {} as ExecutionContext); expect(result).toHaveProperty('type', 'render'); expect(result).toHaveProperty('as', 'repeatImage'); }); @@ -31,46 +33,47 @@ describe('repeatImage', () => { describe('args', () => { describe('image', () => { it('sets the source of the repeated image', async () => { - const result = (await fn(10, { image: elasticLogo })).value; + const result = (await fn(10, { image: elasticLogo }, {} as ExecutionContext)).value; expect(result).toHaveProperty('image', elasticLogo); }); it('defaults to the Elastic outline logo', async () => { - const result = (await fn(100000)).value; + const result = (await fn(100000, {}, {} as ExecutionContext)).value; expect(result).toHaveProperty('image', elasticOutline); }); }); describe('size', () => { it('sets the size of the image', async () => { - const result = (await fn(-5, { size: 200 })).value; + const result = (await fn(-5, { size: 200 }, {} as ExecutionContext)).value; expect(result).toHaveProperty('size', 200); }); it('defaults to 100', async () => { - const result = (await fn(-5)).value; + const result = (await fn(-5, {}, {} as ExecutionContext)).value; expect(result).toHaveProperty('size', 100); }); }); describe('max', () => { it('sets the maximum number of a times the image is repeated', async () => { - const result = (await fn(100000, { max: 20 })).value; + const result = (await fn(100000, { max: 20 }, {} as ExecutionContext)).value; expect(result).toHaveProperty('max', 20); }); it('defaults to 1000', async () => { - const result = (await fn(100000)).value; + const result = (await fn(100000, {}, {} as ExecutionContext)).value; expect(result).toHaveProperty('max', 1000); }); }); describe('emptyImage', () => { it('returns repeatImage object with emptyImage as undefined', async () => { - const result = (await fn(100000, { emptyImage: elasticLogo })).value; + const result = (await fn(100000, { emptyImage: elasticLogo }, {} as ExecutionContext)) + .value; expect(result).toHaveProperty('emptyImage', elasticLogo); }); it('sets emptyImage to null', async () => { - const result = (await fn(100000)).value; + const result = (await fn(100000, {}, {} as ExecutionContext)).value; expect(result).toHaveProperty('emptyImage', null); }); }); diff --git a/src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.ts b/src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.ts new file mode 100644 index 0000000000000..ebc72ab10af42 --- /dev/null +++ b/src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.ts @@ -0,0 +1,115 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + getElasticOutline, + isValidUrl, + resolveWithMissingImage, +} from '../../../presentation_util/common/lib'; +import { CONTEXT, BASE64, URL } from '../constants'; +import { ExpressionRepeatImageFunction } from '../types'; + +export const strings = { + help: i18n.translate('expressionRepeatImage.functions.repeatImageHelpText', { + defaultMessage: 'Configures a repeating image element.', + }), + args: { + emptyImage: i18n.translate( + 'expressionRepeatImage.functions.repeatImage.args.emptyImageHelpText', + { + defaultMessage: + 'Fills the difference between the {CONTEXT} and {maxArg} parameter for the element with this image. ' + + 'Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', + values: { + BASE64, + CONTEXT, + maxArg: '`max`', + URL, + }, + } + ), + image: i18n.translate('expressionRepeatImage.functions.repeatImage.args.imageHelpText', { + defaultMessage: + 'The image to repeat. Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', + values: { + BASE64, + URL, + }, + }), + max: i18n.translate('expressionRepeatImage.functions.repeatImage.args.maxHelpText', { + defaultMessage: 'The maximum number of times the image can repeat.', + }), + size: i18n.translate('expressionRepeatImage.functions.repeatImage.args.sizeHelpText', { + defaultMessage: + 'The maximum height or width of the image, in pixels. ' + + 'When the image is taller than it is wide, this function limits the height.', + }), + }, +}; + +const errors = { + getMissingMaxArgumentErrorMessage: () => + i18n.translate('expressionRepeatImage.error.repeatImage.missingMaxArgument', { + defaultMessage: '{maxArgument} must be set if providing an {emptyImageArgument}', + values: { + maxArgument: '`max`', + emptyImageArgument: '`emptyImage`', + }, + }), +}; + +export const repeatImageFunction: ExpressionRepeatImageFunction = () => { + const { help, args: argHelp } = strings; + + return { + name: 'repeatImage', + aliases: [], + type: 'render', + inputTypes: ['number'], + help, + args: { + emptyImage: { + types: ['string', 'null'], + help: argHelp.emptyImage, + default: null, + }, + image: { + types: ['string', 'null'], + help: argHelp.image, + default: null, + }, + max: { + types: ['number', 'null'], + help: argHelp.max, + default: 1000, + }, + size: { + types: ['number'], + default: 100, + help: argHelp.size, + }, + }, + fn: async (count, args) => { + if (args.emptyImage !== null && isValidUrl(args.emptyImage) && args.max === null) { + throw new Error(errors.getMissingMaxArgumentErrorMessage()); + } + const { elasticOutline } = await getElasticOutline(); + return { + type: 'render', + as: 'repeatImage', + value: { + count: Math.floor(count), + ...args, + image: resolveWithMissingImage(args.image, elasticOutline), + emptyImage: resolveWithMissingImage(args.emptyImage), + }, + }; + }, + }; +}; diff --git a/src/plugins/expression_repeat_image/common/index.ts b/src/plugins/expression_repeat_image/common/index.ts new file mode 100755 index 0000000000000..1b7668c49def5 --- /dev/null +++ b/src/plugins/expression_repeat_image/common/index.ts @@ -0,0 +1,11 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './constants'; +export * from './types'; +export * from './expression_functions'; diff --git a/src/plugins/expression_repeat_image/common/types/expression_functions.ts b/src/plugins/expression_repeat_image/common/types/expression_functions.ts new file mode 100644 index 0000000000000..3e278ddcc97c5 --- /dev/null +++ b/src/plugins/expression_repeat_image/common/types/expression_functions.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ExpressionFunctionDefinition, ExpressionValueRender } from '../../../expressions'; + +interface Arguments { + image: string | null; + size: number; + max: number | null; + emptyImage: string | null; +} + +export interface Return { + count: number; + image: string; + size: number; + max: number; + emptyImage: string | null; +} + +export type ExpressionRepeatImageFunction = () => ExpressionFunctionDefinition< + 'repeatImage', + number, + Arguments, + Promise> +>; diff --git a/src/plugins/expression_repeat_image/common/types/expression_renderers.ts b/src/plugins/expression_repeat_image/common/types/expression_renderers.ts new file mode 100644 index 0000000000000..190abd33f2b1c --- /dev/null +++ b/src/plugins/expression_repeat_image/common/types/expression_renderers.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type OriginString = 'bottom' | 'left' | 'top' | 'right'; +export interface RepeatImageRendererConfig { + max: number; + count: number; + emptyImage: string; + image: string; + size: number; +} + +export interface NodeDimensions { + width: number; + height: number; +} diff --git a/src/plugins/expression_repeat_image/common/types/index.ts b/src/plugins/expression_repeat_image/common/types/index.ts new file mode 100644 index 0000000000000..ec934e7affe88 --- /dev/null +++ b/src/plugins/expression_repeat_image/common/types/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export * from './expression_functions'; +export * from './expression_renderers'; diff --git a/src/plugins/expression_repeat_image/jest.config.js b/src/plugins/expression_repeat_image/jest.config.js new file mode 100644 index 0000000000000..cf1039263840b --- /dev/null +++ b/src/plugins/expression_repeat_image/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/expression_repeat_image'], +}; diff --git a/src/plugins/expression_repeat_image/kibana.json b/src/plugins/expression_repeat_image/kibana.json new file mode 100755 index 0000000000000..33f1f9c8b759d --- /dev/null +++ b/src/plugins/expression_repeat_image/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "expressionRepeatImage", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["expressions", "presentationUtil"], + "optionalPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/expression_repeat_image/public/components/index.ts b/src/plugins/expression_repeat_image/public/components/index.ts new file mode 100644 index 0000000000000..5d7878fc46b51 --- /dev/null +++ b/src/plugins/expression_repeat_image/public/components/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './repeat_image_component'; diff --git a/src/plugins/expression_repeat_image/public/components/repeat_image_component.tsx b/src/plugins/expression_repeat_image/public/components/repeat_image_component.tsx new file mode 100644 index 0000000000000..7a136b470e943 --- /dev/null +++ b/src/plugins/expression_repeat_image/public/components/repeat_image_component.tsx @@ -0,0 +1,99 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactElement, useEffect, useState } from 'react'; +import { times } from 'lodash'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { RepeatImageRendererConfig } from '../../common'; + +interface RepeatImageComponentProps extends RepeatImageRendererConfig { + onLoaded: IInterpreterRenderHandlers['done']; + parentNode: HTMLElement; +} + +interface LoadedImages { + image: HTMLImageElement | null; + emptyImage: HTMLImageElement | null; +} + +async function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (error) => reject(error); + img.src = src; + }); +} + +async function loadImages(images: string[]): Promise> { + const results = await Promise.allSettled([...images.map(loadImage)]); + return results.map((loadedImage) => + loadedImage.status === 'rejected' ? null : loadedImage.value + ); +} + +function setImageSize(img: HTMLImageElement, size: number) { + if (img.naturalHeight > img.naturalWidth) { + img.height = size; + } else { + img.width = size; + } +} + +function createImageJSX(img: HTMLImageElement | null) { + if (!img) return null; + const params = img.width > img.height ? { heigth: img.height } : { width: img.width }; + return ; +} + +function RepeatImageComponent({ + max, + count, + emptyImage: emptyImageSrc, + image: imageSrc, + size, + onLoaded, +}: RepeatImageComponentProps) { + const [images, setImages] = useState({ + image: null, + emptyImage: null, + }); + + useEffect(() => { + loadImages([imageSrc, emptyImageSrc]).then((result) => { + const [image, emptyImage] = result; + setImages({ image, emptyImage }); + onLoaded(); + }); + }, [imageSrc, emptyImageSrc, onLoaded]); + + const imagesToRender: Array = []; + + const { image, emptyImage } = images; + + if (max && count > max) count = max; + + if (image) { + setImageSize(image, size); + times(count, () => imagesToRender.push(createImageJSX(image))); + } + + if (emptyImage) { + setImageSize(emptyImage, size); + times(max - count, () => imagesToRender.push(createImageJSX(emptyImage))); + } + + return ( +
+ {imagesToRender} +
+ ); +} +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { RepeatImageComponent as default }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/__snapshots__/repeat_image.stories.storyshot b/src/plugins/expression_repeat_image/public/expression_renderers/__stories__/__snapshots__/repeat_image.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/__snapshots__/repeat_image.stories.storyshot rename to src/plugins/expression_repeat_image/public/expression_renderers/__stories__/__snapshots__/repeat_image.stories.storyshot diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx b/src/plugins/expression_repeat_image/public/expression_renderers/__stories__/repeat_image_renderer.stories.tsx similarity index 69% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx rename to src/plugins/expression_repeat_image/public/expression_renderers/__stories__/repeat_image_renderer.stories.tsx index 0052b9139aae7..42f008b2570ea 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx +++ b/src/plugins/expression_repeat_image/public/expression_renderers/__stories__/repeat_image_renderer.stories.tsx @@ -1,19 +1,20 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { repeatImage } from '../repeat_image'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { repeatImageRenderer } from '../repeat_image_renderer'; import { getElasticLogo, getElasticOutline, } from '../../../../../../src/plugins/presentation_util/common/lib'; import { waitFor } from '../../../../../../src/plugins/presentation_util/public/__stories__'; -import { Render } from './render'; const Renderer = ({ elasticLogo, @@ -30,7 +31,7 @@ const Renderer = ({ emptyImage: elasticOutline, }; - return ; + return ; }; storiesOf('enderers/repeatImage', module).add( diff --git a/src/plugins/expression_repeat_image/public/expression_renderers/index.ts b/src/plugins/expression_repeat_image/public/expression_renderers/index.ts new file mode 100644 index 0000000000000..5c5625f8c7730 --- /dev/null +++ b/src/plugins/expression_repeat_image/public/expression_renderers/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { repeatImageRenderer } from './repeat_image_renderer'; + +export const renderers = [repeatImageRenderer]; + +export { repeatImageRenderer }; diff --git a/src/plugins/expression_repeat_image/public/expression_renderers/repeat_image_renderer.tsx b/src/plugins/expression_repeat_image/public/expression_renderers/repeat_image_renderer.tsx new file mode 100644 index 0000000000000..bd35de79713cc --- /dev/null +++ b/src/plugins/expression_repeat_image/public/expression_renderers/repeat_image_renderer.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { i18n } from '@kbn/i18n'; +import { getElasticOutline, isValidUrl, withSuspense } from '../../../presentation_util/public'; +import { RepeatImageRendererConfig } from '../../common/types'; + +const strings = { + getDisplayName: () => + i18n.translate('expressionRepeatImage.renderer.repeatImage.displayName', { + defaultMessage: 'RepeatImage', + }), + getHelpDescription: () => + i18n.translate('expressionRepeatImage.renderer.repeatImage.helpDescription', { + defaultMessage: 'Render a basic repeatImage', + }), +}; + +const LazyRepeatImageComponent = lazy(() => import('../components/repeat_image_component')); +const RepeatImageComponent = withSuspense(LazyRepeatImageComponent, null); + +export const repeatImageRenderer = (): ExpressionRenderDefinition => ({ + name: 'repeatImage', + displayName: strings.getDisplayName(), + help: strings.getHelpDescription(), + reuseDomNode: true, + render: async ( + domNode: HTMLElement, + config: RepeatImageRendererConfig, + handlers: IInterpreterRenderHandlers + ) => { + const { elasticOutline } = await getElasticOutline(); + const settings = { + ...config, + image: isValidUrl(config.image) ? config.image : elasticOutline, + emptyImage: config.emptyImage || '', + }; + + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render( + + + , + domNode + ); + }, +}); diff --git a/src/plugins/expression_repeat_image/public/index.ts b/src/plugins/expression_repeat_image/public/index.ts new file mode 100755 index 0000000000000..6e16775256454 --- /dev/null +++ b/src/plugins/expression_repeat_image/public/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionRepeatImagePlugin } from './plugin'; + +export type { ExpressionRepeatImagePluginSetup, ExpressionRepeatImagePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionRepeatImagePlugin(); +} + +export * from './expression_renderers'; diff --git a/src/plugins/expression_repeat_image/public/plugin.ts b/src/plugins/expression_repeat_image/public/plugin.ts new file mode 100755 index 0000000000000..aba8fff219c4a --- /dev/null +++ b/src/plugins/expression_repeat_image/public/plugin.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; +import { repeatImageFunction } from '../common/expression_functions'; +import { repeatImageRenderer } from './expression_renderers'; + +interface SetupDeps { + expressions: ExpressionsSetup; +} + +interface StartDeps { + expression: ExpressionsStart; +} + +export type ExpressionRepeatImagePluginSetup = void; +export type ExpressionRepeatImagePluginStart = void; + +export class ExpressionRepeatImagePlugin + implements + Plugin< + ExpressionRepeatImagePluginSetup, + ExpressionRepeatImagePluginStart, + SetupDeps, + StartDeps + > { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionRepeatImagePluginSetup { + expressions.registerFunction(repeatImageFunction); + expressions.registerRenderer(repeatImageRenderer); + } + + public start(core: CoreStart): ExpressionRepeatImagePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_repeat_image/server/index.ts b/src/plugins/expression_repeat_image/server/index.ts new file mode 100755 index 0000000000000..07d0df9f78e05 --- /dev/null +++ b/src/plugins/expression_repeat_image/server/index.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionRepeatImagePlugin } from './plugin'; + +export type { ExpressionRepeatImagePluginSetup, ExpressionRepeatImagePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionRepeatImagePlugin(); +} diff --git a/src/plugins/expression_repeat_image/server/plugin.ts b/src/plugins/expression_repeat_image/server/plugin.ts new file mode 100755 index 0000000000000..744a3fb7f35b8 --- /dev/null +++ b/src/plugins/expression_repeat_image/server/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { ExpressionsServerStart, ExpressionsServerSetup } from '../../expressions/server'; +import { repeatImageFunction } from '../common'; + +interface SetupDeps { + expressions: ExpressionsServerSetup; +} + +interface StartDeps { + expression: ExpressionsServerStart; +} + +export type ExpressionRepeatImagePluginSetup = void; +export type ExpressionRepeatImagePluginStart = void; + +export class ExpressionRepeatImagePlugin + implements + Plugin< + ExpressionRepeatImagePluginSetup, + ExpressionRepeatImagePluginStart, + SetupDeps, + StartDeps + > { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionRepeatImagePluginSetup { + expressions.registerFunction(repeatImageFunction); + } + + public start(core: CoreStart): ExpressionRepeatImagePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_repeat_image/tsconfig.json b/src/plugins/expression_repeat_image/tsconfig.json new file mode 100644 index 0000000000000..aa4562ec73576 --- /dev/null +++ b/src/plugins/expression_repeat_image/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../presentation_util/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + ] +} diff --git a/src/plugins/expression_reveal_image/public/plugin.ts b/src/plugins/expression_reveal_image/public/plugin.ts index 5f6496a25f820..c3522b43ca0ca 100755 --- a/src/plugins/expression_reveal_image/public/plugin.ts +++ b/src/plugins/expression_reveal_image/public/plugin.ts @@ -9,6 +9,7 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; import { revealImageRenderer } from './expression_renderers'; +import { revealImageFunction } from '../common/expression_functions'; interface SetupDeps { expressions: ExpressionsSetup; @@ -30,6 +31,7 @@ export class ExpressionRevealImagePlugin StartDeps > { public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionRevealImagePluginSetup { + expressions.registerFunction(revealImageFunction); expressions.registerRenderer(revealImageRenderer); } diff --git a/src/plugins/expression_shape/public/plugin.ts b/src/plugins/expression_shape/public/plugin.ts index cb28f97acd697..b20f357d52a9b 100755 --- a/src/plugins/expression_shape/public/plugin.ts +++ b/src/plugins/expression_shape/public/plugin.ts @@ -9,6 +9,7 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; import { shapeRenderer } from './expression_renderers'; +import { shapeFunction } from '../common/expression_functions'; interface SetupDeps { expressions: ExpressionsSetup; @@ -24,6 +25,7 @@ export type ExpressionShapePluginStart = void; export class ExpressionShapePlugin implements Plugin { public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionShapePluginSetup { + expressions.registerFunction(shapeFunction); expressions.registerRenderer(shapeRenderer); } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 5e7f837d9c686..6ab7abac985cc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -42,7 +42,6 @@ import { render } from './render'; import { replace } from './replace'; import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; -import { repeatImage } from './repeat_image'; import { seriesStyle } from './seriesStyle'; import { sort } from './sort'; import { staticColumn } from './staticColumn'; @@ -90,7 +89,6 @@ export const functions = [ ply, progress, render, - repeatImage, replace, rounddate, rowCount, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts deleted file mode 100644 index 751573e27183b..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts +++ /dev/null @@ -1,84 +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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -import { - getElasticOutline, - resolveWithMissingImage, -} from '../../../../../../src/plugins/presentation_util/common/lib'; -import { Render } from '../../../types'; -import { getFunctionHelp } from '../../../i18n'; - -interface Arguments { - image: string | null; - size: number; - max: number; - emptyImage: string | null; -} - -export interface Return { - count: number; - image: string; - size: number; - max: number; - emptyImage: string | null; -} - -export function repeatImage(): ExpressionFunctionDefinition< - 'repeatImage', - number, - Arguments, - Promise> -> { - const { help, args: argHelp } = getFunctionHelp().repeatImage; - return { - name: 'repeatImage', - aliases: [], - type: 'render', - inputTypes: ['number'], - help, - args: { - emptyImage: { - types: ['string', 'null'], - help: argHelp.emptyImage, - default: null, - }, - image: { - types: ['string', 'null'], - help: argHelp.image, - default: null, - }, - max: { - types: ['number'], - help: argHelp.max, - default: 1000, - }, - size: { - types: ['number'], - default: 100, - help: argHelp.size, - }, - }, - fn: async (count, args) => { - const { elasticOutline } = await getElasticOutline(); - if (args.image === null) { - args.image = elasticOutline; - } - - return { - type: 'render', - as: 'repeatImage', - value: { - count: Math.floor(count), - ...args, - image: resolveWithMissingImage(args.image, elasticOutline), - emptyImage: resolveWithMissingImage(args.emptyImage), - }, - }; - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts index d04e342ccb9e3..8eabae4c661d2 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts @@ -11,20 +11,9 @@ import { metric } from './metric'; import { pie } from './pie'; import { plot } from './plot'; import { progress } from './progress'; -import { repeatImage } from './repeat_image'; -import { table } from './table'; import { text } from './text'; +import { table } from './table'; -export const renderFunctions = [ - image, - markdown, - metric, - pie, - plot, - progress, - repeatImage, - table, - text, -]; +export const renderFunctions = [image, markdown, metric, pie, plot, progress, table, text]; export const renderFunctionFactories = []; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts index f24fad04fab50..0c824fb3dd25e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts @@ -5,9 +5,17 @@ * 2.0. */ -import { revealImageRenderer } from '../../../../../src/plugins/expression_reveal_image/public'; import { errorRenderer, debugRenderer } from '../../../../../src/plugins/expression_error/public'; +import { repeatImageRenderer } from '../../../../../src/plugins/expression_repeat_image/public'; +import { revealImageRenderer } from '../../../../../src/plugins/expression_reveal_image/public'; import { shapeRenderer } from '../../../../../src/plugins/expression_shape/public'; -export const renderFunctions = [revealImageRenderer, debugRenderer, errorRenderer, shapeRenderer]; +export const renderFunctions = [ + revealImageRenderer, + debugRenderer, + errorRenderer, + shapeRenderer, + repeatImageRenderer, +]; + export const renderFunctionFactories = []; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts deleted file mode 100644 index b7a94c2089d8c..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts +++ /dev/null @@ -1,79 +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 $ from 'jquery'; -import { times } from 'lodash'; -import { - getElasticOutline, - isValidUrl, -} from '../../../../../src/plugins/presentation_util/common/lib'; -import { RendererStrings, ErrorStrings } from '../../i18n'; -import { Return as Arguments } from '../functions/common/repeat_image'; -import { RendererFactory } from '../../types'; - -const { repeatImage: strings } = RendererStrings; -const { RepeatImage: errors } = ErrorStrings; - -export const repeatImage: RendererFactory = () => ({ - name: 'repeatImage', - displayName: strings.getDisplayName(), - help: strings.getHelpDescription(), - reuseDomNode: true, - render: async (domNode, config, handlers) => { - let image = config.image; - if (!isValidUrl(config.image)) { - image = (await getElasticOutline()).elasticOutline; - } - const settings = { - ...config, - image, - emptyImage: config.emptyImage || '', - }; - - const container = $('
'); - - function setSize(img: HTMLImageElement) { - if (img.naturalHeight > img.naturalWidth) { - img.height = settings.size; - } else { - img.width = settings.size; - } - } - - function finish() { - $(domNode).append(container); - handlers.done(); - } - - const img = new Image(); - img.onload = function () { - setSize(img); - if (settings.max && settings.count > settings.max) { - settings.count = settings.max; - } - times(settings.count, () => container.append($(img).clone())); - - if (isValidUrl(settings.emptyImage)) { - if (settings.max == null) { - throw new Error(errors.getMissingMaxArgumentErrorMessage()); - } - - const emptyImage = new Image(); - emptyImage.onload = function () { - setSize(emptyImage); - times(settings.max - settings.count, () => container.append($(emptyImage).clone())); - finish(); - }; - emptyImage.src = settings.emptyImage; - } else { - finish(); - } - }; - - img.src = settings.image; - }, -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js index 9372e0035bd1d..72fd42a1ff99e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js @@ -14,18 +14,20 @@ import { ArgumentStrings } from '../../../i18n'; const { Shape: strings } = ArgumentStrings; -const ShapeArgInput = ({ onValueChange, argValue, typeInstance }) => ( - - - - - -); +const ShapeArgInput = ({ onValueChange, argValue, typeInstance }) => { + return ( + + + + + + ); +}; ShapeArgInput.propTypes = { argValue: PropTypes.any.isRequired, diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index 8b6697e78ca37..bf1d08f7f1de1 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -58,16 +58,6 @@ export const ErrorStrings = { }, }), }, - RepeatImage: { - getMissingMaxArgumentErrorMessage: () => - i18n.translate('xpack.canvas.error.repeatImage.missingMaxArgument', { - defaultMessage: '{maxArgument} must be set if providing an {emptyImageArgument}', - values: { - maxArgument: '`max`', - emptyImageArgument: '`emptyImage`', - }, - }), - }, WorkpadDropzone: { getTooManyFilesErrorMessage: () => i18n.translate('xpack.canvas.error.workpadDropzone.tooManyFilesErrorMessage', { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts b/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts deleted file mode 100644 index 8f557229f5bed..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts +++ /dev/null @@ -1,47 +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 { i18n } from '@kbn/i18n'; -import { repeatImage } from '../../../canvas_plugin_src/functions/common/repeat_image'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { CONTEXT, BASE64, URL } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.repeatImageHelpText', { - defaultMessage: 'Configures a repeating image element.', - }), - args: { - emptyImage: i18n.translate('xpack.canvas.functions.repeatImage.args.emptyImageHelpText', { - defaultMessage: - 'Fills the difference between the {CONTEXT} and {maxArg} parameter for the element with this image. ' + - 'Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', - values: { - BASE64, - CONTEXT, - maxArg: '`max`', - URL, - }, - }), - image: i18n.translate('xpack.canvas.functions.repeatImage.args.imageHelpText', { - defaultMessage: - 'The image to repeat. Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', - values: { - BASE64, - URL, - }, - }), - max: i18n.translate('xpack.canvas.functions.repeatImage.args.maxHelpText', { - defaultMessage: 'The maximum number of times the image can repeat.', - }), - size: i18n.translate('xpack.canvas.functions.repeatImage.args.sizeHelpText', { - defaultMessage: - 'The maximum height or width of the image, in pixels. ' + - 'When the image is taller than it is wide, this function limits the height.', - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index 4368e2c8a6084..0ca2c01718b49 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -55,7 +55,6 @@ import { help as ply } from './dict/ply'; import { help as pointseries } from './dict/pointseries'; import { help as progress } from './dict/progress'; import { help as render } from './dict/render'; -import { help as repeatImage } from './dict/repeat_image'; import { help as replace } from './dict/replace'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; @@ -214,7 +213,6 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ pointseries, progress, render, - repeatImage, replace, rounddate, rowCount, diff --git a/x-pack/plugins/canvas/i18n/renderers.ts b/x-pack/plugins/canvas/i18n/renderers.ts index fcdb3382af4ea..80f1a5aecc89e 100644 --- a/x-pack/plugins/canvas/i18n/renderers.ts +++ b/x-pack/plugins/canvas/i18n/renderers.ts @@ -119,16 +119,6 @@ export const RendererStrings = { defaultMessage: 'Render a progress indicator that reveals a percentage of an element', }), }, - repeatImage: { - getDisplayName: () => - i18n.translate('xpack.canvas.renderer.repeatImage.displayName', { - defaultMessage: 'Image repeat', - }), - getHelpDescription: () => - i18n.translate('xpack.canvas.renderer.repeatImage.helpDescription', { - defaultMessage: 'Repeat an image a given number of times', - }), - }, table: { getDisplayName: () => i18n.translate('xpack.canvas.renderer.table.displayName', { diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index a98fc3d210c11..1692d90884a62 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -11,9 +11,10 @@ "data", "embeddable", "expressionError", + "expressionRepeatImage", "expressionRevealImage", - "expressions", "expressionShape", + "expressions", "features", "inspector", "presentationUtil", diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js index a45613f4bc96b..be8e9f673090b 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js @@ -7,6 +7,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { i18n } from '@kbn/i18n'; import { EuiModal, EuiModalBody, @@ -18,8 +19,6 @@ import { EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - import { withSuspense } from '../../../../../../../src/plugins/presentation_util/public'; import { LazyErrorComponent } from '../../../../../../../src/plugins/expression_error/public'; import { Datatable } from '../../datatable'; diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx index f267b48028f7d..1f366468a8338 100644 --- a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx +++ b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx @@ -84,12 +84,12 @@ export const RenderWithFn: FC = ({ [] ); - const render = useCallback(() => { + const render = useCallback(async () => { if (!isEqual(handlers.current, incomingHandlers)) { handlers.current = incomingHandlers; } - renderFn(renderTarget.current!, config, handlers.current); + await renderFn(renderTarget.current!, config, handlers.current); }, [renderTarget, config, renderFn, incomingHandlers]); useEffect(() => { @@ -101,12 +101,13 @@ export const RenderWithFn: FC = ({ resetRenderTarget(); } - try { - render(); - firstRender.current = false; - } catch (err: any) { - onError(err, { title: strings.getRenderErrorMessage(functionName) }); - } + render() + .then(() => { + firstRender.current = false; + }) + .catch((err) => { + onError(err, { title: strings.getRenderErrorMessage(functionName) }); + }); }, [domNode, functionName, onError, render, resetRenderTarget, reuseNode]); return ( diff --git a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx index e06a199f85fee..0470699943bf1 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx @@ -18,7 +18,7 @@ interface Props { export const ShapePicker: FC = ({ shapes, onChange = () => {} }) => ( - {shapes.sort().map((shapeKey) => ( + {shapes.sort().map((shapeKey: string) => ( onChange(shapeKey)}> diff --git a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx index 22d0d8cca84ef..48a6874eace0c 100644 --- a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx +++ b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx @@ -9,7 +9,6 @@ import React, { FC, RefCallback, useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { LazyShapeDrawer, - Shape, ShapeDrawerComponentProps, getDefaultShapeData, SvgConfig, @@ -19,7 +18,7 @@ import { import { withSuspense } from '../../../../../../src/plugins/presentation_util/public'; interface Props { - shape?: Shape; + shape?: string; } const ShapeDrawer = withSuspense(LazyShapeDrawer); diff --git a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js index bb5880b7f40a9..d5f0a2196814e 100644 --- a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js +++ b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js @@ -6,7 +6,6 @@ */ import { image } from '../canvas_plugin_src/renderers/image'; -import { repeatImage } from '../canvas_plugin_src/renderers/repeat_image'; import { markdown } from '../canvas_plugin_src/renderers/markdown'; import { metric } from '../canvas_plugin_src/renderers/metric'; import { pie } from '../canvas_plugin_src/renderers/pie'; @@ -14,11 +13,12 @@ import { plot } from '../canvas_plugin_src/renderers/plot'; import { progress } from '../canvas_plugin_src/renderers/progress'; import { table } from '../canvas_plugin_src/renderers/table'; import { text } from '../canvas_plugin_src/renderers/text'; -import { revealImageRenderer as revealImage } from '../../../../src/plugins/expression_reveal_image/public'; import { errorRenderer as error, debugRenderer as debug, } from '../../../../src/plugins/expression_error/public'; +import { repeatImageRenderer as repeatImage } from '../../../../src/plugins/expression_repeat_image/public'; +import { revealImageRenderer as revealImage } from '../../../../src/plugins/expression_reveal_image/public'; import { shapeRenderer as shape } from '../../../../src/plugins/expression_shape/public'; /** diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index 8c97a78da7f0f..6181df5abe464 100644 --- a/x-pack/plugins/canvas/tsconfig.json +++ b/x-pack/plugins/canvas/tsconfig.json @@ -32,6 +32,7 @@ { "path": "../../../src/plugins/embeddable/tsconfig.json" }, { "path": "../../../src/plugins/expressions/tsconfig.json" }, { "path": "../../../src/plugins/expression_error/tsconfig.json" }, + { "path": "../../../src/plugins/expression_repeat_image/tsconfig.json" }, { "path": "../../../src/plugins/expression_reveal_image/tsconfig.json" }, { "path": "../../../src/plugins/expression_shape/tsconfig.json" }, { "path": "../../../src/plugins/home/tsconfig.json" }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx new file mode 100644 index 0000000000000..bdedc9357fa0e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx @@ -0,0 +1,69 @@ +/* + * 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, ShallowWrapper } from 'enzyme'; + +import { EuiButton, EuiButtonEmpty, EuiFlyout, EuiFlyoutBody } from '@elastic/eui'; + +import { AddDomainFlyout } from './add_domain_flyout'; +import { AddDomainForm } from './add_domain_form'; +import { AddDomainFormErrors } from './add_domain_form_errors'; +import { AddDomainFormSubmitButton } from './add_domain_form_submit_button'; + +describe('AddDomainFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is hidden by default', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + + it('displays the flyout when the button is pressed', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + }); + + describe('flyout', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + }); + + it('displays form errors', () => { + expect(wrapper.find(EuiFlyoutBody).dive().find(AddDomainFormErrors)).toHaveLength(1); + }); + + it('contains a form to add domains', () => { + expect(wrapper.find(AddDomainForm)).toHaveLength(1); + }); + + it('contains a cancel buttonn', () => { + wrapper.find(EuiButtonEmpty).simulate('click'); + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + + it('contains a submit button', () => { + expect(wrapper.find(AddDomainFormSubmitButton)).toHaveLength(1); + }); + + it('hides the flyout on close', () => { + wrapper.find(EuiFlyout).simulate('close'); + + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx new file mode 100644 index 0000000000000..f8511d1e2ef14 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx @@ -0,0 +1,98 @@ +/* + * 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, { useState } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { CANCEL_BUTTON_LABEL } from '../../../../../shared/constants'; + +import { AddDomainForm } from './add_domain_form'; +import { AddDomainFormErrors } from './add_domain_form_errors'; +import { AddDomainFormSubmitButton } from './add_domain_form_submit_button'; + +export const AddDomainFlyout: React.FC = () => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + + return ( + <> + setIsFlyoutVisible(true)} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.openButtonLabel', + { + defaultMessage: 'Add domain', + } + )} + + + {isFlyoutVisible && ( + + setIsFlyoutVisible(false)}> + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.title', + { + defaultMessage: 'Add a new domain', + } + )} +

+
+
+ }> + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.description', + { + defaultMessage: + 'You can add multiple domains to this engine\'s web crawler. Add another domain here and modify the entry points and crawl rules from the "Manage" page.', + } + )} +

+ + + + + + + + setIsFlyoutVisible(false)}> + {CANCEL_BUTTON_LABEL} + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx new file mode 100644 index 0000000000000..6c869d9371f6f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButton, EuiFieldText, EuiForm } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { rerender } from '../../../../../test_helpers'; + +import { AddDomainForm } from './add_domain_form'; + +const MOCK_VALUES = { + addDomainFormInputValue: 'https://', + entryPointValue: '/', +}; + +const MOCK_ACTIONS = { + setAddDomainFormInputValue: jest.fn(), + validateDomain: jest.fn(), +}; + +describe('AddDomainForm', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + setMockValues(MOCK_VALUES); + wrapper = shallow(); + }); + + it('renders', () => { + expect(wrapper.find(EuiForm)).toHaveLength(1); + }); + + it('contains a submit button', () => { + expect(wrapper.find(EuiButton).prop('type')).toEqual('submit'); + }); + + it('validates domain on submit', () => { + wrapper.find(EuiForm).simulate('submit', { preventDefault: jest.fn() }); + + expect(MOCK_ACTIONS.validateDomain).toHaveBeenCalledTimes(1); + }); + + describe('url field', () => { + it('uses the value from the logic', () => { + setMockValues({ + ...MOCK_VALUES, + addDomainFormInputValue: 'test value', + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFieldText).prop('value')).toEqual('test value'); + }); + + it('sets the value in the logic on change', () => { + wrapper.find(EuiFieldText).simulate('change', { target: { value: 'test value' } }); + + expect(MOCK_ACTIONS.setAddDomainFormInputValue).toHaveBeenCalledWith('test value'); + }); + }); + + describe('validate domain button', () => { + it('is enabled when the input has a value', () => { + setMockValues({ + ...MOCK_VALUES, + addDomainFormInputValue: 'https://elastic.co', + }); + + rerender(wrapper); + + expect(wrapper.find(EuiButton).prop('disabled')).toEqual(false); + }); + + it('is disabled when the input value is empty', () => { + setMockValues({ + ...MOCK_VALUES, + addDomainFormInputValue: '', + }); + + rerender(wrapper); + + expect(wrapper.find(EuiButton).prop('disabled')).toEqual(true); + }); + }); + + describe('entry point indicator', () => { + it('is hidden when the entry point is /', () => { + setMockValues({ + ...MOCK_VALUES, + entryPointValue: '/', + }); + + rerender(wrapper); + + expect(wrapper.find(FormattedMessage)).toHaveLength(0); + }); + + it('displays the entry point otherwise', () => { + setMockValues({ + ...MOCK_VALUES, + entryPointValue: '/guide', + }); + + rerender(wrapper); + + expect(wrapper.find(FormattedMessage)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx new file mode 100644 index 0000000000000..de6a33403c2ed --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx @@ -0,0 +1,105 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiCode, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { AddDomainLogic } from './add_domain_logic'; + +export const AddDomainForm: React.FC = () => { + const { setAddDomainFormInputValue, validateDomain } = useActions(AddDomainLogic); + + const { addDomainFormInputValue, entryPointValue } = useValues(AddDomainLogic); + + return ( + <> + { + event.preventDefault(); + validateDomain(); + }} + component="form" + > + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.urlHelpText', + { + defaultMessage: 'Domain URLs require a protocol and cannot contain any paths.', + } + )} + + } + > + + + setAddDomainFormInputValue(e.target.value)} + fullWidth + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.validateButtonLabel', + { + defaultMessage: 'Validate Domain', + } + )} + + + + + + {entryPointValue !== '/' && ( + <> + + +

+ + {entryPointValue}, + }} + /> + +

+
+ + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx new file mode 100644 index 0000000000000..d2c3ac37d58fa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.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 { setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AddDomainFormErrors } from './add_domain_form_errors'; + +describe('AddDomainFormErrors', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is empty when there are no errors', () => { + setMockValues({ + errors: [], + }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('displays all the errors from the logic', () => { + setMockValues({ + errors: ['first error', 'second error'], + }); + + const wrapper = shallow(); + + expect(wrapper.find('p')).toHaveLength(2); + expect(wrapper.find('p').first().text()).toContain('first error'); + expect(wrapper.find('p').last().text()).toContain('second error'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx new file mode 100644 index 0000000000000..890657d4c235a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx @@ -0,0 +1,40 @@ +/* + * 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 { useValues } from 'kea'; + +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AddDomainLogic } from './add_domain_logic'; + +export const AddDomainFormErrors: React.FC = () => { + const { errors } = useValues(AddDomainLogic); + + if (errors.length > 0) { + return ( + + {errors.map((message, index) => ( +

{message}

+ ))} +
+ ); + } + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx new file mode 100644 index 0000000000000..a01d8c55bc87c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton } from '@elastic/eui'; + +import { AddDomainFormSubmitButton } from './add_domain_form_submit_button'; + +describe('AddDomainFormSubmitButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is disabled when the domain has not been validated', () => { + setMockValues({ + hasValidationCompleted: false, + }); + + const wrapper = shallow(); + + expect(wrapper.prop('disabled')).toBe(true); + }); + + it('is enabled when the domain has been validated', () => { + setMockValues({ + hasValidationCompleted: true, + }); + + const wrapper = shallow(); + + expect(wrapper.prop('disabled')).toBe(false); + }); + + it('submits the domain on click', () => { + const submitNewDomain = jest.fn(); + + setMockActions({ + submitNewDomain, + }); + setMockValues({ + hasValidationCompleted: true, + }); + + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + + expect(submitNewDomain).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx new file mode 100644 index 0000000000000..dbf5f86ca70fc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiButton } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { AddDomainLogic } from './add_domain_logic'; + +export const AddDomainFormSubmitButton: React.FC = () => { + const { submitNewDomain } = useActions(AddDomainLogic); + + const { hasValidationCompleted } = useValues(AddDomainLogic); + + return ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.addDomainForm.submitButtonLabel', { + defaultMessage: 'Add domain', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts new file mode 100644 index 0000000000000..3072796b7194f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts @@ -0,0 +1,300 @@ +/* + * 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, + mockFlashMessageHelpers, + mockHttpValues, + mockKibanaValues, +} from '../../../../../__mocks__/kea_logic'; +import '../../../../__mocks__/engine_logic.mock'; + +jest.mock('../../crawler_overview_logic', () => ({ + CrawlerOverviewLogic: { + actions: { + onReceiveCrawlerData: jest.fn(), + }, + }, +})); + +import { nextTick } from '@kbn/test/jest'; + +import { CrawlerOverviewLogic } from '../../crawler_overview_logic'; +import { CrawlerDomain } from '../../types'; + +import { AddDomainLogic, AddDomainLogicValues } from './add_domain_logic'; + +const DEFAULT_VALUES: AddDomainLogicValues = { + addDomainFormInputValue: 'https://', + allowSubmit: false, + entryPointValue: '/', + hasValidationCompleted: false, + errors: [], +}; + +describe('AddDomainLogic', () => { + const { mount } = new LogicMounter(AddDomainLogic); + const { flashSuccessToast } = mockFlashMessageHelpers; + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has default values', () => { + mount(); + expect(AddDomainLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('clearDomainFormInputValue', () => { + beforeAll(() => { + mount({ + addDomainFormInputValue: 'http://elastic.co', + entryPointValue: '/foo', + hasValidationCompleted: true, + errors: ['first error', 'second error'], + }); + + AddDomainLogic.actions.clearDomainFormInputValue(); + }); + + it('should clear the input value', () => { + expect(AddDomainLogic.values.addDomainFormInputValue).toEqual('https://'); + }); + + it('should clear the entry point value', () => { + expect(AddDomainLogic.values.entryPointValue).toEqual('/'); + }); + + it('should reset validation completion', () => { + expect(AddDomainLogic.values.hasValidationCompleted).toEqual(false); + }); + + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + + describe('onSubmitNewDomainError', () => { + it('should set errors', () => { + mount(); + + AddDomainLogic.actions.onSubmitNewDomainError(['first error', 'second error']); + + expect(AddDomainLogic.values.errors).toEqual(['first error', 'second error']); + }); + }); + + describe('onValidateDomain', () => { + beforeAll(() => { + mount({ + addDomainFormInputValue: 'https://elastic.co', + entryPointValue: '/customers', + hasValidationCompleted: true, + errors: ['first error', 'second error'], + }); + + AddDomainLogic.actions.onValidateDomain('https://swiftype.com', '/site-search'); + }); + + it('should set the input value', () => { + expect(AddDomainLogic.values.addDomainFormInputValue).toEqual('https://swiftype.com'); + }); + + it('should set the entry point value', () => { + expect(AddDomainLogic.values.entryPointValue).toEqual('/site-search'); + }); + + it('should flag validation as being completed', () => { + expect(AddDomainLogic.values.hasValidationCompleted).toEqual(true); + }); + + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + + describe('setAddDomainFormInputValue', () => { + beforeAll(() => { + mount({ + addDomainFormInputValue: 'https://elastic.co', + entryPointValue: '/customers', + hasValidationCompleted: true, + errors: ['first error', 'second error'], + }); + + AddDomainLogic.actions.setAddDomainFormInputValue('https://swiftype.com/site-search'); + }); + + it('should set the input value', () => { + expect(AddDomainLogic.values.addDomainFormInputValue).toEqual( + 'https://swiftype.com/site-search' + ); + }); + + it('should clear the entry point value', () => { + expect(AddDomainLogic.values.entryPointValue).toEqual('/'); + }); + + it('should reset validation completion', () => { + expect(AddDomainLogic.values.hasValidationCompleted).toEqual(false); + }); + + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + + describe('submitNewDomain', () => { + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + }); + + describe('listeners', () => { + describe('onSubmitNewDomainSuccess', () => { + it('should flash a success toast', () => { + const { navigateToUrl } = mockKibanaValues; + mount(); + + AddDomainLogic.actions.onSubmitNewDomainSuccess({ id: 'test-domain' } as CrawlerDomain); + + expect(flashSuccessToast).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/crawler/domains/test-domain' + ); + }); + }); + + describe('submitNewDomain', () => { + it('calls the domains endpoint with a JSON formatted body', async () => { + mount({ + addDomainFormInputValue: 'https://elastic.co', + entryPointValue: '/guide', + }); + http.post.mockReturnValueOnce(Promise.resolve({})); + + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/crawler/domains', + { + query: { + respond_with: 'crawler_details', + }, + body: JSON.stringify({ + name: 'https://elastic.co', + entry_points: [{ value: '/guide' }], + }), + } + ); + }); + + describe('on success', () => { + beforeEach(() => { + mount(); + }); + + it('sets crawler data', async () => { + http.post.mockReturnValueOnce( + Promise.resolve({ + domains: [], + }) + ); + + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(CrawlerOverviewLogic.actions.onReceiveCrawlerData).toHaveBeenCalledWith({ + domains: [], + }); + }); + + it('calls the success callback with the most recent domain', async () => { + http.post.mockReturnValueOnce( + Promise.resolve({ + domains: [ + { + id: '1', + name: 'https://elastic.co/guide', + }, + { + id: '2', + name: 'https://swiftype.co/site-search', + }, + ], + }) + ); + jest.spyOn(AddDomainLogic.actions, 'onSubmitNewDomainSuccess'); + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(AddDomainLogic.actions.onSubmitNewDomainSuccess).toHaveBeenCalledWith({ + id: '2', + url: 'https://swiftype.co/site-search', + }); + }); + }); + + describe('on error', () => { + beforeEach(() => { + mount(); + jest.spyOn(AddDomainLogic.actions, 'onSubmitNewDomainError'); + }); + + it('passes error messages to the error callback', async () => { + http.post.mockReturnValueOnce( + Promise.reject({ + body: { + attributes: { + errors: ['first error', 'second error'], + }, + }, + }) + ); + + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(AddDomainLogic.actions.onSubmitNewDomainError).toHaveBeenCalledWith([ + 'first error', + 'second error', + ]); + }); + }); + }); + + describe('validateDomain', () => { + it('extracts the domain and entrypoint and passes them to the callback ', () => { + mount({ addDomainFormInputValue: 'https://swiftype.com/site-search' }); + jest.spyOn(AddDomainLogic.actions, 'onValidateDomain'); + + AddDomainLogic.actions.validateDomain(); + + expect(AddDomainLogic.actions.onValidateDomain).toHaveBeenCalledWith( + 'https://swiftype.com', + '/site-search' + ); + }); + }); + }); + + describe('selectors', () => { + describe('allowSubmit', () => { + it('gets set true when validation is completed', () => { + mount({ hasValidationCompleted: false }); + expect(AddDomainLogic.values.allowSubmit).toEqual(false); + + mount({ hasValidationCompleted: true }); + expect(AddDomainLogic.values.allowSubmit).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts new file mode 100644 index 0000000000000..b05b9454fe8f8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { flashSuccessToast } from '../../../../../shared/flash_messages'; +import { getErrorsFromHttpResponse } from '../../../../../shared/flash_messages/handle_api_errors'; + +import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { ENGINE_CRAWLER_DOMAIN_PATH } from '../../../../routes'; +import { EngineLogic, generateEnginePath } from '../../../engine'; + +import { CrawlerOverviewLogic } from '../../crawler_overview_logic'; +import { CrawlerDataFromServer, CrawlerDomain } from '../../types'; +import { crawlerDataServerToClient } from '../../utils'; + +import { extractDomainAndEntryPointFromUrl } from './utils'; + +export interface AddDomainLogicValues { + addDomainFormInputValue: string; + allowSubmit: boolean; + hasValidationCompleted: boolean; + entryPointValue: string; + errors: string[]; +} + +export interface AddDomainLogicActions { + clearDomainFormInputValue(): void; + setAddDomainFormInputValue(newValue: string): string; + onSubmitNewDomainError(errors: string[]): { errors: string[] }; + onSubmitNewDomainSuccess(domain: CrawlerDomain): { domain: CrawlerDomain }; + onValidateDomain( + newValue: string, + newEntryPointValue: string + ): { newValue: string; newEntryPointValue: string }; + submitNewDomain(): void; + validateDomain(): void; +} + +const DEFAULT_SELECTOR_VALUES = { + addDomainFormInputValue: 'https://', + entryPointValue: '/', +}; + +export const AddDomainLogic = kea>({ + path: ['enterprise_search', 'app_search', 'crawler', 'add_domain'], + actions: () => ({ + clearDomainFormInputValue: true, + setAddDomainFormInputValue: (newValue) => newValue, + onSubmitNewDomainSuccess: (domain) => ({ domain }), + onSubmitNewDomainError: (errors) => ({ errors }), + onValidateDomain: (newValue, newEntryPointValue) => ({ + newValue, + newEntryPointValue, + }), + submitNewDomain: true, + validateDomain: true, + }), + reducers: () => ({ + addDomainFormInputValue: [ + DEFAULT_SELECTOR_VALUES.addDomainFormInputValue, + { + clearDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.addDomainFormInputValue, + setAddDomainFormInputValue: (_, newValue: string) => newValue, + onValidateDomain: (_, { newValue }: { newValue: string }) => newValue, + }, + ], + entryPointValue: [ + DEFAULT_SELECTOR_VALUES.entryPointValue, + { + clearDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.entryPointValue, + setAddDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.entryPointValue, + onValidateDomain: (_, { newEntryPointValue }) => newEntryPointValue, + }, + ], + // TODO When 4-step validation is added this will become a selector as + // we'll use individual step results to determine whether this is true/false + hasValidationCompleted: [ + false, + { + clearDomainFormInputValue: () => false, + setAddDomainFormInputValue: () => false, + onValidateDomain: () => true, + }, + ], + errors: [ + [], + { + clearDomainFormInputValue: () => [], + setAddDomainFormInputValue: () => [], + onValidateDomain: () => [], + submitNewDomain: () => [], + onSubmitNewDomainError: (_, { errors }) => errors, + }, + ], + }), + selectors: ({ selectors }) => ({ + // TODO include selectors.blockingFailures once 4-step validation is migrated + allowSubmit: [ + () => [selectors.hasValidationCompleted], // should eventually also contain selectors.hasBlockingFailures when that is added + (hasValidationCompleted: boolean) => hasValidationCompleted, // && !hasBlockingFailures + ], + }), + listeners: ({ actions, values }) => ({ + onSubmitNewDomainSuccess: ({ domain }) => { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.domainsTable.action.add.successMessage', + { + defaultMessage: "Successfully added domain '{domainUrl}'", + values: { + domainUrl: domain.url, + }, + } + ) + ); + KibanaLogic.values.navigateToUrl( + generateEnginePath(ENGINE_CRAWLER_DOMAIN_PATH, { domainId: domain.id }) + ); + }, + submitNewDomain: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const requestBody = JSON.stringify({ + name: values.addDomainFormInputValue.trim(), + entry_points: [{ value: values.entryPointValue }], + }); + + try { + const response = await http.post(`/api/app_search/engines/${engineName}/crawler/domains`, { + query: { + respond_with: 'crawler_details', + }, + body: requestBody, + }); + + const crawlerData = crawlerDataServerToClient(response as CrawlerDataFromServer); + CrawlerOverviewLogic.actions.onReceiveCrawlerData(crawlerData); + const newDomain = crawlerData.domains[crawlerData.domains.length - 1]; + if (newDomain) { + actions.onSubmitNewDomainSuccess(newDomain); + } + // If there is not a new domain, that means the server responded with a 200 but + // didn't actually persist the new domain to our BE, and we take no action + } catch (e) { + // we surface errors inside the form instead of in flash messages + const errorMessages = getErrorsFromHttpResponse(e); + actions.onSubmitNewDomainError(errorMessages); + } + }, + validateDomain: () => { + const { domain, entryPoint } = extractDomainAndEntryPointFromUrl( + values.addDomainFormInputValue.trim() + ); + actions.onValidateDomain(domain, entryPoint); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts new file mode 100644 index 0000000000000..446545c28ee79 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { extractDomainAndEntryPointFromUrl } from './utils'; + +describe('extractDomainAndEntryPointFromUrl', () => { + it('extracts a provided entry point and domain', () => { + expect(extractDomainAndEntryPointFromUrl('https://elastic.co/guide')).toEqual({ + domain: 'https://elastic.co', + entryPoint: '/guide', + }); + }); + + it('provides a default entry point if there is only a domain', () => { + expect(extractDomainAndEntryPointFromUrl('https://elastic.co')).toEqual({ + domain: 'https://elastic.co', + entryPoint: '/', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.ts new file mode 100644 index 0000000000000..7ba67ae61aa2b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.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. + */ + +export const extractDomainAndEntryPointFromUrl = ( + url: string +): { domain: string; entryPoint: string } => { + let domain = url; + let entryPoint = '/'; + + const pathSlashIndex = url.search(/[^\:\/]\//); + if (pathSlashIndex !== -1) { + domain = url.substring(0, pathSlashIndex + 1); + entryPoint = url.substring(pathSlashIndex + 1); + } + + return { domain, entryPoint }; +}; 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 index 3804ecfe7c67d..610ad1f571699 100644 --- 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 @@ -13,6 +13,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; +import { AddDomainFlyout } from './components/add_domain/add_domain_flyout'; import { DomainsTable } from './components/domains_table'; import { CrawlerOverview } from './crawler_overview'; @@ -44,7 +45,7 @@ describe('CrawlerOverview', () => { // TODO test for CrawlRequestsTable after it is built in a future PR - // TODO test for AddDomainForm after it is built in a future PR + expect(wrapper.find(AddDomainFlyout)).toHaveLength(1); // TODO test for empty state after it is built in a future PR }); 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 index 9e484df35e7a2..0daac399b7b09 100644 --- 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 @@ -9,9 +9,14 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + import { getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; +import { AddDomainFlyout } from './components/add_domain/add_domain_flyout'; import { DomainsTable } from './components/domains_table'; import { CRAWLER_TITLE } from './constants'; import { CrawlerOverviewLogic } from './crawler_overview_logic'; @@ -31,6 +36,21 @@ export const CrawlerOverview: React.FC = () => { pageHeader={{ pageTitle: CRAWLER_TITLE }} isLoading={dataLoading} > + + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.domainsTitle', { + defaultMessage: 'Domains', + })} +

+
+
+ + + +
+ ); 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 a0145cf76908a..c5dd3907c9019 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 @@ -8,13 +8,15 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; +import { ENGINE_CRAWLER_PATH } from '../../routes'; + 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/shared/flash_messages/handle_api_errors.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts index b361e796b4f43..47cbef0bfd953 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts @@ -9,7 +9,7 @@ import '../../__mocks__/kea_logic/kibana_logic.mock'; import { FlashMessagesLogic } from './flash_messages_logic'; -import { flashAPIErrors } from './handle_api_errors'; +import { flashAPIErrors, getErrorsFromHttpResponse } from './handle_api_errors'; describe('flashAPIErrors', () => { const mockHttpError = { @@ -68,10 +68,29 @@ describe('flashAPIErrors', () => { try { flashAPIErrors(Error('whatever') as any); } catch (e) { - expect(e.message).toEqual('whatever'); expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([ - { type: 'error', message: 'An unexpected error occurred' }, + { type: 'error', message: expect.any(String) }, ]); } }); }); + +describe('getErrorsFromHttpResponse', () => { + it('should return errors from the response if present', () => { + expect( + getErrorsFromHttpResponse({ + body: { attributes: { errors: ['first error', 'second error'] } }, + } as any) + ).toEqual(['first error', 'second error']); + }); + + it('should return a message from the responnse if no errors', () => { + expect(getErrorsFromHttpResponse({ body: { message: 'test message' } } as any)).toEqual([ + 'test message', + ]); + }); + + it('should return the a default message otherwise', () => { + expect(getErrorsFromHttpResponse({} as any)).toEqual([expect.any(String)]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts index 1b5dab0839663..7c82dfb971a1d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts @@ -40,13 +40,22 @@ export const defaultErrorMessage = i18n.translate( } ); +export const getErrorsFromHttpResponse = (response: HttpResponse) => { + return Array.isArray(response?.body?.attributes?.errors) + ? response.body!.attributes.errors + : [response?.body?.message || defaultErrorMessage]; +}; + /** * Converts API/HTTP errors into user-facing Flash Messages */ -export const flashAPIErrors = (error: HttpResponse, { isQueued }: Options = {}) => { - const errorFlashMessages: IFlashMessage[] = Array.isArray(error?.body?.attributes?.errors) - ? error.body!.attributes.errors.map((message) => ({ type: 'error', message })) - : [{ type: 'error', message: error?.body?.message || defaultErrorMessage }]; +export const flashAPIErrors = ( + response: HttpResponse, + { isQueued }: Options = {} +) => { + const errorFlashMessages: IFlashMessage[] = getErrorsFromHttpResponse( + response + ).map((message) => ({ type: 'error', message })); if (isQueued) { FlashMessagesLogic.actions.setQueuedMessages(errorFlashMessages); @@ -56,7 +65,7 @@ export const flashAPIErrors = (error: HttpResponse, { isQueued }: // If this was a programming error or a failed request (such as a CORS) error, // we rethrow the error so it shows up in the developer console - if (!error?.body?.message) { - throw error; + if (!response?.body?.message) { + throw response; } }; 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 index 06a206017fbd1..fd478e35064c5 100644 --- 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 @@ -43,6 +43,62 @@ describe('crawler routes', () => { }); }); + describe('POST /api/app_search/engines/{name}/crawler/domains', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{name}/crawler/domains', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:name/crawler/domains', + }); + }); + + it('validates correctly with params and body', () => { + const request = { + params: { name: 'some-engine' }, + body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] }, + }; + mockRouter.shouldValidate(request); + }); + + it('accepts a query param', () => { + const request = { + params: { name: 'some-engine' }, + body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] }, + query: { respond_with: 'crawler_details' }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails validation without a name param', () => { + const request = { + params: {}, + body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] }, + }; + mockRouter.shouldThrow(request); + }); + + it('fails validation without a body', () => { + const request = { + params: { name: 'some-engine' }, + body: {}, + }; + mockRouter.shouldThrow(request); + }); + }); + describe('DELETE /api/app_search/engines/{name}/crawler/domains/{id}', () => { let mockRouter: MockRouter; 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 index 6c8ed7a49c64a..35bfae763bb9a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -27,6 +27,31 @@ export function registerCrawlerRoutes({ }) ); + router.post( + { + path: '/api/app_search/engines/{name}/crawler/domains', + validate: { + params: schema.object({ + name: schema.string(), + }), + body: schema.object({ + name: schema.string(), + entry_points: schema.arrayOf( + schema.object({ + value: schema.string(), + }) + ), + }), + query: schema.object({ + respond_with: schema.maybe(schema.string()), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:name/crawler/domains', + }) + ); + router.delete( { path: '/api/app_search/engines/{name}/crawler/domains/{id}', diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 27d4a5c9fd399..48a23a967059e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -62,20 +62,21 @@ export const DEFAULT_INDICATOR_SOURCE_PATH = 'threatintel.indicator'; export const INDICATOR_DESTINATION_PATH = 'threat.indicator'; export enum SecurityPageName { - overview = 'overview', - detections = 'detections', + administration = 'administration', alerts = 'alerts', - rules = 'rules', + case = 'case', + detections = 'detections', + endpoints = 'endpoints', + eventFilters = 'event_filters', exceptions = 'exceptions', hosts = 'hosts', network = 'network', - timelines = 'timelines', - case = 'case', - administration = 'administration', - endpoints = 'endpoints', + overview = 'overview', policies = 'policies', + rules = 'rules', + timelines = 'timelines', trustedApps = 'trusted_apps', - eventFilters = 'event_filters', + ueba = 'ueba', } export const TIMELINES_PATH = '/timelines'; @@ -86,6 +87,7 @@ export const ALERTS_PATH = '/alerts'; export const RULES_PATH = '/rules'; export const EXCEPTIONS_PATH = '/exceptions'; export const HOSTS_PATH = '/hosts'; +export const UEBA_PATH = '/ueba'; export const NETWORK_PATH = '/network'; export const MANAGEMENT_PATH = '/administration'; export const ENDPOINTS_PATH = `${MANAGEMENT_PATH}/endpoints`; @@ -100,6 +102,7 @@ export const APP_RULES_PATH = `${APP_PATH}${RULES_PATH}`; export const APP_EXCEPTIONS_PATH = `${APP_PATH}${EXCEPTIONS_PATH}`; export const APP_HOSTS_PATH = `${APP_PATH}${HOSTS_PATH}`; +export const APP_UEBA_PATH = `${APP_PATH}${UEBA_PATH}`; export const APP_NETWORK_PATH = `${APP_PATH}${NETWORK_PATH}`; export const APP_TIMELINES_PATH = `${APP_PATH}${TIMELINES_PATH}`; export const APP_CASES_PATH = `${APP_PATH}${CASES_PATH}`; @@ -119,6 +122,11 @@ export const DEFAULT_INDEX_PATTERN = [ 'winlogbeat-*', ]; +export const DEFAULT_INDEX_PATTERN_EXPERIMENTAL = [ + // TODO: Steph/ueba TEMP for testing UEBA data + 'ml_host_risk_score_*', +]; + /** This Kibana Advanced Setting enables the `Security news` feed widget */ export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed'; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index a9a81aa285af7..6d4a2b78840ea 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -11,11 +11,12 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; * A list of allowed values that can be used in `xpack.securitySolution.enableExperimental`. * This object is then used to validate and parse the value entered. */ -const allowedExperimentalValues = Object.freeze({ - trustedAppsByPolicyEnabled: false, +export const allowedExperimentalValues = Object.freeze({ metricsEntitiesEnabled: false, ruleRegistryEnabled: false, tGridEnabled: false, + trustedAppsByPolicyEnabled: false, + uebaEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 06d4a16699b8f..208579ffacabe 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -71,14 +71,27 @@ import { CtiEventEnrichmentStrategyResponse, CtiQueries, } from './cti'; +import { + HostRulesRequestOptions, + HostRulesStrategyResponse, + HostTacticsRequestOptions, + HostTacticsStrategyResponse, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, + UebaQueries, + UserRulesRequestOptions, + UserRulesStrategyResponse, +} from './ueba'; export * from './hosts'; export * from './matrix_histogram'; export * from './network'; +export * from './ueba'; export type FactoryQueryTypes = | HostsQueries | HostsKpiQueries + | UebaQueries | NetworkQueries | NetworkKpiQueries | CtiQueries @@ -109,6 +122,14 @@ export type StrategyResponseType = T extends HostsQ ? HostsStrategyResponse : T extends HostsQueries.details ? HostDetailsStrategyResponse + : T extends UebaQueries.riskScore + ? RiskScoreStrategyResponse + : T extends UebaQueries.hostRules + ? HostRulesStrategyResponse + : T extends UebaQueries.userRules + ? UserRulesStrategyResponse + : T extends UebaQueries.hostTactics + ? HostTacticsStrategyResponse : T extends HostsQueries.overview ? HostsOverviewStrategyResponse : T extends HostsQueries.authentications @@ -199,6 +220,14 @@ export type StrategyRequestType = T extends HostsQu ? NetworkKpiUniqueFlowsRequestOptions : T extends NetworkKpiQueries.uniquePrivateIps ? NetworkKpiUniquePrivateIpsRequestOptions + : T extends UebaQueries.riskScore + ? RiskScoreRequestOptions + : T extends UebaQueries.hostRules + ? HostRulesRequestOptions + : T extends UebaQueries.userRules + ? UserRulesRequestOptions + : T extends UebaQueries.hostTactics + ? HostTacticsRequestOptions : T extends typeof MatrixHistogramQuery ? MatrixHistogramRequestOptions : T extends CtiQueries.eventEnrichment diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts new file mode 100644 index 0000000000000..f7406e32d1869 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Maybe } from '../../../common'; + +export enum RiskScoreFields { + hostName = 'host_name', + riskKeyword = 'risk_keyword', + riskScore = 'risk_score', +} +export interface RiskScoreItem { + _id?: Maybe; + [RiskScoreFields.hostName]: Maybe; + [RiskScoreFields.riskKeyword]: Maybe; + [RiskScoreFields.riskScore]: Maybe; +} +export enum HostRulesFields { + hits = 'hits', + riskScore = 'risk_score', + ruleName = 'rule_name', + ruleType = 'rule_type', +} +export interface HostRulesItem { + _id?: Maybe; + [HostRulesFields.hits]: Maybe; + [HostRulesFields.riskScore]: Maybe; + [HostRulesFields.ruleName]: Maybe; + [HostRulesFields.ruleType]: Maybe; +} +export enum UserRulesFields { + userName = 'user_name', + riskScore = 'risk_score', + rules = 'rules', + ruleCount = 'rule_count', +} +export enum HostTacticsFields { + hits = 'hits', + riskScore = 'risk_score', + tactic = 'tactic', + technique = 'technique', +} +export interface HostTacticsItem { + _id?: Maybe; + [HostTacticsFields.hits]: Maybe; + [HostTacticsFields.riskScore]: Maybe; + [HostTacticsFields.tactic]: Maybe; + [HostTacticsFields.technique]: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts new file mode 100644 index 0000000000000..cb6469c6209a6 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts @@ -0,0 +1,48 @@ +/* + * 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostRulesItem, HostRulesFields } from '../common'; +import { CursorType, Hit, Inspect, Maybe, PageInfoPaginated, SortField } from '../../../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface HostRulesHit extends Hit { + key: string; + doc_count: number; + risk_score: { + value?: number; + }; + rule_type: { + buckets?: Array<{ + key: string; + doc_count: number; + }>; + }; + rule_count: { + value: number; + }; +} + +export interface HostRulesEdges { + node: HostRulesItem; + cursor: CursorType; +} + +export interface HostRulesStrategyResponse extends IEsSearchResponse { + edges: HostRulesEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface HostRulesRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; + hostName: string; +} + +export type HostRulesSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts new file mode 100644 index 0000000000000..c55058dc6be04 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostTacticsItem, HostTacticsFields } from '../common'; +import { CursorType, Hit, Inspect, Maybe, PageInfoPaginated, SortField } from '../../../common'; +import { RequestOptionsPaginated } from '../..'; +export interface HostTechniqueHit { + key: string; + doc_count: number; + risk_score: { + value?: number; + }; +} +export interface HostTacticsHit extends Hit { + key: string; + doc_count: number; + risk_score: { + value?: number; + }; + technique: { + buckets?: HostTechniqueHit[]; + }; + tactic_count: { + value: number; + }; +} + +export interface HostTacticsEdges { + node: HostTacticsItem; + cursor: CursorType; +} + +export interface HostTacticsStrategyResponse extends IEsSearchResponse { + edges: HostTacticsEdges[]; + techniqueCount: number; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface HostTacticsRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; + hostName: string; +} + +export type HostTacticsSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts new file mode 100644 index 0000000000000..1d166e36f6973 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './common'; +export * from './host_rules'; +export * from './host_tactics'; +export * from './risk_score'; +export * from './user_rules'; + +export enum UebaQueries { + hostRules = 'hostRules', + hostTactics = 'hostTactics', + riskScore = 'riskScore', + userRules = 'userRules', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts new file mode 100644 index 0000000000000..14c1533755056 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts @@ -0,0 +1,47 @@ +/* + * 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { RiskScoreItem, RiskScoreFields } from '../common'; +import { CursorType, Hit, Inspect, Maybe, PageInfoPaginated, SortField } from '../../../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface RiskScoreHit extends Hit { + _source: { + '@timestamp': string; + }; + key: string; + doc_count: number; + risk_score: { + value?: number; + }; + risk_keyword: { + buckets?: Array<{ + key: string; + doc_count: number; + }>; + }; +} + +export interface RiskScoreEdges { + node: RiskScoreItem; + cursor: CursorType; +} + +export interface RiskScoreStrategyResponse extends IEsSearchResponse { + edges: RiskScoreEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface RiskScoreRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; +} + +export type RiskScoreSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts new file mode 100644 index 0000000000000..c7302c10fab3b --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts @@ -0,0 +1,78 @@ +/* + * 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostRulesFields, UserRulesFields } from '../common'; +import { Hit, Inspect, Maybe, PageInfoPaginated, SearchHit, SortField } from '../../../common'; +import { HostRulesEdges, RequestOptionsPaginated } from '../..'; + +export interface RuleNameHit extends Hit { + key: string; + doc_count: number; + risk_score: { + value: number; + }; + rule_type: { + buckets?: Array<{ + key: string; + doc_count: number; + }>; + }; +} +export interface UserRulesHit extends Hit { + _source: { + '@timestamp': string; + }; + key: string; + doc_count: number; + risk_score: { + value: number; + }; + rule_count: { + value: number; + }; + rule_name: { + buckets?: RuleNameHit[]; + }; +} + +export interface UserRulesByUser { + _id?: Maybe; + [UserRulesFields.userName]: string; + [UserRulesFields.riskScore]: number; + [UserRulesFields.ruleCount]: number; + [UserRulesFields.rules]: HostRulesEdges[]; +} + +export interface UserRulesStrategyUserResponse { + [UserRulesFields.userName]: string; + [UserRulesFields.riskScore]: number; + edges: HostRulesEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; +} + +export interface UserRulesStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; + data: UserRulesStrategyUserResponse[]; +} + +export interface UserRulesRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; + hostName: string; +} + +export type UserRulesSortField = SortField; + +export interface UsersRulesHit extends SearchHit { + aggregations: { + user_data: { + buckets: UserRulesHit[]; + }; + }; +} diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 05cf99195774b..e7c6464bc1546 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -308,6 +308,7 @@ export enum TimelineId { detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', + uebaPageExternalAlerts = 'ueba-page-external-alerts', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes @@ -320,6 +321,7 @@ export const TimelineIdLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineId.detectionsRulesDetailsPage), runtimeTypes.literal(TimelineId.detectionsPage), runtimeTypes.literal(TimelineId.networkPageExternalAlerts), + runtimeTypes.literal(TimelineId.uebaPageExternalAlerts), runtimeTypes.literal(TimelineId.active), runtimeTypes.literal(TimelineId.test), ]); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts index f125218b68c09..59af6737e495f 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts @@ -7,13 +7,14 @@ import { getDeepLinks } from '.'; import { Capabilities } from '../../../../../../src/core/public'; import { SecurityPageName } from '../types'; +import { mockGlobalState } from '../../common/mock'; describe('public search functions', () => { it('returns a subset of links for basic license, full set for platinum', () => { const basicLicense = 'basic'; const platinumLicense = 'platinum'; - const basicLinks = getDeepLinks(basicLicense); - const platinumLinks = getDeepLinks(platinumLicense); + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense); + const platinumLinks = getDeepLinks(mockGlobalState.app.enableExperimental, platinumLicense); basicLinks.forEach((basicLink, index) => { const platinumLink = platinumLinks[index]; @@ -26,7 +27,7 @@ describe('public search functions', () => { it('returns case links for basic license with only read_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: true, crud_cases: false }, } as unknown) as Capabilities); @@ -35,7 +36,7 @@ describe('public search functions', () => { it('returns case links with NO deepLinks for basic license with only read_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: true, crud_cases: false }, } as unknown) as Capabilities); @@ -46,7 +47,7 @@ describe('public search functions', () => { it('returns case links with deepLinks for basic license with crud_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: true, crud_cases: true }, } as unknown) as Capabilities); @@ -57,7 +58,7 @@ describe('public search functions', () => { it('returns NO case links for basic license with NO read_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: false, crud_cases: false }, } as unknown) as Capabilities); @@ -66,17 +67,38 @@ describe('public search functions', () => { it('returns case links for basic license with undefined capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, undefined); + const basicLinks = getDeepLinks( + mockGlobalState.app.enableExperimental, + basicLicense, + undefined + ); expect(basicLinks.some((l) => l.id === SecurityPageName.case)).toBeTruthy(); }); it('returns case deepLinks for basic license with undefined capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, undefined); + const basicLinks = getDeepLinks( + mockGlobalState.app.enableExperimental, + basicLicense, + undefined + ); expect( (basicLinks.find((l) => l.id === SecurityPageName.case)?.deepLinks?.length ?? 0) > 0 ).toBeTruthy(); }); + + it('returns NO ueba link when enableExperimental.uebaEnabled === false', () => { + const deepLinks = getDeepLinks(mockGlobalState.app.enableExperimental); + expect(deepLinks.some((l) => l.id === SecurityPageName.ueba)).toBeFalsy(); + }); + + it('returns ueba link when enableExperimental.uebaEnabled === true', () => { + const deepLinks = getDeepLinks({ + ...mockGlobalState.app.enableExperimental, + uebaEnabled: true, + }); + expect(deepLinks.some((l) => l.id === SecurityPageName.ueba)).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index f5cec592c7abf..871f1a01e3de0 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -27,6 +27,7 @@ import { TIMELINES, CASE, MANAGE, + UEBA, } from '../translations'; import { OVERVIEW_PATH, @@ -40,7 +41,9 @@ import { ENDPOINTS_PATH, TRUSTED_APPS_PATH, EVENT_FILTERS_PATH, + UEBA_PATH, } from '../../../common/constants'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; export const topDeepLinks: AppDeepLink[] = [ { @@ -90,6 +93,18 @@ export const topDeepLinks: AppDeepLink[] = [ ], order: 9003, }, + { + id: SecurityPageName.ueba, + title: UEBA, + path: UEBA_PATH, + navLinkStatus: AppNavLinkStatus.visible, + keywords: [ + i18n.translate('xpack.securitySolution.search.ueba', { + defaultMessage: 'Users & Entities', + }), + ], + order: 9004, + }, { id: SecurityPageName.timelines, title: TIMELINES, @@ -100,7 +115,7 @@ export const topDeepLinks: AppDeepLink[] = [ defaultMessage: 'Timelines', }), ], - order: 9004, + order: 9005, }, { id: SecurityPageName.case, @@ -112,7 +127,7 @@ export const topDeepLinks: AppDeepLink[] = [ defaultMessage: 'Cases', }), ], - order: 9005, + order: 9006, }, { id: SecurityPageName.administration, @@ -254,6 +269,9 @@ const nestedDeepLinks: SecurityDeepLinks = { }, ], }, + [SecurityPageName.ueba]: { + base: [], + }, [SecurityPageName.timelines]: { base: [ { @@ -316,18 +334,22 @@ const nestedDeepLinks: SecurityDeepLinks = { /** * A function that generates the plugin deepLinks + * @param enableExperimental ExperimentalFeatures arg * @param licenseType optional string for license level, if not provided basic is assumed. + * @param capabilities optional arg for app start capabilities */ export function getDeepLinks( + enableExperimental: ExperimentalFeatures, licenseType?: LicenseType, capabilities?: ApplicationStart['capabilities'] ): AppDeepLink[] { return topDeepLinks .filter( (deepLink) => - deepLink.id !== SecurityPageName.case || - capabilities == null || - (deepLink.id === SecurityPageName.case && capabilities.siem.read_cases === true) + (deepLink.id !== SecurityPageName.case && deepLink.id !== SecurityPageName.ueba) || // is not cases or ueba + (deepLink.id === SecurityPageName.case && + (capabilities == null || capabilities.siem.read_cases === true)) || // is cases with at least read only caps + (deepLink.id === SecurityPageName.ueba && enableExperimental.uebaEnabled) // is ueba with ueba feature flag enabled ) .map((deepLink) => { const deepLinkId = deepLink.id as SecurityDeepLinkName; @@ -370,11 +392,13 @@ export function isPremiumLicense(licenseType?: LicenseType): boolean { export function updateGlobalNavigation({ capabilities, updater$, + enableExperimental, }: { capabilities: ApplicationStart['capabilities']; updater$: Subject; + enableExperimental: ExperimentalFeatures; }) { - const deepLinks = getDeepLinks(undefined, capabilities); + const deepLinks = getDeepLinks(enableExperimental, undefined, capabilities); const updatedDeepLinks = deepLinks.map((link) => { switch (link.id) { case SecurityPageName.case: diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index d6f8516d43a72..686dafca76d99 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -24,6 +24,7 @@ import { APP_ENDPOINTS_PATH, APP_TRUSTED_APPS_PATH, APP_EVENT_FILTERS_PATH, + APP_UEBA_PATH, SecurityPageName, } from '../../../common/constants'; @@ -70,6 +71,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'network', }, + [SecurityPageName.ueba]: { + id: SecurityPageName.ueba, + name: i18n.UEBA, + href: APP_UEBA_PATH, + disabled: false, + urlKey: 'ueba', + }, [SecurityPageName.timelines]: { id: SecurityPageName.timelines, name: i18n.TIMELINES, diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index 81437ec9ec6f6..e880da57cf374 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -10,7 +10,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Redirect, Route, Switch } from 'react-router-dom'; import { OVERVIEW_PATH } from '../../common/constants'; -import { NotFoundPage } from '../app/404'; +import { NotFoundPage } from './404'; import { SecurityApp } from './app'; import { RenderAppProps } from './types'; @@ -43,6 +43,8 @@ export const renderApp = ({ ...subPlugins.exceptions.routes, ...subPlugins.hosts.routes, ...subPlugins.network.routes, + // will be undefined if enabledExperimental.uebaEnabled === false + ...(subPlugins.ueba != null ? subPlugins.ueba.routes : []), ...subPlugins.timelines.routes, ...subPlugins.cases.routes, ...subPlugins.management.routes, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 027789713a2ae..c3cf11f35211e 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -19,6 +19,10 @@ export const NETWORK = i18n.translate('xpack.securitySolution.navigation.network defaultMessage: 'Network', }); +export const UEBA = i18n.translate('xpack.securitySolution.navigation.ueba', { + defaultMessage: 'Users & Entities', +}); + export const RULES = i18n.translate('xpack.securitySolution.navigation.rules', { defaultMessage: 'Rules', }); diff --git a/x-pack/plugins/security_solution/public/app/types.ts b/x-pack/plugins/security_solution/public/app/types.ts index 8056c4092091c..490ff8936c18c 100644 --- a/x-pack/plugins/security_solution/public/app/types.ts +++ b/x-pack/plugins/security_solution/public/app/types.ts @@ -54,19 +54,21 @@ export interface SecuritySubPlugin { export type SecuritySubPluginKeyStore = | 'hosts' | 'network' + | 'ueba' | 'timeline' | 'hostList' | 'alertList' | 'management'; export type SecurityDeepLinkName = - | SecurityPageName.overview + | SecurityPageName.administration + | SecurityPageName.case | SecurityPageName.detections | SecurityPageName.hosts | SecurityPageName.network + | SecurityPageName.overview | SecurityPageName.timelines - | SecurityPageName.case - | SecurityPageName.administration; + | SecurityPageName.ueba; interface SecurityDeepLink { base: AppDeepLink[]; diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index dea19e1366875..46d05d9712227 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -77,6 +77,7 @@ export interface HeaderPageProps extends HeaderProps { children?: React.ReactNode; draggableArguments?: DraggableArguments; hideSourcerer?: boolean; + sourcererScope?: SourcererScopeName; subtitle?: SubtitleProps['items']; subtitle2?: SubtitleProps['items']; title: TitleProp; @@ -115,6 +116,7 @@ const HeaderPageComponent: React.FC = ({ draggableArguments, hideSourcerer = false, isLoading, + sourcererScope = SourcererScopeName.default, subtitle, subtitle2, title, @@ -145,7 +147,7 @@ const HeaderPageComponent: React.FC = ({ {children} )} - {!hideSourcerer && } + {!hideSourcerer && } {/* Manually add a 'padding-bottom' to header */} diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx new file mode 100644 index 0000000000000..614ddf698d6b7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.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 { UebaTableType } from '../../../ueba/store/model'; +import { UEBA_PATH } from '../../../../common/constants'; +import { appendSearch } from './helpers'; + +export const getUebaUrl = (search?: string) => `${UEBA_PATH}${appendSearch(search)}`; + +export const getTabsOnUebaUrl = (tabName: UebaTableType, search?: string) => + `/${tabName}${appendSearch(search)}`; + +export const getUebaDetailsUrl = (detailName: string, search?: string) => + `/${detailName}${appendSearch(search)}`; + +export const getTabsOnUebaDetailsUrl = ( + detailName: string, + tabName: UebaTableType, + search?: string +) => `/${detailName}/${tabName}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 0b6b77aab00e4..cc0fdb3923dce 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -42,6 +42,7 @@ import { isUrlInvalid } from '../../utils/validators'; import * as i18n from './translations'; import { SecurityPageName } from '../../../app/types'; +import { getUebaDetailsUrl } from '../link_to/redirect_to_ueba'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -61,6 +62,45 @@ export const PortContainer = styled.div` `; // Internal Links +const UebaDetailsLinkComponent: React.FC<{ + children?: React.ReactNode; + hostName: string; + isButton?: boolean; +}> = ({ children, hostName, isButton }) => { + const { formatUrl, search } = useFormatUrl(SecurityPageName.ueba); + const { navigateToApp } = useKibana().services.application; + const goToUebaDetails = useCallback( + (ev) => { + ev.preventDefault(); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.ueba, + path: getUebaDetailsUrl(encodeURIComponent(hostName), search), + }); + }, + [hostName, navigateToApp, search] + ); + + return isButton ? ( + + {children ? children : hostName} + + ) : ( + + {children ? children : hostName} + + ); +}; + +export const UebaDetailsLink = React.memo(UebaDetailsLinkComponent); + const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: string; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 4ad26533cb58c..aae97d90cb4b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -15,6 +15,7 @@ import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/p import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../../cases/pages/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../../timelines/pages'; +import { getBreadcrumbs as getUebaBreadcrumbs } from '../../../../ueba/pages/details/utils'; import { getBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/common/breadcrumbs'; import { SecurityPageName } from '../../../../app/types'; import { @@ -23,6 +24,7 @@ import { NetworkRouteSpyState, TimelineRouteSpyState, AdministrationRouteSpyState, + UebaRouteSpyState, } from '../../../utils/route/types'; import { getAppOverviewUrl } from '../../link_to'; @@ -60,6 +62,9 @@ const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpySt const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => spyState != null && spyState.pageName === SecurityPageName.hosts; +const isUebaRoutes = (spyState: RouteSpyState): spyState is UebaRouteSpyState => + spyState != null && spyState.pageName === SecurityPageName.ueba; + const isTimelinesRoutes = (spyState: RouteSpyState): spyState is TimelineRouteSpyState => spyState != null && spyState.pageName === SecurityPageName.timelines; @@ -124,6 +129,25 @@ export const getBreadcrumbsForRoute = ( ), ]; } + if (isUebaRoutes(spyState) && object.navTabs) { + const tempNav: SearchNavTab = { urlKey: 'ueba', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + siemRootBreadcrumb, + ...getUebaBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ), + getUrlForApp + ), + ]; + } if (isRulesRoutes(spyState) && object.navTabs) { const tempNav: SearchNavTab = { urlKey: SecurityPageName.rules, isDetailPage: false }; let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx index 2ca0d878078aa..4d9a8a704dde5 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import deepEqual from 'fast-deep-equal'; -import { useNavigation } from '../../../lib/kibana/hooks'; +import { useNavigation } from '../../../lib/kibana'; import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/telemetry'; import { TabNavigationProps, TabNavigationItemProps } from './types'; @@ -84,7 +84,6 @@ export const TabNavigationComponent: React.FC = ({ () => Object.values(navTabs).map((tab) => { const isSelected = selectedTabId === tab.id; - return ( ; -} export interface TabNavigationComponentProps { pageName: string; tabName: SiemRouteType | undefined; @@ -43,22 +39,30 @@ export interface NavTab { urlKey?: UrlStateType; pageId?: SecurityPageName; } + export type SecurityNavKey = - | SecurityPageName.overview + | SecurityPageName.administration + | SecurityPageName.alerts + | SecurityPageName.case + | SecurityPageName.endpoints + | SecurityPageName.eventFilters + | SecurityPageName.exceptions | SecurityPageName.hosts | SecurityPageName.network - | SecurityPageName.alerts + | SecurityPageName.overview | SecurityPageName.rules - | SecurityPageName.exceptions | SecurityPageName.timelines - | SecurityPageName.case - | SecurityPageName.administration - | SecurityPageName.endpoints | SecurityPageName.trustedApps - | SecurityPageName.eventFilters; + | SecurityPageName.ueba; export type SecurityNav = Record; +export type GenericNavRecord = Record; + +export interface SecuritySolutionTabNavigationProps { + display?: 'default' | 'condensed'; + navTabs: GenericNavRecord; +} export type GetUrlForApp = ( appId: string, options?: { deepLinkId?: string; path?: string; absolute?: boolean } diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index af88aacb7602a..4bd5a43684792 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -16,10 +16,12 @@ import { TimelineTabs } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { UrlInputsModel } from '../../../store/inputs/model'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; jest.mock('../../../lib/kibana/kibana_react'); jest.mock('../../../lib/kibana'); jest.mock('../../../hooks/use_selector'); +jest.mock('../../../hooks/use_experimental_features'); jest.mock('../../../utils/route/use_route_spy'); describe('useSecuritySolutionNavigation', () => { @@ -70,6 +72,7 @@ describe('useSecuritySolutionNavigation', () => { ]; beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); (useDeepEqualSelector as jest.Mock).mockReturnValue({ urlState: mockUrlState }); (useRouteSpy as jest.Mock).mockReturnValue(mockRouteSpy); (useKibana as jest.Mock).mockReturnValue({ @@ -231,6 +234,17 @@ describe('useSecuritySolutionNavigation', () => { `); }); + // TODO: Steph/ueba remove when no longer experimental + it('should include ueba when feature flag is on', async () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(() => + useSecuritySolutionNavigation() + ); + + // @ts-ignore possibly undefined, but if undefined we want this test to fail + expect(result.current.items[2].items[2].id).toEqual(SecurityPageName.ueba); + }); + describe('Permission gated routes', () => { describe('cases', () => { it('should display the cases navigation item when the user has read permissions', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx index 39c6885e8dff5..5165a903bbde1 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx @@ -13,6 +13,8 @@ import { makeMapStateToProps } from '../../url_state/helpers'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; import { navTabs } from '../../../../app/home/home_navigations'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; +import { GenericNavRecord } from '../types'; /** * @description - This hook provides the structure necessary by the KibanaPageTemplate for rendering the primary security_solution side navigation. @@ -29,6 +31,12 @@ export const useSecuritySolutionNavigation = () => { const { detailName, flowTarget, pageName, pathName, search, state, tabName } = routeProps; + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); + let enabledNavTabs: GenericNavRecord = (navTabs as unknown) as GenericNavRecord; + if (!uebaEnabled) { + const { ueba, ...rest } = enabledNavTabs; + enabledNavTabs = rest; + } useEffect(() => { if (pathName || pageName) { setBreadcrumbs( @@ -36,7 +44,7 @@ export const useSecuritySolutionNavigation = () => { detailName, filters: urlState.filters, flowTarget, - navTabs, + navTabs: enabledNavTabs, pageName, pathName, query: urlState.query, @@ -65,12 +73,13 @@ export const useSecuritySolutionNavigation = () => { tabName, getUrlForApp, navigateToUrl, + enabledNavTabs, ]); return usePrimaryNavigation({ query: urlState.query, filters: urlState.filters, - navTabs, + navTabs: enabledNavTabs, pageName, sourcerer: urlState.sourcerer, savedQuery: urlState.savedQuery, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index fffe59fceff41..feeeacf6124e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -20,7 +20,6 @@ export const usePrimaryNavigationItems = ({ ...urlStateProps }: PrimaryNavigationItemsProps): Array> => { const { navigateTo, getAppUrl } = useNavigation(); - const getSideNav = useCallback( (tab: NavTab) => { const { id, name, disabled } = tab; @@ -62,7 +61,6 @@ export const usePrimaryNavigationItems = ({ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; - return useMemo( () => [ { @@ -76,7 +74,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { }, { ...securityNavGroup.explore, - items: [navTabs.hosts, navTabs.network], + items: [navTabs.hosts, navTabs.network, ...(navTabs.ueba != null ? [navTabs.ueba] : [])], }, { ...securityNavGroup.investigate, diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index 3d0be80e3d58c..f5828c9f65db9 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -46,6 +46,9 @@ import { useStateToaster } from '../toasters'; import * as i18n from './translations'; import { Panel } from '../panel'; import { InspectButtonContainer } from '../inspect'; +import { RiskScoreColumns } from '../../../ueba/components/risk_score_table'; +import { HostRulesColumns } from '../../../ueba/components/host_rules_table'; +import { HostTacticsColumns } from '../../../ueba/components/host_tactics_table'; const DEFAULT_DATA_TEST_SUBJ = 'paginated-table'; @@ -74,6 +77,8 @@ declare type HostsTableColumnsTest = [ declare type BasicTableColumns = | AuthTableColumns + | HostRulesColumns + | HostTacticsColumns | HostsTableColumns | HostsTableColumnsTest | NetworkDnsColumns @@ -82,6 +87,8 @@ declare type BasicTableColumns = | NetworkTopCountriesColumnsNetworkDetails | NetworkTopNFlowColumns | NetworkTopNFlowColumnsNetworkDetails + | NetworkHttpColumns + | RiskScoreColumns | TlsColumns | UncommonProcessTableColumns | UsersColumns; @@ -97,7 +104,8 @@ export interface BasicTableProps { headerSupplement?: React.ReactElement; headerTitle: string | React.ReactElement; headerTooltip?: string; - headerUnit: string | React.ReactElement; + headerUnit?: string | React.ReactElement; + headerSubtitle?: string | React.ReactElement; id?: string; itemsPerRow?: ItemsPerRow[]; isInspect?: boolean; @@ -136,6 +144,7 @@ const PaginatedTableComponent: FC = ({ headerTitle, headerTooltip, headerUnit, + headerSubtitle, id, isInspect, itemsPerRow, @@ -248,8 +257,12 @@ const PaginatedTableComponent: FC = ({ = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` + !loadingInitial && headerSubtitle + ? `${i18n.SHOWING}: ${headerSubtitle}` + : headerUnit && + `${i18n.SHOWING}: ${ + headerCount >= 0 ? headerCount.toLocaleString() : 0 + } ${headerUnit}` } title={headerTitle} tooltip={headerTooltip} diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index 6107b61638888..edf09a52006fd 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -26,12 +26,13 @@ export enum CONSTANTS { } export type UrlStateType = - | 'case' + | 'administration' | 'alerts' - | 'rules' + | 'case' | 'exceptions' | 'host' | 'network' | 'overview' + | 'rules' | 'timeline' - | 'administration'; + | 'ueba'; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts index 63511c54d28db..e6f79d3d24ae0 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts @@ -19,7 +19,7 @@ import { UrlInputsModel } from '../../store/inputs/model'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { RouteSpyState } from '../../utils/route/types'; import { DispatchUpdateTimeline } from '../../../timelines/components/open_timeline/types'; -import { NavTab } from '../navigation/types'; +import { SecurityNav } from '../navigation/types'; import { CONSTANTS, UrlStateType } from './constants'; import { SourcererScopePatterns } from '../../store/sourcerer/model'; @@ -66,6 +66,14 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], + ueba: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.sourcerer, + CONSTANTS.timerange, + CONSTANTS.timeline, + ], administration: [], network: [ CONSTANTS.appQuery, @@ -124,7 +132,7 @@ export interface UrlState { export type KeyUrlState = keyof UrlState; export interface UrlStateProps { - navTabs: Record; + navTabs: SecurityNav; indexPattern?: IIndexPattern; mapToUrlState?: (value: string) => UrlState; onChange?: (urlState: UrlState, previousUrlState: UrlState) => void; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index 002c40fc9d428..d804f350a7f79 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -14,8 +14,8 @@ import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIndexFields } from '../source'; import { useUserInfo } from '../../../detections/components/user_info'; import { timelineSelectors } from '../../../timelines/store/timeline'; -import { ALERTS_PATH, RULES_PATH } from '../../../../common/constants'; -import { TimelineId } from '../../../../common/types/timeline'; +import { ALERTS_PATH, RULES_PATH, UEBA_PATH } from '../../../../common/constants'; +import { TimelineId } from '../../../../common'; import { useDeepEqualSelector } from '../../hooks/use_selector'; export const useInitSourcerer = ( @@ -57,8 +57,7 @@ export const useInitSourcerer = ( !loadingSignalIndex && signalIndexName != null && signalIndexNameSelector == null && - (activeTimeline == null || - (activeTimeline != null && activeTimeline.savedObjectId == null)) && + (activeTimeline == null || activeTimeline.savedObjectId == null) && initialTimelineSourcerer.current ) { initialTimelineSourcerer.current = false; @@ -70,8 +69,7 @@ export const useInitSourcerer = ( ); } else if ( signalIndexNameSelector != null && - (activeTimeline == null || - (activeTimeline != null && activeTimeline.savedObjectId == null)) && + (activeTimeline == null || activeTimeline.savedObjectId == null) && initialTimelineSourcerer.current ) { initialTimelineSourcerer.current = false; @@ -124,15 +122,14 @@ export const useInitSourcerer = ( export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName.default) => { const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []); - const SourcererScope = useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); - return SourcererScope; + return useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); }; export const getScopeFromPath = ( pathname: string ): SourcererScopeName.default | SourcererScopeName.detections => { return matchPath(pathname, { - path: [ALERTS_PATH, `${RULES_PATH}/id/:id`], + path: [ALERTS_PATH, `${RULES_PATH}/id/:id`, `${UEBA_PATH}/:id`], strict: false, }) == null ? SourcererScopeName.default diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts index 247b7624914cf..9a6b8c54f2bc6 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -14,8 +14,8 @@ import { const allowedExperimentalValues = getExperimentalAllowedValues(); -export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { - return useSelector(({ app: { enableExperimental } }: State) => { +export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => + useSelector(({ app: { enableExperimental } }: State) => { if (!enableExperimental || !(feature in enableExperimental)) { throw new Error( `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join( @@ -25,4 +25,3 @@ export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatu } return enableExperimental[feature]; }); -}; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index 44a100e27e95b..f8a77d97b8700 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -172,7 +172,7 @@ const createCoreStartMock = ( ): ReturnType => { const coreStart = coreMock.createStart({ basePath: '/mock' }); - const deepLinkPaths = getDeepLinkPaths(getDeepLinks()); + const deepLinkPaths = getDeepLinkPaths(getDeepLinks(mockGlobalState.app.enableExperimental)); // Mock the certain APP Ids returned by `application.getUrlForApp()` coreStart.application.getUrlForApp.mockImplementation((appId, { deepLinkId, path } = {}) => { diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index ffbfd1a5123ad..8130a7058700d 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -13,6 +13,9 @@ import { NetworkTopTablesFields, NetworkTlsFields, NetworkUsersFields, + RiskScoreFields, + HostRulesFields, + HostTacticsFields, } from '../../../common/search_strategy'; import { State } from '../store'; @@ -25,12 +28,14 @@ import { DEFAULT_INDEX_PATTERN, } from '../../../common/constants'; import { networkModel } from '../../network/store'; +import { uebaModel } from '../../ueba/store'; import { TimelineType, TimelineStatus, TimelineTabs } from '../../../common/types/timeline'; import { mockManagementState } from '../../management/store/reducer'; import { ManagementState } from '../../management/types'; import { initialSourcererState, SourcererScopeName } from '../store/sourcerer/model'; import { mockBrowserFields, mockDocValueFields } from '../containers/source/mock'; import { mockIndexPattern } from './index_pattern'; +import { allowedExperimentalValues } from '../../../common/experimental_features'; export const mockGlobalState: State = { app: { @@ -39,12 +44,7 @@ export const mockGlobalState: State = { { id: 'error-id-1', title: 'title-1', message: ['error-message-1'] }, { id: 'error-id-2', title: 'title-2', message: ['error-message-2'] }, ], - enableExperimental: { - trustedAppsByPolicyEnabled: false, - metricsEntitiesEnabled: false, - ruleRegistryEnabled: false, - tGridEnabled: false, - }, + enableExperimental: allowedExperimentalValues, }, hosts: { page: { @@ -164,6 +164,36 @@ export const mockGlobalState: State = { }, }, }, + ueba: { + page: { + queries: { + [uebaModel.UebaTableType.riskScore]: { + activePage: 0, + limit: 10, + sort: { field: RiskScoreFields.riskScore, direction: Direction.desc }, + }, + }, + }, + details: { + queries: { + [uebaModel.UebaTableType.hostRules]: { + activePage: 0, + limit: 10, + sort: { field: HostRulesFields.riskScore, direction: Direction.desc }, + }, + [uebaModel.UebaTableType.hostTactics]: { + activePage: 0, + limit: 10, + sort: { field: HostTacticsFields.riskScore, direction: Direction.desc }, + }, + [uebaModel.UebaTableType.userRules]: { + activePage: 0, + limit: 10, + sort: { field: HostRulesFields.riskScore, direction: Direction.desc }, + }, + }, + }, + }, inputs: { global: { timerange: { diff --git a/x-pack/plugins/security_solution/public/common/mock/utils.ts b/x-pack/plugins/security_solution/public/common/mock/utils.ts index e0f8e651a5821..0d9e2f4f367ec 100644 --- a/x-pack/plugins/security_solution/public/common/mock/utils.ts +++ b/x-pack/plugins/security_solution/public/common/mock/utils.ts @@ -12,6 +12,7 @@ import { tGridReducer } from '../../../../timelines/public'; import { hostsReducer } from '../../hosts/store'; import { networkReducer } from '../../network/store'; +import { uebaReducer } from '../../ueba/store'; import { timelineReducer } from '../../timelines/store/timeline/reducer'; import { managementReducer } from '../../management/store/reducer'; import { ManagementPluginReducer } from '../../management'; @@ -52,6 +53,7 @@ const combineTimelineReducer = reduceReducers( export const SUB_PLUGINS_REDUCER: SubPluginsInitReducer = { hosts: hostsReducer, network: networkReducer, + ueba: uebaReducer, timeline: combineTimelineReducer, /** * These state's are wrapped in `Immutable`, but for compatibility with the overall app architecture, diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index 2888867167c14..2c4ddb703f6a0 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -27,5 +27,5 @@ export type ErrorModel = Error[]; export interface AppModel { notesById: NotesById; errors: ErrorState; - enableExperimental?: ExperimentalFeatures; + enableExperimental: ExperimentalFeatures; } diff --git a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts index 20c9b0e14dbd9..5b0a2330a408d 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts @@ -17,6 +17,13 @@ export type AppState = AppModel; export const initialAppState: AppState = { notesById: {}, errors: [], + enableExperimental: { + trustedAppsByPolicyEnabled: false, + metricsEntitiesEnabled: false, + ruleRegistryEnabled: false, + tGridEnabled: false, + uebaEnabled: false, + }, }; interface UpdateNotesByIdParams { diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index c2ef2563fe63e..d5633ee84d6d4 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -14,6 +14,7 @@ import { sourcererReducer, sourcererModel } from './sourcerer'; import { HostsPluginReducer } from '../../hosts/store'; import { NetworkPluginReducer } from '../../network/store'; +import { UebaPluginReducer } from '../../ueba/store'; import { TimelinePluginReducer } from '../../timelines/store/timeline'; import { SecuritySubPlugins } from '../../app/types'; @@ -24,6 +25,7 @@ import { KibanaIndexPatterns } from './sourcerer/model'; import { ExperimentalFeatures } from '../../../common/experimental_features'; export type SubPluginsInitReducer = HostsPluginReducer & + UebaPluginReducer & NetworkPluginReducer & TimelinePluginReducer & ManagementPluginReducer; diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 21e833abe1f9b..6943b4cf73117 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -18,10 +18,12 @@ import { HostsPluginState } from '../../hosts/store'; import { DragAndDropState } from './drag_and_drop/reducer'; import { TimelinePluginState } from '../../timelines/store/timeline'; import { NetworkPluginState } from '../../network/store'; +import { UebaPluginState } from '../../ueba/store'; import { ManagementPluginState } from '../../management'; export type StoreState = HostsPluginState & NetworkPluginState & + UebaPluginState & TimelinePluginState & ManagementPluginState & { app: AppState; diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts index 189e68d1c55bb..c6d5852881850 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts +++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts @@ -15,8 +15,14 @@ import { HostsTableType } from '../../../hosts/store/model'; import { NetworkRouteType } from '../../../network/pages/navigation/types'; import { AdministrationSubTab as AdministrationType } from '../../../management/types'; import { FlowTarget } from '../../../../common/search_strategy'; +import { UebaTableType } from '../../../ueba/store/model'; -export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType | AdministrationType; +export type SiemRouteType = + | HostsTableType + | NetworkRouteType + | TimelineType + | AdministrationType + | UebaTableType; export interface RouteSpyState { pageName: string; detailName: string | undefined; @@ -32,6 +38,9 @@ export interface HostRouteSpyState extends RouteSpyState { tabName: HostsTableType | undefined; } +export interface UebaRouteSpyState extends RouteSpyState { + tabName: UebaTableType | undefined; +} export interface NetworkRouteSpyState extends RouteSpyState { tabName: NetworkRouteType | undefined; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 9f59e3763ffbc..b1881d29ec10d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -23,7 +23,10 @@ import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { TimelineId } from '../../../../../common/types/timeline'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; +import { + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../../common/constants'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { timelineActions } from '../../../../timelines/store/timeline'; import { EventsTdContent } from '../../../../timelines/components/timeline/styles'; @@ -49,6 +52,7 @@ import { AlertData, EcsHit } from '../../../../common/components/exceptions/type import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index'; import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; interface AlertContextMenuProps { ariaLabel?: string; @@ -84,6 +88,8 @@ const AlertContextMenuComponent: React.FC = ({ [ecsRowData] ); + // TODO: Steph/ueba remove when past experimental + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const ruleIndices = useMemo((): string[] => { if ( @@ -93,9 +99,11 @@ const AlertContextMenuComponent: React.FC = ({ ) { return ecsRowData.signal.rule.index; } else { - return DEFAULT_INDEX_PATTERN; + return uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN; } - }, [ecsRowData]); + }, [ecsRowData.signal?.rule, uebaEnabled]); const { addWarning } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 66f62ad3ebeab..8770e59e0c178 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -86,7 +86,11 @@ import { SecurityPageName } from '../../../../../app/types'; import { LinkButton } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; -import { APP_ID, DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants'; +import { + APP_ID, + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../../../common/constants'; import { useGlobalFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; @@ -227,6 +231,9 @@ const RuleDetailsPageComponent = () => { // TODO: Once we are past experimental phase this code should be removed const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); + // TODO: Steph/ueba remove when past experimental + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); + // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); const { @@ -348,7 +355,14 @@ const RuleDetailsPageComponent = () => { ), [ruleDetailTab, setRuleDetailTab] ); - + const ruleIndices = useMemo( + () => + rule?.index ?? + (uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN), + [rule?.index, uebaEnabled] + ); const handleRefresh = useCallback(() => { if (fetchRuleStatus != null && ruleId != null) { fetchRuleStatus(ruleId); @@ -732,7 +746,7 @@ const RuleDetailsPageComponent = () => { ( export const isDetectionsPath = (pathname: string): boolean => { return !!matchPath(pathname, { - path: `(${ALERTS_PATH}|${RULES_PATH}|${EXCEPTIONS_PATH})`, + path: `(${ALERTS_PATH}|${RULES_PATH}|${UEBA_PATH}|${EXCEPTIONS_PATH})`, strict: false, }); }; diff --git a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx index 47026cbec49ad..430c77b9422d8 100644 --- a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx +++ b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx @@ -16,6 +16,7 @@ import { Exceptions } from './exceptions'; import { Hosts } from './hosts'; import { Network } from './network'; +import { Ueba } from './ueba'; import { Overview } from './overview'; import { Rules } from './rules'; @@ -31,6 +32,7 @@ const subPluginClasses = { Exceptions, Hosts, Network, + Ueba, Overview, Rules, Timelines, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 137fef1641501..ee5ca84c6e13f 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -58,16 +58,21 @@ import { SecuritySolutionUiConfigType } from './common/types'; import { getLazyEndpointPolicyEditExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_edit_extension'; import { LazyEndpointPolicyCreateExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_create_extension'; import { getLazyEndpointPackageCustomExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_package_custom_extension'; -import { parseExperimentalConfigValue } from '../common/experimental_features'; +import { + ExperimentalFeatures, + parseExperimentalConfigValue, +} from '../common/experimental_features'; import type { TimelineState } from '../../timelines/public'; import { LazyEndpointCustomAssetsExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_custom_assets_extension'; export class Plugin implements IPlugin { - private kibanaVersion: string; + readonly kibanaVersion: string; private config: SecuritySolutionUiConfigType; + readonly experimentalFeatures: ExperimentalFeatures; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); this.kibanaVersion = initializerContext.env.packageInfo.version; } private appUpdater$ = new Subject(); @@ -151,7 +156,7 @@ export class Plugin implements IPlugin { const [coreStart, startPlugins] = await core.getStartServices(); const subPlugins = await this.startSubPlugins(this.storage, coreStart, startPlugins); @@ -231,7 +236,11 @@ export class Plugin implements IPlugin ({ navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks(currentLicense.type, core.application.capabilities), + deepLinks: getDeepLinks( + this.experimentalFeatures, + currentLicense.type, + core.application.capabilities + ), })); } }); @@ -239,6 +248,7 @@ export class Plugin implements IPlugin { if (!this._store) { - const experimentalFeatures = parseExperimentalConfigValue( - this.config.enableExperimental || [] - ); const defaultIndicesName = coreStart.uiSettings.get(DEFAULT_INDEX_KEY); const [ { createStore, createInitialState }, @@ -359,7 +370,7 @@ export class Plugin implements IPlugin; hosts: ReturnType; network: ReturnType; + // TODO: Steph/ueba require ueba once no longer experimental + ueba?: ReturnType; overview: ReturnType; timelines: ReturnType; management: ReturnType; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx new file mode 100644 index 0000000000000..4289b7d2c62da --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx @@ -0,0 +1,145 @@ +/* + * 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 { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { HostRulesColumns } from './'; + +import * as i18n from './translations'; +import { HostRulesFields } from '../../../../common'; + +export const getHostRulesColumns = (): HostRulesColumns => [ + { + field: `node.${HostRulesFields.ruleName}`, + name: i18n.NAME, + truncateText: false, + hideForMobile: false, + render: (ruleName) => { + if (ruleName != null && ruleName.length > 0) { + const id = escapeDataProviderId(`ueba-table-ruleName-${ruleName}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + ruleName + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostRulesFields.ruleType}`, + name: i18n.RULE_TYPE, + truncateText: false, + hideForMobile: false, + render: (ruleType) => { + if (ruleType != null && ruleType.length > 0) { + const id = escapeDataProviderId(`ueba-table-ruleType-${ruleType}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + ruleType + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostRulesFields.riskScore}`, + name: i18n.RISK_SCORE, + truncateText: false, + hideForMobile: false, + render: (riskScore) => { + if (riskScore != null) { + const id = escapeDataProviderId(`ueba-table-riskScore-${riskScore}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + riskScore + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostRulesFields.hits}`, + name: i18n.HITS, + truncateText: false, + hideForMobile: false, + sortable: false, + render: (hits) => { + if (hits != null) { + return hits; + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx new file mode 100644 index 0000000000000..3d369a56a7bc0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Columns, + Criteria, + PaginatedTable, + SortingBasicTable, +} from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaActions, uebaModel, uebaSelectors } from '../../store'; +import { getHostRulesColumns } from './columns'; +import * as i18n from './translations'; +import { + HostRulesEdges, + HostRulesItem, + HostRulesSortField, + HostRulesFields, +} from '../../../../common'; +import { Direction } from '../../../../common/search_strategy'; +import { HOST_RULES } from '../../pages/translations'; +import { rowItems } from '../utils'; + +interface HostRulesTableProps { + data: HostRulesEdges[]; + fakeTotalCount: number; + headerTitle?: string; + headerSupplement?: React.ReactElement; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + showMorePagesIndicator: boolean; + totalCount: number; + type: uebaModel.UebaType; + tableType: uebaModel.UebaTableType.hostRules | uebaModel.UebaTableType.userRules; +} + +export type HostRulesColumns = [ + Columns, + Columns, + Columns, + Columns +]; + +const getSorting = (sortField: HostRulesFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const HostRulesTableComponent: React.FC = ({ + data, + fakeTotalCount, + headerTitle, + headerSupplement, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + tableType, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const { activePage, limit, sort } = useDeepEqualSelector(uebaSelectors.hostRulesSelector()); + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + uebaActions.updateTableLimit({ + uebaType: type, + limit: newLimit, + tableType, + }) + ), + [tableType, type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + uebaActions.updateTableActivePage({ + activePage: newPage, + uebaType: type, + tableType, // this will need to become unique for each user table in the group + }) + ), + [tableType, type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort: HostRulesSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + // dispatch( + // uebaActions.updateHostRulesSort({ + // sort, + // uebaType: type, + // }) + // ); TODO: Steph/ueba implement sorting + } + } + }, + [sort] + ); + + const columns = useMemo(() => getHostRulesColumns(), []); + + const sorting = useMemo(() => getSorting(sort.field, sort.direction), [sort]); + const headerProps = useMemo( + () => + tableType === uebaModel.UebaTableType.userRules && headerTitle && headerSupplement + ? { + headerTitle, + headerSupplement, + } + : { headerTitle: HOST_RULES }, + [headerSupplement, headerTitle, tableType] + ); + return ( + + ); +}; + +HostRulesTableComponent.displayName = 'HostRulesTableComponent'; + +const getSortField = (field: string): HostRulesFields => { + switch (field) { + case `node.${HostRulesFields.ruleName}`: + return HostRulesFields.ruleName; + case `node.${HostRulesFields.riskScore}`: + return HostRulesFields.riskScore; + default: + return HostRulesFields.riskScore; + } +}; + +const getNodeField = (field: HostRulesFields): string => `node.${field}`; + +export const HostRulesTable = React.memo(HostRulesTableComponent); + +HostRulesTable.displayName = 'HostRulesTable'; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts new file mode 100644 index 0000000000000..f029910b9714b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.securitySolution.uebaTableHostRules.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {rule} other {rules}}`, + }); + +export const NAME = i18n.translate('xpack.securitySolution.uebaTableHostRules.ruleName', { + defaultMessage: 'Rule name', +}); + +export const RISK_SCORE = i18n.translate( + 'xpack.securitySolution.uebaTableHostRules.totalRiskScore', + { + defaultMessage: 'Total risk score', + } +); + +export const RULE_TYPE = i18n.translate('xpack.securitySolution.uebaTableHostRules.ruleType', { + defaultMessage: 'Rule type', +}); + +export const HITS = i18n.translate('xpack.securitySolution.uebaTableHostRules.hits', { + defaultMessage: 'Number of hits', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx new file mode 100644 index 0000000000000..19516ad6fcafa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx @@ -0,0 +1,153 @@ +/* + * 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 { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { HostTacticsColumns } from './'; + +import * as i18n from './translations'; +import { HostTacticsFields } from '../../../../common'; + +export const getHostTacticsColumns = (): HostTacticsColumns => [ + { + field: `node.${HostTacticsFields.tactic}`, + name: i18n.TACTIC, + truncateText: false, + hideForMobile: false, + render: (tactic) => { + if (tactic != null && tactic.length > 0) { + const id = escapeDataProviderId(`ueba-table-tactic-${tactic}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + tactic + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostTacticsFields.technique}`, + name: i18n.TECHNIQUE, + truncateText: false, + hideForMobile: false, + render: (technique) => { + if (technique != null && technique.length > 0) { + const id = escapeDataProviderId(`ueba-table-technique-${technique}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + technique + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostTacticsFields.riskScore}`, + name: i18n.RISK_SCORE, + truncateText: false, + hideForMobile: false, + render: (riskScore) => { + if (riskScore != null) { + const id = escapeDataProviderId(`ueba-table-riskScore-${riskScore}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + riskScore + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostTacticsFields.hits}`, + name: i18n.HITS, + truncateText: false, + hideForMobile: false, + sortable: false, + render: (hits) => { + if (hits != null) { + return hits; + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx new file mode 100644 index 0000000000000..28bd3d6ad43a0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx @@ -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 React, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Columns, + Criteria, + PaginatedTable, + SortingBasicTable, +} from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaActions, uebaModel, uebaSelectors } from '../../store'; +import { getHostTacticsColumns } from './columns'; +import * as i18n from './translations'; +import { + HostTacticsEdges, + HostTacticsItem, + HostTacticsSortField, + HostTacticsFields, +} from '../../../../common'; +import { Direction } from '../../../../common/search_strategy'; +import { HOST_TACTICS } from '../../pages/translations'; +import { rowItems } from '../utils'; + +const tableType = uebaModel.UebaTableType.hostTactics; + +interface HostTacticsTableProps { + data: HostTacticsEdges[]; + fakeTotalCount: number; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + showMorePagesIndicator: boolean; + techniqueCount: number; + totalCount: number; + type: uebaModel.UebaType; +} + +export type HostTacticsColumns = [ + Columns, + Columns, + Columns, + Columns +]; + +const getSorting = (sortField: HostTacticsFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const HostTacticsTableComponent: React.FC = ({ + data, + fakeTotalCount, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + techniqueCount, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const { activePage, limit, sort } = useDeepEqualSelector(uebaSelectors.hostTacticsSelector()); + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + uebaActions.updateTableLimit({ + uebaType: type, + limit: newLimit, + tableType, + }) + ), + [type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + uebaActions.updateTableActivePage({ + activePage: newPage, + uebaType: type, + tableType, // this will need to become unique for each user table in the group + }) + ), + [type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort: HostTacticsSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + // dispatch( + // uebaActions.updateHostTacticsSort({ + // sort, + // uebaType: type, + // }) + // ); TODO: Steph/ueba implement sorting + } + } + }, + [sort] + ); + + const columns = useMemo(() => getHostTacticsColumns(), []); + + const sorting = useMemo(() => getSorting(sort.field, sort.direction), [sort]); + return ( + + ); +}; + +HostTacticsTableComponent.displayName = 'HostTacticsTableComponent'; + +const getSortField = (field: string): HostTacticsFields => { + switch (field) { + case `node.${HostTacticsFields.tactic}`: + return HostTacticsFields.tactic; + case `node.${HostTacticsFields.riskScore}`: + return HostTacticsFields.riskScore; + default: + return HostTacticsFields.riskScore; + } +}; + +const getNodeField = (field: HostTacticsFields): string => `node.${field}`; + +export const HostTacticsTable = React.memo(HostTacticsTableComponent); + +HostTacticsTable.displayName = 'HostTacticsTable'; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts new file mode 100644 index 0000000000000..98cd53a59e5f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const COUNT = (totalCount: number, techniqueCount: number) => + i18n.translate('xpack.securitySolution.uebaTableHostTactics.tacticTechnique', { + values: { techniqueCount, totalCount }, + defaultMessage: `{totalCount} {totalCount, plural, =1 {tactic} other {tactics}} with {techniqueCount} {techniqueCount, plural, =1 {technique} other {techniques}}`, + }); + +export const TACTIC = i18n.translate('xpack.securitySolution.uebaTableHostTactics.tactic', { + defaultMessage: 'Tactic', +}); + +export const RISK_SCORE = i18n.translate( + 'xpack.securitySolution.uebaTableHostTactics.totalRiskScore', + { + defaultMessage: 'Total risk score', + } +); + +export const TECHNIQUE = i18n.translate('xpack.securitySolution.uebaTableHostTactics.technique', { + defaultMessage: 'Technique', +}); + +export const HITS = i18n.translate('xpack.securitySolution.uebaTableHostTactics.hits', { + defaultMessage: 'Number of hits', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx new file mode 100644 index 0000000000000..b751521001fe5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { UebaDetailsLink } from '../../../common/components/links'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { + AddFilterToGlobalSearchBar, + createFilter, +} from '../../../common/components/add_filter_to_global_search_bar'; +import { RiskScoreColumns } from './'; + +import * as i18n from './translations'; +export const getRiskScoreColumns = (): RiskScoreColumns => [ + { + field: 'node.host_name', + name: i18n.NAME, + truncateText: false, + hideForMobile: false, + sortable: true, + render: (hostName) => { + if (hostName != null && hostName.length > 0) { + const id = escapeDataProviderId(`ueba-table-hostName-${hostName}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'node.risk_keyword', + name: i18n.CURRENT_RISK, + truncateText: false, + hideForMobile: false, + sortable: false, + render: (riskKeyword) => { + if (riskKeyword != null) { + return ( + + <>{riskKeyword} + + ); + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx new file mode 100644 index 0000000000000..9e9c6f81a43bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Columns, + Criteria, + PaginatedTable, + SortingBasicTable, +} from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaActions, uebaModel, uebaSelectors } from '../../store'; +import { getRiskScoreColumns } from './columns'; +import * as i18n from './translations'; +import { + RiskScoreEdges, + RiskScoreItem, + RiskScoreSortField, + RiskScoreFields, +} from '../../../../common'; +import { Direction } from '../../../../common/search_strategy'; +import { rowItems } from '../utils'; + +const tableType = uebaModel.UebaTableType.riskScore; + +interface RiskScoreTableProps { + data: RiskScoreEdges[]; + fakeTotalCount: number; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + showMorePagesIndicator: boolean; + totalCount: number; + type: uebaModel.UebaType; +} + +export type RiskScoreColumns = [ + Columns, + Columns +]; + +const getSorting = (sortField: RiskScoreFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const RiskScoreTableComponent: React.FC = ({ + data, + fakeTotalCount, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const { activePage, limit, sort } = useDeepEqualSelector(uebaSelectors.riskScoreSelector()); + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + uebaActions.updateTableLimit({ + uebaType: type, + limit: newLimit, + tableType, + }) + ), + [type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + uebaActions.updateTableActivePage({ + activePage: newPage, + uebaType: type, + tableType, + }) + ), + [type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort: RiskScoreSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + // dispatch( + // uebaActions.updateRiskScoreSort({ + // sort, + // uebaType: type, + // }) + // ); TODO: Steph/ueba implement sorting + } + } + }, + [sort] + ); + + const columns = useMemo(() => getRiskScoreColumns(), []); + + const sorting = useMemo(() => getSorting(sort.field, sort.direction), [sort]); + + return ( + + ); +}; + +RiskScoreTableComponent.displayName = 'RiskScoreTableComponent'; + +const getSortField = (field: string): RiskScoreFields => { + switch (field) { + case `node.${RiskScoreFields.hostName}`: + return RiskScoreFields.hostName; + case `node.${RiskScoreFields.riskScore}`: + return RiskScoreFields.riskScore; + default: + return RiskScoreFields.riskScore; + } +}; + +const getNodeField = (field: RiskScoreFields): string => `node.${field}`; + +export const RiskScoreTable = React.memo(RiskScoreTableComponent); + +RiskScoreTable.displayName = 'RiskScoreTable'; diff --git a/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts new file mode 100644 index 0000000000000..a4e7a3271d152 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.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 { i18n } from '@kbn/i18n'; + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.securitySolution.uebaTableRiskScore.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {user} other {users}}`, + }); + +export const NAME = i18n.translate('xpack.securitySolution.uebaTableRiskScore.nameTitle', { + defaultMessage: 'Host name', +}); + +export const RISK_SCORE = i18n.translate('xpack.securitySolution.uebaTableRiskScore.riskScore', { + defaultMessage: 'Risk score', +}); + +export const CURRENT_RISK = i18n.translate( + 'xpack.securitySolution.uebaTableRiskScore.currentRisk', + { + defaultMessage: 'Current risk', + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/components/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/translations.ts new file mode 100644 index 0000000000000..5775871a3fe4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/translations.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ROWS_5 = i18n.translate('xpack.securitySolution.uebaTable.rows', { + values: { numRows: 5 }, + defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', +}); + +export const ROWS_10 = i18n.translate('xpack.securitySolution.uebaTable.rows', { + values: { numRows: 10 }, + defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/components/utils.ts b/x-pack/plugins/security_solution/public/ueba/components/utils.ts new file mode 100644 index 0000000000000..d12e66a5f6d7b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/utils.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ItemsPerRow } from '../../common/components/paginated_table'; +import * as i18n from './translations'; + +export const rowItems: ItemsPerRow[] = [ + { + text: i18n.ROWS_5, + numberOfRow: 5, + }, + { + text: i18n.ROWS_10, + numberOfRow: 10, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx new file mode 100644 index 0000000000000..7db1a77244bbe --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx @@ -0,0 +1,220 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + HostRulesEdges, + PageInfoPaginated, + DocValueFields, + UebaQueries, + HostRulesRequestOptions, + HostRulesStrategyResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'hostRulesQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface HostRulesState { + data: HostRulesEdges[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + totalCount: number; +} + +interface UseHostRules { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + hostName: string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useHostRules = ({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip = false, + startDate, +}: UseHostRules): [boolean, HostRulesState] => { + const getHostRulesSelector = useMemo(() => uebaSelectors.hostRulesSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getHostRulesSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [hostRulesRequest, setHostRulesRequest] = useState(null); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setHostRulesRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [hostRulesResponse, setHostRulesResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + startDate, + totalCount: -1, + }); + + const hostRulesSearch = useCallback( + (request: HostRulesRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setHostRulesResponse((prevResponse) => ({ + ...prevResponse, + data: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_HOST_RULES); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_HOST_RULES }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setHostRulesRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.hostRules, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + hostName, + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + hostName, + ]); + + useEffect(() => { + hostRulesSearch(hostRulesRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [hostRulesRequest, hostRulesSearch]); + + return [loading, hostRulesResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts new file mode 100644 index 0000000000000..6cf5521f4eaaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx new file mode 100644 index 0000000000000..35dd2a0b08d4e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx @@ -0,0 +1,225 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + HostTacticsEdges, + PageInfoPaginated, + DocValueFields, + UebaQueries, + HostTacticsRequestOptions, + HostTacticsStrategyResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'hostTacticsQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface HostTacticsState { + data: HostTacticsEdges[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + techniqueCount: number; + totalCount: number; +} + +interface UseHostTactics { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + hostName: string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useHostTactics = ({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip = false, + startDate, +}: UseHostTactics): [boolean, HostTacticsState] => { + const getHostTacticsSelector = useMemo(() => uebaSelectors.hostTacticsSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getHostTacticsSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [hostTacticsRequest, setHostTacticsRequest] = useState( + null + ); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setHostTacticsRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [hostTacticsResponse, setHostTacticsResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + startDate, + techniqueCount: -1, + totalCount: -1, + }); + + const hostTacticsSearch = useCallback( + (request: HostTacticsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setHostTacticsResponse((prevResponse) => ({ + ...prevResponse, + data: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + techniqueCount: response.techniqueCount, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_HOST_RULES); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_HOST_RULES }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setHostTacticsRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.hostTactics, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + hostName, + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + hostName, + ]); + + useEffect(() => { + hostTacticsSearch(hostTacticsRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [hostTacticsRequest, hostTacticsSearch]); + + return [loading, hostTacticsResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts new file mode 100644 index 0000000000000..6cf5521f4eaaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx new file mode 100644 index 0000000000000..f2f353ffc0cff --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx @@ -0,0 +1,216 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + RiskScoreEdges, + PageInfoPaginated, + DocValueFields, + UebaQueries, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'riskScoreQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface RiskScoreState { + data: RiskScoreEdges[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + totalCount: number; +} + +interface UseRiskScore { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useRiskScore = ({ + docValueFields, + endDate, + filterQuery, + indexNames, + skip = false, + startDate, +}: UseRiskScore): [boolean, RiskScoreState] => { + const getRiskScoreSelector = useMemo(() => uebaSelectors.riskScoreSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getRiskScoreSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [riskScoreRequest, setRiskScoreRequest] = useState(null); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setRiskScoreRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [riskScoreResponse, setRiskScoreResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + startDate, + totalCount: -1, + }); + + const riskScoreSearch = useCallback( + (request: RiskScoreRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setRiskScoreResponse((prevResponse) => ({ + ...prevResponse, + data: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_RISK_SCORE); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_RISK_SCORE }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setRiskScoreRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.riskScore, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + ]); + + useEffect(() => { + riskScoreSearch(riskScoreRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [riskScoreRequest, riskScoreSearch]); + + return [loading, riskScoreResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts new file mode 100644 index 0000000000000..8cc275674d4e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.riskScore.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.riskScore.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx new file mode 100644 index 0000000000000..3c4e45bd3a1e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx @@ -0,0 +1,209 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + DocValueFields, + UebaQueries, + UserRulesRequestOptions, + UserRulesStrategyResponse, + UserRulesStrategyUserResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'userRulesQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface UserRulesState { + data: UserRulesStrategyUserResponse[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + refetch: inputsModel.Refetch; + startDate: string; +} + +interface UseUserRules { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + hostName: string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useUserRules = ({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip = false, + startDate, +}: UseUserRules): [boolean, UserRulesState] => { + const getUserRulesSelector = useMemo(() => uebaSelectors.userRulesSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getUserRulesSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [userRulesRequest, setUserRulesRequest] = useState(null); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setUserRulesRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [userRulesResponse, setUserRulesResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + refetch: refetch.current, + startDate, + }); + + const userRulesSearch = useCallback( + (request: UserRulesRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setUserRulesResponse((prevResponse) => ({ + ...prevResponse, + data: response.data, + inspect: getInspectResponse(response, prevResponse.inspect), + refetch: refetch.current, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_HOST_RULES); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_HOST_RULES }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setUserRulesRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.userRules, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + hostName, + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + hostName, + ]); + + useEffect(() => { + userRulesSearch(userRulesRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [userRulesRequest, userRulesSearch]); + + return [loading, userRulesResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts new file mode 100644 index 0000000000000..6cf5521f4eaaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/index.ts b/x-pack/plugins/security_solution/public/ueba/index.ts new file mode 100644 index 0000000000000..030844735b0f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { SecuritySubPluginWithStore } from '../app/types'; +import { routes } from './routes'; +import { initialUebaState, uebaReducer, uebaModel } from './store'; +import { TimelineId } from '../../common/types/timeline'; +import { getTimelinesInStorageByIds } from '../timelines/containers/local_storage'; + +export class Ueba { + public setup() {} + + public start(storage: Storage): SecuritySubPluginWithStore<'ueba', uebaModel.UebaModel> { + return { + routes, + storageTimelines: { + timelineById: getTimelinesInStorageByIds(storage, [TimelineId.uebaPageExternalAlerts]), + }, + store: { + initialState: { ueba: initialUebaState }, + reducer: { ueba: uebaReducer }, + }, + }; + } +} diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx new file mode 100644 index 0000000000000..dad3277d0a7a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx @@ -0,0 +1,95 @@ +/* + * 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, { useCallback } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { UpdateDateRange } from '../../../common/components/charts/common'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { Anomaly } from '../../../common/components/ml/types'; +import { UebaTableType } from '../../store/model'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; + +import { UebaDetailsTabsProps } from './types'; +import { type } from './utils'; + +import { + HostRulesQueryTabBody, + HostTacticsQueryTabBody, + UserRulesQueryTabBody, +} from '../navigation'; + +export const UebaDetailsTabs = React.memo( + ({ + detailName, + docValueFields, + filterQuery, + indexNames, + indexPattern, + pageFilters, + setAbsoluteRangeDatePicker, + uebaDetailsPagePath, + }) => { + const { from, to, isInitializing, deleteQuery, setQuery } = useGlobalTime(); + const narrowDateRange = useCallback( + (score: Anomaly, interval: string) => { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const updateDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const tabProps = { + deleteQuery, + endDate: to, + filterQuery, + skip: isInitializing || filterQuery === undefined, + setQuery, + startDate: from, + type, + indexPattern, + indexNames, + hostName: detailName, + narrowDateRange, + updateDateRange, + }; + return ( + + + + + + + + + + + + ); + } +); + +UebaDetailsTabs.displayName = 'UebaDetailsTabs'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts b/x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts new file mode 100644 index 0000000000000..70f8027b1f55b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts @@ -0,0 +1,50 @@ +/* + * 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 { escapeQueryValue } from '../../../common/lib/keury'; +import { Filter } from '../../../../../../../src/plugins/data/public'; + +/** Returns the kqlQueryExpression for the `Events` widget on the `Host Details` page */ +export const getUebaDetailsEventsKqlQueryExpression = ({ + filterQueryExpression, + hostName, +}: { + filterQueryExpression: string; + hostName: string; +}): string => { + if (filterQueryExpression.length) { + return `${filterQueryExpression}${ + hostName.length ? ` and host.name: ${escapeQueryValue(hostName)}` : '' + }`; + } else { + return hostName.length ? `host.name: ${escapeQueryValue(hostName)}` : ''; + } +}; + +export const getUebaDetailsPageFilters = (hostName: string): Filter[] => [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.name', + value: hostName, + params: { + query: hostName, + }, + }, + query: { + match: { + 'host.name': { + query: hostName, + type: 'phrase', + }, + }, + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx new file mode 100644 index 0000000000000..5a297099f3834 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx @@ -0,0 +1,150 @@ +/* + * 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 { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +import { LastEventIndexKey } from '../../../../common/search_strategy'; +import { SecurityPageName } from '../../../app/types'; +import { FiltersGlobal } from '../../../common/components/filters_global'; +import { HeaderPage } from '../../../common/components/header_page'; +import { LastEventTime } from '../../../common/components/last_event_time'; +import { SecuritySolutionTabNavigation } from '../../../common/components/navigation'; +import { SiemSearchBar } from '../../../common/components/search_bar'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useKibana } from '../../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../../common/lib/keury'; +import { inputsSelectors } from '../../../common/store'; +import { setUebaDetailsTablesActivePageToZero } from '../../store/actions'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { esQuery, Filter } from '../../../../../../../src/plugins/data/public'; + +import { OverviewEmpty } from '../../../overview/components/overview_empty'; +import { UebaDetailsTabs } from './details_tabs'; +import { navTabsUebaDetails } from './nav_tabs'; +import { UebaDetailsProps } from './types'; +import { type } from './utils'; +import { getUebaDetailsPageFilters } from './helpers'; +import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; +import { Display } from '../display'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../common/types/timeline'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +const ID = 'UebaDetailsQueryId'; + +const UebaDetailsComponent: React.FC = ({ detailName, uebaDetailsPagePath }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useGlobalFullScreen(); + + const kibana = useKibana(); + const uebaDetailsPageFilters: Filter[] = useMemo(() => getUebaDetailsPageFilters(detailName), [ + detailName, + ]); + const getFilters = () => [...uebaDetailsPageFilters, ...filters]; + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope( + SourcererScopeName.detections + ); + + const [filterQuery, kqlError] = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters: getFilters(), + }); + + useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to }); + + useEffect(() => { + dispatch(setUebaDetailsTablesActivePageToZero()); + }, [dispatch, detailName]); + + return ( + <> + {indicesExist ? ( + <> + + + + + + + + + } + title={detailName} + /> + + + + + + + + + ) : ( + + + + + + )} + + + + ); +}; + +UebaDetailsComponent.displayName = 'UebaDetailsComponent'; + +export const UebaDetails = React.memo(UebaDetailsComponent); diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx new file mode 100644 index 0000000000000..ba97a03bf6daf --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx @@ -0,0 +1,37 @@ +/* + * 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 * as i18n from '../translations'; +import { UebaDetailsNavTab } from './types'; +import { UebaTableType } from '../../store/model'; +import { UEBA_PATH } from '../../../../common/constants'; + +const getTabsOnUebaDetailsUrl = (hostName: string, tabName: UebaTableType) => + `${UEBA_PATH}/${hostName}/${tabName}`; + +export const navTabsUebaDetails = (hostName: string): UebaDetailsNavTab => { + return { + [UebaTableType.hostRules]: { + id: UebaTableType.hostRules, + name: i18n.HOST_RULES, + href: getTabsOnUebaDetailsUrl(hostName, UebaTableType.hostRules), + disabled: false, + }, + [UebaTableType.hostTactics]: { + id: UebaTableType.hostTactics, + name: i18n.HOST_TACTICS, + href: getTabsOnUebaDetailsUrl(hostName, UebaTableType.hostTactics), + disabled: false, + }, + [UebaTableType.userRules]: { + id: UebaTableType.userRules, + name: i18n.USER_RULES, + href: getTabsOnUebaDetailsUrl(hostName, UebaTableType.userRules), + disabled: false, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/types.ts b/x-pack/plugins/security_solution/public/ueba/pages/details/types.ts new file mode 100644 index 0000000000000..976b033db5f5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/types.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 { ActionCreator } from 'typescript-fsa'; +import { Query, IIndexPattern, Filter } from 'src/plugins/data/public'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { UebaTableType } from '../../store/model'; +import { UebaQueryProps } from '../types'; +import { NavTab } from '../../../common/components/navigation/types'; +import { uebaModel } from '../../store'; +import { DocValueFields } from '../../../common/containers/source'; + +interface UebaDetailsComponentReduxProps { + query: Query; + filters: Filter[]; +} + +interface HostBodyComponentDispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: string; + to: string; + }>; + detailName: string; + uebaDetailsPagePath: string; +} + +interface UebaDetailsComponentDispatchProps extends HostBodyComponentDispatchProps { + setUebaDetailsTablesActivePageToZero: ActionCreator; +} + +export interface UebaDetailsProps { + detailName: string; + uebaDetailsPagePath: string; +} + +export type UebaDetailsComponentProps = UebaDetailsComponentReduxProps & + UebaDetailsComponentDispatchProps & + UebaQueryProps; + +type KeyUebaDetailsNavTab = UebaTableType.hostRules & + UebaTableType.hostTactics & + UebaTableType.userRules; + +export type UebaDetailsNavTab = Record; + +export type UebaDetailsTabsProps = HostBodyComponentDispatchProps & + UebaQueryProps & { + docValueFields?: DocValueFields[]; + indexNames: string[]; + pageFilters?: Filter[]; + filterQuery?: string; + indexPattern: IIndexPattern; + type: uebaModel.UebaType; + }; + +export type SetAbsoluteRangeDatePicker = ActionCreator<{ + id: InputsModelId; + from: string; + to: string; +}>; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts b/x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts new file mode 100644 index 0000000000000..d5f346d3ece64 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts @@ -0,0 +1,71 @@ +/* + * 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 { get, isEmpty } from 'lodash/fp'; + +import { ChromeBreadcrumb } from '../../../../../../../src/core/public'; +import { uebaModel } from '../../store'; +import { UebaTableType } from '../../store/model'; +import { getUebaDetailsUrl } from '../../../common/components/link_to/redirect_to_ueba'; + +import * as i18n from '../translations'; +import { UebaRouteSpyState } from '../../../common/utils/route/types'; +import { GetUrlForApp } from '../../../common/components/navigation/types'; +import { APP_ID } from '../../../../common/constants'; +import { SecurityPageName } from '../../../app/types'; + +export const type = uebaModel.UebaType.details; + +const TabNameMappedToI18nKey: Record = { + [UebaTableType.hostRules]: i18n.HOST_RULES, + [UebaTableType.hostTactics]: i18n.HOST_TACTICS, + [UebaTableType.riskScore]: i18n.RISK_SCORE_TITLE, + [UebaTableType.userRules]: i18n.USER_RULES, +}; + +export const getBreadcrumbs = ( + params: UebaRouteSpyState, + search: string[], + getUrlForApp: GetUrlForApp +): ChromeBreadcrumb[] => { + let breadcrumb = [ + { + text: i18n.PAGE_TITLE, + href: getUrlForApp(APP_ID, { + path: !isEmpty(search[0]) ? search[0] : '', + deepLinkId: SecurityPageName.ueba, + }), + }, + ]; + + if (params.detailName != null) { + breadcrumb = [ + ...breadcrumb, + { + text: params.detailName, + href: getUrlForApp(APP_ID, { + path: getUebaDetailsUrl(params.detailName, !isEmpty(search[0]) ? search[0] : ''), + deepLinkId: SecurityPageName.ueba, + }), + }, + ]; + } + + if (params.tabName != null) { + const tabName = get('tabName', params); + if (!tabName) return breadcrumb; + + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[tabName], + href: '', + }, + ]; + } + return breadcrumb; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/display.tsx b/x-pack/plugins/security_solution/public/ueba/pages/display.tsx new file mode 100644 index 0000000000000..a907f1fdb5997 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/display.tsx @@ -0,0 +1,14 @@ +/* + * 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 styled from 'styled-components'; + +export const Display = styled.div<{ show: boolean }>` + ${({ show }) => (show ? '' : 'display: none;')}; +`; + +Display.displayName = 'Display'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/index.tsx b/x-pack/plugins/security_solution/public/ueba/pages/index.tsx new file mode 100644 index 0000000000000..c4a6794b75999 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/index.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Route, Switch, Redirect } from 'react-router-dom'; +import { UEBA_PATH } from '../../../common/constants'; +import { UebaTableType } from '../store/model'; +import { Ueba } from './ueba'; +import { uebaDetailsPagePath } from './types'; +import { UebaDetails } from './details'; + +const uebaTabPath = `${UEBA_PATH}/:tabName(${UebaTableType.riskScore})`; + +const uebaDetailsTabPath = + `${uebaDetailsPagePath}/:tabName(` + + `${UebaTableType.hostRules}|` + + `${UebaTableType.hostTactics}|` + + `${UebaTableType.userRules})`; + +export const UebaContainer = React.memo(() => ( + + ( + + )} + /> + + + + + } + /> + ( + + )} + /> + +)); + +UebaContainer.displayName = 'UebaContainer'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx new file mode 100644 index 0000000000000..5e06e5c9bf068 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as i18n from './translations'; +import { UebaTableType } from '../store/model'; +import { UebaNavTab } from './navigation/types'; +import { UEBA_PATH } from '../../../common/constants'; + +const getTabsOnUebaUrl = (tabName: UebaTableType) => `${UEBA_PATH}/${tabName}`; + +export const navTabsUeba: UebaNavTab = { + [UebaTableType.riskScore]: { + id: UebaTableType.riskScore, + name: i18n.RISK_SCORE_TITLE, + href: getTabsOnUebaUrl(UebaTableType.riskScore), + disabled: false, + }, +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx new file mode 100644 index 0000000000000..bce19a9da7ab9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.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 { getOr } from 'lodash/fp'; +import React from 'react'; +import { useHostRules } from '../../containers/host_rules'; +import { HostQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { HostRulesTable } from '../../components/host_rules_table'; +import { uebaModel } from '../../store'; + +const HostRulesTableManage = manageQuery(HostRulesTable); + +export const HostRulesQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + setQuery, + startDate, + type, +}: HostQueryProps) => { + const [ + loading, + { data, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useHostRules({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + startDate, + type, + }); + + return ( + + ); +}; + +HostRulesQueryTabBody.displayName = 'HostRulesQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx new file mode 100644 index 0000000000000..c441eff3219d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import React from 'react'; +import { useHostTactics } from '../../containers/host_tactics'; +import { HostQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { HostTacticsTable } from '../../components/host_tactics_table'; + +const HostTacticsTableManage = manageQuery(HostTacticsTable); + +export const HostTacticsQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + setQuery, + startDate, + type, +}: HostQueryProps) => { + const [ + loading, + { data, techniqueCount, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useHostTactics({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + startDate, + type, + }); + + return ( + + ); +}; + +HostTacticsQueryTabBody.displayName = 'HostTacticsQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts b/x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts new file mode 100644 index 0000000000000..dd549659a3eab --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts @@ -0,0 +1,11 @@ +/* + * 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 './host_rules_query_tab_body'; +export * from './host_tactics_query_tab_body'; +export * from './risk_score_query_tab_body'; +export * from './user_rules_query_tab_body'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx new file mode 100644 index 0000000000000..cde972d8a66ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import React from 'react'; +import { useRiskScore } from '../../containers/risk_score'; +import { RiskScoreQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { RiskScoreTable } from '../../components/risk_score_table'; + +const RiskScoreTableManage = manageQuery(RiskScoreTable); + +export const RiskScoreQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + indexNames, + skip, + setQuery, + startDate, + type, +}: RiskScoreQueryProps) => { + const [ + loading, + { data, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useRiskScore({ docValueFields, endDate, filterQuery, indexNames, skip, startDate, type }); + + return ( + + ); +}; + +RiskScoreQueryTabBody.displayName = 'RiskScoreQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts new file mode 100644 index 0000000000000..e24b3271cf534 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UebaTableType, UebaType } from '../../store/model'; +import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; +import { ESTermQuery } from '../../../../common/typed_json'; +import { DocValueFields } from '../../../../../timelines/common'; +import { Filter } from '../../../../../../../src/plugins/data/common'; +import { UpdateDateRange } from '../../../common/components/charts/common'; +import { NarrowDateRange } from '../../../common/components/ml/types'; +import { NavTab } from '../../../common/components/navigation/types'; + +type KeyUebaNavTab = UebaTableType.riskScore; + +export type UebaNavTab = Record; +export interface QueryTabBodyProps { + type: UebaType; + startDate: GlobalTimeArgs['from']; + endDate: GlobalTimeArgs['to']; + filterQuery?: string | ESTermQuery; +} + +export type RiskScoreQueryProps = QueryTabBodyProps & { + deleteQuery?: GlobalTimeArgs['deleteQuery']; + docValueFields?: DocValueFields[]; + indexNames: string[]; + pageFilters?: Filter[]; + skip: boolean; + setQuery: GlobalTimeArgs['setQuery']; + updateDateRange?: UpdateDateRange; + narrowDateRange?: NarrowDateRange; +}; +export type HostQueryProps = RiskScoreQueryProps & { + hostName: string; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx new file mode 100644 index 0000000000000..f7542b7b4b8a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useUserRules } from '../../containers/user_rules'; +import { HostQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { HostRulesTable } from '../../components/host_rules_table'; +import { uebaModel } from '../../store'; +import { UserRulesFields } from '../../../../common'; + +const UserRulesTableManage = manageQuery(HostRulesTable); + +export const UserRulesQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + setQuery, + startDate, + type, +}: HostQueryProps) => { + const [loading, { data, loadPage, id, inspect, isInspected, refetch }] = useUserRules({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + startDate, + type, + }); + return ( + + {data.map((user, i) => ( + + {`Total user risk score: ${user[UserRulesFields.riskScore]}`}

} + headerTitle={`user.name: ${user[UserRulesFields.userName]}`} + fakeTotalCount={getOr(50, 'fakeTotalCount', user.pageInfo)} + id={`${id}${i}`} + inspect={inspect} + isInspect={isInspected} + loading={loading} + loadPage={loadPage} + refetch={refetch} + setQuery={setQuery} + showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', user.pageInfo)} + tableType={uebaModel.UebaTableType.userRules} // pagination will not work until this is unique + totalCount={user.totalCount} + type={type} + /> +
+ ))} +
+ ); +}; + +UserRulesQueryTabBody.displayName = 'UserRulesQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/translations.ts b/x-pack/plugins/security_solution/public/ueba/pages/translations.ts new file mode 100644 index 0000000000000..0e6519d9d45ce --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/translations.ts @@ -0,0 +1,27 @@ +/* + * 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'; + +export const PAGE_TITLE = i18n.translate('xpack.securitySolution.ueba.pageTitle', { + defaultMessage: 'Users & Entities', +}); +export const RISK_SCORE_TITLE = i18n.translate('xpack.securitySolution.ueba.riskScore', { + defaultMessage: 'Risk score', +}); + +export const HOST_RULES = i18n.translate('xpack.securitySolution.ueba.hostRules', { + defaultMessage: 'Host risk score by rule', +}); + +export const HOST_TACTICS = i18n.translate('xpack.securitySolution.ueba.hostTactics', { + defaultMessage: 'Host risk score by tactic', +}); + +export const USER_RULES = i18n.translate('xpack.securitySolution.ueba.userRules', { + defaultMessage: 'User risk score by rule', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/pages/types.ts b/x-pack/plugins/security_solution/public/ueba/pages/types.ts new file mode 100644 index 0000000000000..07c4d5fccd066 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/types.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 { ActionCreator } from 'typescript-fsa'; + +import { GlobalTimeArgs } from '../../common/containers/use_global_time'; +import { UEBA_PATH } from '../../../common/constants'; +import { uebaModel } from '../../ueba/store'; +import { DocValueFields } from '../../../../timelines/common'; +import { InputsModelId } from '../../common/store/inputs/constants'; + +export const uebaDetailsPagePath = `${UEBA_PATH}/:detailName`; + +export type UebaTabsProps = GlobalTimeArgs & { + docValueFields: DocValueFields[]; + filterQuery: string; + indexNames: string[]; + type: uebaModel.UebaType; + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: string; + to: string; + }>; +}; + +export type UebaQueryProps = GlobalTimeArgs; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx b/x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx new file mode 100644 index 0000000000000..4e0041a98454c --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx @@ -0,0 +1,184 @@ +/* + * 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 { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import styled from 'styled-components'; +import { noop } from 'lodash/fp'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { isTab } from '../../../../timelines/public'; + +import { SecurityPageName } from '../../app/types'; +import { FiltersGlobal } from '../../common/components/filters_global'; +import { HeaderPage } from '../../common/components/header_page'; +import { LastEventTime } from '../../common/components/last_event_time'; +import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; + +import { SiemSearchBar } from '../../common/components/search_bar'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; +import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; +import { useGlobalTime } from '../../common/containers/use_global_time'; +import { TimelineId } from '../../../common'; +import { LastEventIndexKey } from '../../../common/search_strategy'; +import { useKibana } from '../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../common/lib/keury'; +import { inputsSelectors } from '../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; + +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { esQuery } from '../../../../../../src/plugins/data/public'; +import { OverviewEmpty } from '../../overview/components/overview_empty'; +import { Display } from './display'; +import { UebaTabs } from './ueba_tabs'; +import { navTabsUeba } from './nav_tabs'; +import * as i18n from './translations'; +import { uebaModel } from '../store'; +import { + onTimelineTabKeyPressed, + resetKeyboardFocus, + showGlobalFilters, +} from '../../timelines/components/timeline/helpers'; +import { timelineSelectors } from '../../timelines/store/timeline'; +import { timelineDefaults } from '../../timelines/store/timeline/defaults'; +import { useSourcererScope } from '../../common/containers/sourcerer'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hooks/use_selector'; +import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query'; + +const ID = 'UebaQueryId'; + +/** + * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. + */ +const StyledFullHeightContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1 1 auto; +`; + +const UebaComponent = () => { + const containerElement = useRef(null); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => + (getTimeline(state, TimelineId.uebaPageExternalAlerts) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useGlobalFullScreen(); + const { uiSettings } = useKibana().services; + const tabsFilters = filters; + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + const [filterQuery, kqlError] = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }), + [filters, indexPattern, uiSettings, query] + ); + const [tabsFilterQuery] = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: tabsFilters, + }), + [indexPattern, query, tabsFilters, uiSettings] + ); + + useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to }); + + const onSkipFocusBeforeEventsTable = useCallback(() => { + containerElement.current + ?.querySelector('.inspectButtonComponent:last-of-type') + ?.focus(); + }, [containerElement]); + + const onSkipFocusAfterEventsTable = useCallback(() => { + resetKeyboardFocus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onTimelineTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, + }); + } + }, + [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] + ); + + return ( + <> + {indicesExist ? ( + + + + + + + + + + } + title={i18n.PAGE_TITLE} + /> + + + + + + + + + + ) : ( + + + + + + )} + + + + ); +}; +UebaComponent.displayName = 'UebaComponent'; + +export const Ueba = React.memo(UebaComponent); diff --git a/x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx new file mode 100644 index 0000000000000..b6ae4419b609a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx @@ -0,0 +1,82 @@ +/* + * 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, useCallback } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { UebaTabsProps } from './types'; +import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_interval_to_datetime'; +import { Anomaly } from '../../common/components/ml/types'; +import { UebaTableType } from '../store/model'; +import { UpdateDateRange } from '../../common/components/charts/common'; +import { UEBA_PATH } from '../../../common/constants'; +import { RiskScoreQueryTabBody } from './navigation'; + +export const UebaTabs = memo( + ({ + deleteQuery, + docValueFields, + filterQuery, + from, + indexNames, + isInitializing, + setAbsoluteRangeDatePicker, + setQuery, + to, + type, + }) => { + const narrowDateRange = useCallback( + (score: Anomaly, interval: string) => { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const updateDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const tabProps = { + deleteQuery, + endDate: to, + filterQuery, + indexNames, + skip: isInitializing || filterQuery === undefined, + setQuery, + startDate: from, + type, + narrowDateRange, + updateDateRange, + }; + + return ( + + + + + + ); + } +); + +UebaTabs.displayName = 'UebaTabs'; diff --git a/x-pack/plugins/security_solution/public/ueba/routes.tsx b/x-pack/plugins/security_solution/public/ueba/routes.tsx new file mode 100644 index 0000000000000..4d761856155e3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/routes.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 { UebaContainer } from './pages'; + +import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; +import { SecurityPageName, SecuritySubPluginRoutes } from '../app/types'; +import { UEBA_PATH } from '../../common/constants'; + +export const UebaRoutes = () => ( + + + +); + +export const routes: SecuritySubPluginRoutes = [ + { + path: UEBA_PATH, + render: UebaRoutes, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/store/actions.ts b/x-pack/plugins/security_solution/public/ueba/store/actions.ts new file mode 100644 index 0000000000000..72ec2ff425d20 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/actions.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 actionCreatorFactory from 'typescript-fsa'; +import { uebaModel } from '.'; + +const actionCreator = actionCreatorFactory('x-pack/security_solution/local/ueba'); + +export const updateUebaTable = actionCreator<{ + uebaType: uebaModel.UebaType; + tableType: uebaModel.UebaTableType | uebaModel.UebaTableType; + updates: uebaModel.TableUpdates; +}>('UPDATE_NETWORK_TABLE'); + +export const setUebaDetailsTablesActivePageToZero = actionCreator( + 'SET_UEBA_DETAILS_TABLES_ACTIVE_PAGE_TO_ZERO' +); + +export const setUebaTablesActivePageToZero = actionCreator('SET_UEBA_TABLES_ACTIVE_PAGE_TO_ZERO'); + +export const updateTableLimit = actionCreator<{ + uebaType: uebaModel.UebaType; + limit: number; + tableType: uebaModel.UebaTableType; +}>('UPDATE_UEBA_TABLE_LIMIT'); + +export const updateTableActivePage = actionCreator<{ + uebaType: uebaModel.UebaType; + activePage: number; + tableType: uebaModel.UebaTableType; +}>('UPDATE_UEBA_ACTIVE_PAGE'); diff --git a/x-pack/plugins/security_solution/public/ueba/store/helpers.ts b/x-pack/plugins/security_solution/public/ueba/store/helpers.ts new file mode 100644 index 0000000000000..653cf30fac484 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/helpers.ts @@ -0,0 +1,45 @@ +/* + * 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 { UebaModel, UebaType, UebaTableType, UebaQueries, UebaDetailsQueries } from './model'; +import { DEFAULT_TABLE_ACTIVE_PAGE } from '../../common/store/constants'; + +export const setUebaPageQueriesActivePageToZero = (state: UebaModel): UebaQueries => ({ + ...state.page.queries, + [UebaTableType.riskScore]: { + ...state.page.queries[UebaTableType.riskScore], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, +}); + +export const setUebaDetailsQueriesActivePageToZero = (state: UebaModel): UebaDetailsQueries => ({ + ...state.details.queries, + [UebaTableType.hostRules]: { + ...state.details.queries[UebaTableType.hostRules], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, + [UebaTableType.hostTactics]: { + ...state.details.queries[UebaTableType.hostTactics], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, + [UebaTableType.userRules]: { + ...state.details.queries[UebaTableType.userRules], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, +}); + +export const setUebaQueriesActivePageToZero = ( + state: UebaModel, + type: UebaType +): UebaQueries | UebaDetailsQueries => { + if (type === UebaType.page) { + return setUebaPageQueriesActivePageToZero(state); + } else if (type === UebaType.details) { + return setUebaDetailsQueriesActivePageToZero(state); + } + throw new Error(`UebaType ${type} is unknown`); +}; diff --git a/x-pack/plugins/security_solution/public/ueba/store/index.ts b/x-pack/plugins/security_solution/public/ueba/store/index.ts new file mode 100644 index 0000000000000..8538509e58d4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Reducer, AnyAction } from 'redux'; +import * as uebaActions from './actions'; +import * as uebaModel from './model'; +import * as uebaSelectors from './selectors'; + +export { uebaActions, uebaModel, uebaSelectors }; +export * from './reducer'; + +export interface UebaPluginState { + ueba: uebaModel.UebaModel; +} + +export interface UebaPluginReducer { + ueba: Reducer; +} diff --git a/x-pack/plugins/security_solution/public/ueba/store/model.ts b/x-pack/plugins/security_solution/public/ueba/store/model.ts new file mode 100644 index 0000000000000..9e9f39977c8ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/model.ts @@ -0,0 +1,78 @@ +/* + * 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 { + HostRulesSortField, + HostTacticsSortField, + RiskScoreFields, + RiskScoreSortField, + SortField, + UserRulesSortField, +} from '../../../common/search_strategy'; + +export enum UebaType { + page = 'page', + details = 'details', +} + +export enum UebaTableType { + riskScore = 'riskScore', + hostRules = 'hostRules', + hostTactics = 'hostTactics', + userRules = 'userRules', +} + +export type AllUebaTables = UebaTableType; + +export interface BasicQueryPaginated { + activePage: number; + limit: number; +} + +// Ueba Page Models +export interface RiskScoreQuery extends BasicQueryPaginated { + sort: RiskScoreSortField; +} +export interface HostRulesQuery extends BasicQueryPaginated { + sort: HostRulesSortField; +} +export interface UserRulesQuery extends BasicQueryPaginated { + sort: UserRulesSortField; +} +export interface HostTacticsQuery extends BasicQueryPaginated { + sort: HostTacticsSortField; +} + +export interface TableUpdates { + activePage?: number; + limit?: number; + isPtrIncluded?: boolean; + sort?: SortField; +} + +export interface UebaQueries { + [UebaTableType.riskScore]: RiskScoreQuery; +} + +export interface UebaPageModel { + queries: UebaQueries; +} + +export interface UebaDetailsQueries { + [UebaTableType.hostRules]: HostRulesQuery; + [UebaTableType.hostTactics]: HostTacticsQuery; + [UebaTableType.userRules]: UserRulesQuery; +} + +export interface UebaDetailsModel { + queries: UebaDetailsQueries; +} + +export interface UebaModel { + [UebaType.page]: UebaPageModel; + [UebaType.details]: UebaDetailsModel; +} diff --git a/x-pack/plugins/security_solution/public/ueba/store/reducer.ts b/x-pack/plugins/security_solution/public/ueba/store/reducer.ts new file mode 100644 index 0000000000000..f981868c21eb1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/reducer.ts @@ -0,0 +1,136 @@ +/* + * 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 { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { get } from 'lodash/fp'; +import { + Direction, + HostRulesFields, + HostTacticsFields, + RiskScoreFields, +} from '../../../common/search_strategy'; +import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../common/store/constants'; + +import { + setUebaDetailsTablesActivePageToZero, + setUebaTablesActivePageToZero, + updateUebaTable, + updateTableActivePage, + updateTableLimit, +} from './actions'; +import { + setUebaDetailsQueriesActivePageToZero, + setUebaPageQueriesActivePageToZero, +} from './helpers'; +import { UebaTableType, UebaModel } from './model'; + +export const initialUebaState: UebaModel = { + page: { + queries: { + [UebaTableType.riskScore]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: RiskScoreFields.riskScore, + direction: Direction.desc, + }, + }, + }, + }, + details: { + queries: { + [UebaTableType.hostRules]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: HostRulesFields.riskScore, + direction: Direction.desc, + }, + }, + [UebaTableType.hostTactics]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: HostTacticsFields.riskScore, + direction: Direction.desc, + }, + }, + [UebaTableType.userRules]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: HostRulesFields.riskScore, // this looks wrong but its right, the user "table" is an array of host tables + direction: Direction.desc, + }, + }, + }, + }, +}; + +export const uebaReducer = reducerWithInitialState(initialUebaState) + .case(updateUebaTable, (state, { uebaType, tableType, updates }) => ({ + ...state, + [uebaType]: { + ...state[uebaType], + queries: { + ...state[uebaType].queries, + [tableType]: { + ...get([uebaType, 'queries', tableType], state), + ...updates, + }, + }, + }, + })) + .case(setUebaTablesActivePageToZero, (state) => ({ + ...state, + page: { + ...state.page, + queries: setUebaPageQueriesActivePageToZero(state), + }, + details: { + ...state.details, + queries: setUebaDetailsQueriesActivePageToZero(state), + }, + })) + .case(setUebaDetailsTablesActivePageToZero, (state) => ({ + ...state, + details: { + ...state.details, + queries: setUebaDetailsQueriesActivePageToZero(state), + }, + })) + .case(updateTableActivePage, (state, { activePage, uebaType, tableType }) => ({ + ...state, + [uebaType]: { + ...state[uebaType], + queries: { + ...state[uebaType].queries, + [tableType]: { + // TODO: Steph/ueba fix active page/limit on ueba tables. is broken because multiple UebaTableType.userRules tables + // @ts-ignore + ...state[uebaType].queries[tableType], + activePage, + }, + }, + }, + })) + .case(updateTableLimit, (state, { limit, uebaType, tableType }) => ({ + ...state, + [uebaType]: { + ...state[uebaType], + queries: { + ...state[uebaType].queries, + [tableType]: { + // TODO: Steph/ueba fix active page/limit on ueba tables. is broken because multiple UebaTableType.userRules tables + // @ts-ignore + ...state[uebaType].queries[tableType], + limit, + }, + }, + }, + })) + .build(); diff --git a/x-pack/plugins/security_solution/public/ueba/store/selectors.ts b/x-pack/plugins/security_solution/public/ueba/store/selectors.ts new file mode 100644 index 0000000000000..a3d7a5f8a8867 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/selectors.ts @@ -0,0 +1,27 @@ +/* + * 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 { createSelector } from 'reselect'; + +import { State } from '../../common/store/types'; + +import { UebaDetailsModel, UebaPageModel, UebaTableType } from './model'; + +const selectUebaPage = (state: State): UebaPageModel => state.ueba.page; +const selectUebaDetailsPage = (state: State): UebaDetailsModel => state.ueba.details; + +export const riskScoreSelector = () => + createSelector(selectUebaPage, (ueba) => ueba.queries[UebaTableType.riskScore]); + +export const hostRulesSelector = () => + createSelector(selectUebaDetailsPage, (ueba) => ueba.queries[UebaTableType.hostRules]); + +export const hostTacticsSelector = () => + createSelector(selectUebaDetailsPage, (ueba) => ueba.queries[UebaTableType.hostTactics]); + +export const userRulesSelector = () => + createSelector(selectUebaDetailsPage, (ueba) => ueba.queries[UebaTableType.userRules]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts index a1d7d03f313db..e98e9b49b3646 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts @@ -16,6 +16,7 @@ import { getIndexVersion } from '../../routes/index/get_index_version'; import { SIGNALS_TEMPLATE_VERSION } from '../../routes/index/get_signals_template'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { allowedExperimentalValues } from '../../../../../common/experimental_features'; jest.mock('../../routes/index/get_index_version'); @@ -73,6 +74,7 @@ describe('eql_executor', () => { rule: eqlSO, tuple, exceptionItems, + experimentalFeatures: allowedExperimentalValues, services: alertServices, version, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index e08f519e9761a..8d19510c63477 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -34,11 +34,13 @@ import { SimpleHit, } from '../types'; import { createSearchAfterReturnType, makeFloatString } from '../utils'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const eqlExecutor = async ({ rule, tuple, exceptionItems, + experimentalFeatures, services, version, logger, @@ -50,6 +52,7 @@ export const eqlExecutor = async ({ rule: SavedObject>; tuple: RuleRangeTuple; exceptionItems: ExceptionListItemSchema[]; + experimentalFeatures: ExperimentalFeatures; services: AlertServices; version: string; logger: Logger; @@ -85,7 +88,12 @@ export const eqlExecutor = async ({ throw err; } } - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); const request = buildEqlSearchRequest( ruleParams.query, inputIndex, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index 385c01c2f1cda..454cb464506a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -21,12 +21,14 @@ import { AlertAttributes, RuleRangeTuple, BulkCreate, WrapHits } from '../types' import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { QueryRuleParams, SavedQueryRuleParams } from '../../schemas/rule_schemas'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const queryExecutor = async ({ rule, tuple, listClient, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, @@ -40,6 +42,7 @@ export const queryExecutor = async ({ tuple: RuleRangeTuple; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; + experimentalFeatures: ExperimentalFeatures; services: AlertServices; version: string; searchAfterSize: number; @@ -50,7 +53,12 @@ export const queryExecutor = async ({ wrapHits: WrapHits; }) => { const ruleParams = rule.attributes.params; - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); const esFilter = await getFilter({ type: ruleParams.type, filters: ruleParams.filters, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts index d0e22f696b222..37b2c53636cfd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts @@ -20,6 +20,7 @@ import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { createThreatSignals } from '../threat_mapping/create_threat_signals'; import { ThreatRuleParams } from '../../schemas/rule_schemas'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const threatMatchExecutor = async ({ rule, @@ -31,6 +32,7 @@ export const threatMatchExecutor = async ({ searchAfterSize, logger, eventsTelemetry, + experimentalFeatures, buildRuleMessage, bulkCreate, wrapHits, @@ -44,12 +46,18 @@ export const threatMatchExecutor = async ({ searchAfterSize: number; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; + experimentalFeatures: ExperimentalFeatures; buildRuleMessage: BuildRuleMessage; bulkCreate: BulkCreate; wrapHits: WrapHits; }) => { const ruleParams = rule.attributes.params; - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); return createThreatSignals({ tuple, threatMapping: ruleParams.threatMapping, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts index 3906c66922238..afcb3707591fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts @@ -16,6 +16,7 @@ import { getEntryListMock } from '../../../../../../lists/common/schemas/types/e import { getThresholdRuleParams } from '../../schemas/rule_schemas.mock'; import { buildRuleMessageFactory } from '../rule_messages'; import { sampleEmptyDocSearchResults } from '../__mocks__/es_results'; +import { allowedExperimentalValues } from '../../../../../common/experimental_features'; describe('threshold_executor', () => { const version = '8.0.0'; @@ -70,6 +71,7 @@ describe('threshold_executor', () => { rule: thresholdSO, tuple, exceptionItems, + experimentalFeatures: allowedExperimentalValues, services: alertServices, version, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts index 378d68fc13d2a..ffd90f3b90b91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts @@ -36,11 +36,13 @@ import { mergeReturns, } from '../utils'; import { BuildRuleMessage } from '../rule_messages'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const thresholdExecutor = async ({ rule, tuple, exceptionItems, + experimentalFeatures, services, version, logger, @@ -52,6 +54,7 @@ export const thresholdExecutor = async ({ rule: SavedObject>; tuple: RuleRangeTuple; exceptionItems: ExceptionListItemSchema[]; + experimentalFeatures: ExperimentalFeatures; services: AlertServices; version: string; logger: Logger; @@ -68,7 +71,12 @@ export const thresholdExecutor = async ({ ); result.warning = true; } - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); const { thresholdSignalHistory, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts index 9c4bf37aca789..5058056b169a3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts @@ -7,7 +7,7 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; -import { getInputIndex } from './get_input_output_index'; +import { getInputIndex, GetInputIndex } from './get_input_output_index'; describe('get_input_output_index', () => { let servicesMock: AlertServicesMock; @@ -19,7 +19,7 @@ describe('get_input_output_index', () => { afterAll(() => { jest.resetAllMocks(); }); - + let defaultProps: GetInputIndex; beforeEach(() => { servicesMock = alertsMock.createAlertServices(); servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ @@ -28,6 +28,18 @@ describe('get_input_output_index', () => { references: [], attributes: {}, })); + defaultProps = { + services: servicesMock, + version: '8.0.0', + index: ['test-input-index-1'], + experimentalFeatures: { + trustedAppsByPolicyEnabled: false, + metricsEntitiesEnabled: false, + ruleRegistryEnabled: false, + tGridEnabled: false, + uebaEnabled: false, + }, + }; }); describe('getInputOutputIndex', () => { @@ -38,7 +50,7 @@ describe('get_input_output_index', () => { references: [], attributes: {}, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', ['test-input-index-1']); + const inputIndex = await getInputIndex(defaultProps); expect(inputIndex).toEqual(['test-input-index-1']); }); @@ -51,7 +63,10 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: ['configured-index-1', 'configured-index-2'], }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', undefined); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: undefined, + }); expect(inputIndex).toEqual(['configured-index-1', 'configured-index-2']); }); @@ -64,7 +79,10 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: ['configured-index-1', 'configured-index-2'], }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', null); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); expect(inputIndex).toEqual(['configured-index-1', 'configured-index-2']); }); @@ -77,7 +95,26 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: null, }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', null); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); + expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); + }); + + test('Returns a saved object inputIndex default along with experimental features when uebaEnabled=true', async () => { + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], + attributes: { + [DEFAULT_INDEX_KEY]: null, + }, + })); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); @@ -90,17 +127,26 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: null, }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', undefined); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: undefined, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); test('Returns a saved object inputIndex default from constants if both passed in inputIndex and configuration attributes are missing and the index is undefined', async () => { - const inputIndex = await getInputIndex(servicesMock, '8.0.0', undefined); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: undefined, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); test('Returns a saved object inputIndex default from constants if both passed in inputIndex and configuration attributes are missing and the index is null', async () => { - const inputIndex = await getInputIndex(servicesMock, '8.0.0', null); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts index f0c62bee7aec9..d3b60f1e9a281 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts @@ -5,20 +5,33 @@ * 2.0. */ -import { DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; +import { + DEFAULT_INDEX_KEY, + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../common/constants'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; +import { ExperimentalFeatures } from '../../../../common/experimental_features'; + +export interface GetInputIndex { + experimentalFeatures: ExperimentalFeatures; + index: string[] | null | undefined; + services: AlertServices; + version: string; +} -export const getInputIndex = async ( - services: AlertServices, - version: string, - inputIndex: string[] | null | undefined -): Promise => { - if (inputIndex != null) { - return inputIndex; +export const getInputIndex = async ({ + experimentalFeatures, + index, + services, + version, +}: GetInputIndex): Promise => { + if (index != null) { + return index; } else { const configuration = await services.savedObjectsClient.get<{ 'securitySolution:defaultIndex': string[]; @@ -26,7 +39,9 @@ export const getInputIndex = async ( if (configuration.attributes != null && configuration.attributes[DEFAULT_INDEX_KEY] != null) { return configuration.attributes[DEFAULT_INDEX_KEY]; } else { - return DEFAULT_INDEX_PATTERN; + return experimentalFeatures.uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN; } } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index aec8b6c552b1d..a14c678d27536 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -33,6 +33,7 @@ import { queryExecutor } from './executors/query'; import { mlExecutor } from './executors/ml'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -188,6 +189,7 @@ describe('signal_rule_alert_type', () => { payload = getPayload(ruleAlert, alertServices) as jest.Mocked; alert = signalRulesAlertType({ + experimentalFeatures: allowedExperimentalValues, logger, eventsTelemetry: undefined, version, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 6eef97b05b697..d524757b7c144 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -69,10 +69,12 @@ import { bulkCreateFactory } from './bulk_create_factory'; import { wrapHitsFactory } from './wrap_hits_factory'; import { wrapSequencesFactory } from './wrap_sequences_factory'; import { ConfigType } from '../../../config'; +import { ExperimentalFeatures } from '../../../../common/experimental_features'; export const signalRulesAlertType = ({ logger, eventsTelemetry, + experimentalFeatures, version, ml, lists, @@ -80,6 +82,7 @@ export const signalRulesAlertType = ({ }: { logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; + experimentalFeatures: ExperimentalFeatures; version: string; ml: SetupPlugins['ml']; lists: SetupPlugins['lists'] | undefined; @@ -153,7 +156,12 @@ export const signalRulesAlertType = ({ if (!isMachineLearningParams(params)) { const index = params.index; const hasTimestampOverride = timestampOverride != null && !isEmpty(timestampOverride); - const inputIndices = await getInputIndex(services, version, index); + const inputIndices = await getInputIndex({ + services, + version, + index, + experimentalFeatures, + }); const [privileges, timestampFieldCaps] = await Promise.all([ checkPrivileges(services, inputIndices), services.scopedClusterClient.asCurrentUser.fieldCaps({ @@ -268,6 +276,7 @@ export const signalRulesAlertType = ({ rule: thresholdRuleSO, tuple, exceptionItems, + experimentalFeatures, services, version, logger, @@ -285,6 +294,7 @@ export const signalRulesAlertType = ({ tuple, listClient, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, @@ -303,6 +313,7 @@ export const signalRulesAlertType = ({ tuple, listClient, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, @@ -320,6 +331,7 @@ export const signalRulesAlertType = ({ rule: eqlRuleSO, tuple, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9d2e918d4f274..4a346581b7767 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -290,6 +290,7 @@ export class Plugin implements IPlugin > = { ...hostsFactory, + ...uebaFactory, ...matrixHistogramFactory, ...networkFactory, ...ctiFactoryTypes, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts new file mode 100644 index 0000000000000..f9c94eea3ff29 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts @@ -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 { getOr } from 'lodash/fp'; +import { HostRulesHit, HostRulesEdges, HostRulesFields } from '../../../../../../common'; + +export const formatHostRulesData = (buckets: HostRulesHit[]): HostRulesEdges[] => + buckets.map((bucket) => ({ + node: { + _id: bucket.key, + [HostRulesFields.hits]: bucket.doc_count, + [HostRulesFields.riskScore]: getOr(0, 'risk_score.value', bucket), + [HostRulesFields.ruleName]: bucket.key, + [HostRulesFields.ruleType]: getOr(0, 'rule_type.buckets[0].key', bucket), + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts new file mode 100644 index 0000000000000..39fa7193fd5d2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import { SecuritySolutionFactory } from '../../types'; +import { + HostRulesEdges, + HostRulesRequestOptions, + HostRulesStrategyResponse, + UebaQueries, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildHostRulesQuery } from './query.host_rules.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatHostRulesData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const hostRules: SecuritySolutionFactory = { + buildDsl: (options: HostRulesRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildHostRulesQuery(options); + }, + parse: async ( + options: HostRulesRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.rule_count.value', response.rawResponse); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + + const hostRulesEdges: HostRulesEdges[] = formatHostRulesData( + getOr([], 'aggregations.rule_name.buckets', response.rawResponse) + ); + + const edges = hostRulesEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildHostRulesQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + ...response, + inspect, + edges, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts new file mode 100644 index 0000000000000..4c116104b3e14 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; +import { Direction, HostRulesRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildHostRulesQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + hostName, + timerange: { from, to }, +}: HostRulesRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, // can stop getting this from sourcerer and assume default detections index if we want + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_name: { + terms: { + field: 'signal.rule.name', + order: { + risk_score: Direction.desc, + }, + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_type: { + terms: { + field: 'signal.rule.type', + }, + }, + }, + }, + rule_count: { + cardinality: { + field: 'signal.rule.name', + }, + }, + }, + query: { + bool: { + filter, + must: [ + { + term: { + 'host.name': hostName, + }, + }, + ], + }, + }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts new file mode 100644 index 0000000000000..b20cf4582c824 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts @@ -0,0 +1,47 @@ +/* + * 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 { getOr } from 'lodash/fp'; +import { + HostTacticsHit, + HostTacticsEdges, + HostTacticsFields, + HostTechniqueHit, +} from '../../../../../../common'; + +export const formatHostTacticsData = (buckets: HostTacticsHit[]): HostTacticsEdges[] => + buckets.reduce((acc: HostTacticsEdges[], bucket) => { + return [ + ...acc, + ...getOr([], 'technique.buckets', bucket).map((t: HostTechniqueHit) => ({ + node: { + _id: bucket.key + t.key, + [HostTacticsFields.hits]: t.doc_count, + [HostTacticsFields.riskScore]: getOr(0, 'risk_score.value', t), + [HostTacticsFields.tactic]: bucket.key, + [HostTacticsFields.technique]: t.key, + }, + cursor: { + value: bucket.key + t.key, + tiebreaker: null, + }, + })), + ]; + }, []); +// buckets.map((bucket) => ({ +// node: { +// _id: bucket.key, +// [HostTacticsFields.hits]: bucket.doc_count, +// [HostTacticsFields.riskScore]: getOr(0, 'risk_score.value', bucket), +// [HostTacticsFields.tactic]: bucket.key, +// [HostTacticsFields.technique]: getOr(0, 'technique.buckets[0].key', bucket), +// }, +// cursor: { +// value: bucket.key, +// tiebreaker: null, +// }, +// })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts new file mode 100644 index 0000000000000..0ba8cbef1d144 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import { SecuritySolutionFactory } from '../../types'; +import { + HostTacticsEdges, + HostTacticsRequestOptions, + HostTacticsStrategyResponse, + UebaQueries, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildHostTacticsQuery } from './query.host_tactics.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatHostTacticsData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const hostTactics: SecuritySolutionFactory = { + buildDsl: (options: HostTacticsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildHostTacticsQuery(options); + }, + parse: async ( + options: HostTacticsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.tactic_count.value', response.rawResponse); + const techniqueCount = getOr(0, 'aggregations.technique_count.value', response.rawResponse); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const hostTacticsEdges: HostTacticsEdges[] = formatHostTacticsData( + getOr([], 'aggregations.tactic.buckets', response.rawResponse) + ); + const edges = hostTacticsEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildHostTacticsQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + ...response, + inspect, + edges, + techniqueCount, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts new file mode 100644 index 0000000000000..ec1afe247011b --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts @@ -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 { isEmpty } from 'lodash/fp'; +import { HostTacticsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildHostTacticsQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + hostName, + timerange: { from, to }, +}: HostTacticsRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, // can stop getting this from sourcerer and assume default detections index if we want + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + tactic: { + terms: { + field: 'signal.rule.threat.tactic.name', + }, + aggs: { + technique: { + terms: { + field: 'signal.rule.threat.technique.name', + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + }, + }, + }, + }, + tactic_count: { + cardinality: { + field: 'signal.rule.threat.tactic.name', + }, + }, + technique_count: { + cardinality: { + field: 'signal.rule.threat.technique.name', + }, + }, + }, + query: { + bool: { + filter, + must: [ + { + term: { + 'host.name': hostName, + }, + }, + ], + }, + }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts new file mode 100644 index 0000000000000..90db2ec63260a --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + FactoryQueryTypes, + UebaQueries, +} from '../../../../../common/search_strategy/security_solution'; +import { SecuritySolutionFactory } from '../types'; +import { hostRules } from './host_rules'; +import { hostTactics } from './host_tactics'; +import { riskScore } from './risk_score'; +import { userRules } from './user_rules'; + +export const uebaFactory: Record> = { + [UebaQueries.hostRules]: hostRules, + [UebaQueries.hostTactics]: hostTactics, + [UebaQueries.riskScore]: riskScore, + [UebaQueries.userRules]: userRules, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts new file mode 100644 index 0000000000000..ace2faf819877 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import { RiskScoreHit, RiskScoreEdges } from '../../../../../../common'; + +export const formatRiskScoreData = (buckets: RiskScoreHit[]): RiskScoreEdges[] => + buckets.map((bucket) => ({ + node: { + _id: bucket.key, + host_name: bucket.key, + risk_score: getOr(0, 'risk_score.value', bucket), + risk_keyword: getOr(0, 'risk_keyword.buckets[0].key', bucket), + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts new file mode 100644 index 0000000000000..6b3a956c9c1b7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import { SecuritySolutionFactory } from '../../types'; +import { + RiskScoreEdges, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, + UebaQueries, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildRiskScoreQuery } from './query.risk_score.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatRiskScoreData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const riskScore: SecuritySolutionFactory = { + buildDsl: (options: RiskScoreRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildRiskScoreQuery(options); + }, + parse: async ( + options: RiskScoreRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + + const riskScoreEdges: RiskScoreEdges[] = formatRiskScoreData( + getOr([], 'aggregations.host_data.buckets', response.rawResponse) + ); + + const edges = riskScoreEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildRiskScoreQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + ...response, + inspect, + edges, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts new file mode 100644 index 0000000000000..79c50d84e3c92 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts @@ -0,0 +1,71 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import { Direction, RiskScoreRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildRiskScoreQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + pagination: { querySize }, + sort, + timerange: { from, to }, +}: RiskScoreRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggregations: { + host_data: { + terms: { + field: 'host.name', + order: { + risk_score: Direction.desc, + }, + }, + aggs: { + risk_score: { + sum: { + field: 'risk_score', + }, + }, + risk_keyword: { + terms: { + field: 'risk.keyword', + }, + }, + }, + }, + host_count: { + cardinality: { + field: 'host.name', + }, + }, + }, + query: { bool: { filter } }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts new file mode 100644 index 0000000000000..c0f38af37c1f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import { UserRulesHit, UserRulesFields, UserRulesByUser } from '../../../../../../common'; +import { formatHostRulesData } from '../host_rules/helpers'; + +export const formatUserRulesData = (buckets: UserRulesHit[]): UserRulesByUser[] => + buckets.map((user) => ({ + _id: user.key, + [UserRulesFields.userName]: user.key, + [UserRulesFields.riskScore]: getOr(0, 'risk_score.value', user), + [UserRulesFields.ruleCount]: getOr(0, 'rule_count.value', user), + [UserRulesFields.rules]: formatHostRulesData(getOr([], 'rule_name.buckets', user)), + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts new file mode 100644 index 0000000000000..aa525f2c5b741 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.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. + */ + +import { getOr } from 'lodash/fp'; +import { SecuritySolutionFactory } from '../../types'; +import { + UebaQueries, + UserRulesByUser, + UserRulesFields, + UserRulesRequestOptions, + UserRulesStrategyResponse, + UsersRulesHit, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildUserRulesQuery } from './query.user_rules.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatUserRulesData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const userRules: SecuritySolutionFactory = { + buildDsl: (options: UserRulesRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildUserRulesQuery(options); + }, + parse: async ( + options: UserRulesRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + + const userRulesByUser: UserRulesByUser[] = formatUserRulesData( + getOr([], 'aggregations.user_data.buckets', response.rawResponse) + ); + const inspect = { + dsl: [inspectStringifyObject(buildUserRulesQuery(options))], + }; + return { + ...response, + inspect, + data: userRulesByUser.map((user) => { + const edges = user[UserRulesFields.rules].splice(cursorStart, querySize - cursorStart); + const totalCount = user[UserRulesFields.ruleCount]; + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + [UserRulesFields.userName]: user[UserRulesFields.userName], + [UserRulesFields.riskScore]: user[UserRulesFields.riskScore], + edges, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }), + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts new file mode 100644 index 0000000000000..c2242ff00a6c1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts @@ -0,0 +1,97 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import { Direction, UserRulesRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildUserRulesQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + hostName, + timerange: { from, to }, +}: UserRulesRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, // can stop getting this from sourcerer and assume default detections index if we want + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggs: { + user_data: { + terms: { + field: 'user.name', + order: { + risk_score: Direction.desc, + }, + size: 20, + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_name: { + terms: { + field: 'signal.rule.name', + order: { + risk_score: Direction.desc, + }, + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_type: { + terms: { + field: 'signal.rule.type', + }, + }, + }, + }, + rule_count: { + cardinality: { + field: 'signal.rule.name', + }, + }, + }, + }, + }, + query: { + bool: { + filter, + must: [ + { + term: { + 'host.name': hostName, + }, + }, + ], + }, + }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 259c0f2ae2f92..611860929e25e 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -13,6 +13,7 @@ import { APP_ID, DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, DEFAULT_ANOMALY_SCORE, DEFAULT_APP_TIME_RANGE, DEFAULT_APP_REFRESH_INTERVAL, @@ -88,7 +89,9 @@ export const initUiSettings = ( }), sensitive: true, - value: DEFAULT_INDEX_PATTERN, + value: experimentalFeatures.uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN, description: i18n.translate('xpack.securitySolution.uiSettings.defaultIndexDescription', { defaultMessage: '

Comma-delimited list of Elasticsearch indices from which the Security app collects events.

', diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts index f29dc4a3c7450..9a2d884af948f 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts @@ -14,6 +14,7 @@ export enum LastEventIndexKey { hosts = 'hosts', ipDetails = 'ipDetails', network = 'network', + ueba = 'ueba', // TODO: Steph/ueba implement this } export interface LastTimeDetails { diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index c0bc1c305b970..36a5d31bd6904 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -314,6 +314,7 @@ export enum TimelineId { detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', + uebaPageExternalAlerts = 'ueba-page-external-alerts', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes @@ -326,6 +327,7 @@ export const TimelineIdLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineId.detectionsRulesDetailsPage), runtimeTypes.literal(TimelineId.detectionsPage), runtimeTypes.literal(TimelineId.networkPageExternalAlerts), + runtimeTypes.literal(TimelineId.uebaPageExternalAlerts), runtimeTypes.literal(TimelineId.active), runtimeTypes.literal(TimelineId.test), ]); diff --git a/x-pack/plugins/timelines/public/store/t_grid/types.ts b/x-pack/plugins/timelines/public/store/t_grid/types.ts index c8c72e0310958..41f69b9f55d0d 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/types.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/types.ts @@ -45,6 +45,7 @@ export enum TimelineId { detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', + uebaPageExternalAlerts = 'ueba-page-external-alerts', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts index 6d3d8ac3c55aa..0fc6ce78ee982 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts @@ -82,6 +82,7 @@ export const buildLastEventTimeQuery = ({ throw new Error('buildLastEventTimeQuery - no hostName argument provided'); case LastEventIndexKey.hosts: case LastEventIndexKey.network: + case LastEventIndexKey.ueba: return { allowNoIndices: true, index: indicesToQuery[indexKey], diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d406dd9b688d0..5a09667e2a327 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6530,7 +6530,7 @@ "xpack.canvas.error.esService.fieldsFetchErrorMessage": "「{index}」の Elasticsearch フィールドを取得できませんでした", "xpack.canvas.error.esService.indicesFetchErrorMessage": "Elasticsearch インデックスを取得できませんでした", "xpack.canvas.error.RenderWithFn.renderErrorMessage": "「{functionName}」のレンダリングが失敗しました", - "xpack.canvas.error.repeatImage.missingMaxArgument": "{emptyImageArgument} を指定する場合は、{maxArgument} を設定する必要があります", + "expressionRepeatImage.error.repeatImage.missingMaxArgument": "{emptyImageArgument} を指定する場合は、{maxArgument} を設定する必要があります", "xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage": "ワークパッドのクローンを作成できませんでした", "xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage": "ワークパッドをアップロードできませんでした", "xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage": "すべてのワークパッドを削除できませんでした", @@ -6776,11 +6776,11 @@ "xpack.canvas.functions.render.args.containerStyleHelpText": "背景、境界、透明度を含む、コンテナーのスタイルです。", "xpack.canvas.functions.render.args.cssHelpText": "このエレメントの対象となるカスタム {CSS} のブロックです。", "xpack.canvas.functions.renderHelpText": "{CONTEXT}を特定のエレメントとしてレンダリングし、背景と境界のスタイルなどのエレメントレベルのオプションを設定します。", - "xpack.canvas.functions.repeatImage.args.emptyImageHelpText": "この画像のエレメントについて、{CONTEXT}および{maxArg}パラメーターの差異を解消します。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", - "xpack.canvas.functions.repeatImage.args.imageHelpText": "繰り返す画像です。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", - "xpack.canvas.functions.repeatImage.args.maxHelpText": "画像が繰り返される最高回数です。", - "xpack.canvas.functions.repeatImage.args.sizeHelpText": "画像の高さまたは幅のピクセル単位での最高値です。画像が縦長の場合、この関数は高さを制限します。", - "xpack.canvas.functions.repeatImageHelpText": "繰り返し画像エレメントを構成します。", + "expressionRepeatImage.functions.repeatImage.args.emptyImageHelpText": "この画像のエレメントについて、{CONTEXT}および{maxArg}パラメーターの差異を解消します。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", + "expressionRepeatImage.functions.repeatImage.args.imageHelpText": "繰り返す画像です。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", + "expressionRepeatImage.functions.repeatImage.args.maxHelpText": "画像が繰り返される最高回数です。", + "expressionRepeatImage.functions.repeatImage.args.sizeHelpText": "画像の高さまたは幅のピクセル単位での最高値です。画像が縦長の場合、この関数は高さを制限します。", + "expressionRepeatImage.functions.repeatImageHelpText": "繰り返し画像エレメントを構成します。", "xpack.canvas.functions.replace.args.flagsHelpText": "フラグを指定します。{url}を参照してください。", "xpack.canvas.functions.replace.args.patternHelpText": "{JS} 正規表現のテキストまたはパターンです。例:{example}。ここではキャプチャグループを使用できます。", "xpack.canvas.functions.replace.args.replacementHelpText": "文字列の一致する部分の代わりです。キャプチャグループはノードによってアクセス可能です。例:{example}。", @@ -6971,8 +6971,8 @@ "xpack.canvas.renderer.plot.helpDescription": "データから XY プロットをレンダリングします", "xpack.canvas.renderer.progress.displayName": "進捗インジケーター", "xpack.canvas.renderer.progress.helpDescription": "エレメントのパーセンテージを示す進捗インジケーターをレンダリングします", - "xpack.canvas.renderer.repeatImage.displayName": "画像の繰り返し", - "xpack.canvas.renderer.repeatImage.helpDescription": "画像を指定回数繰り返し表示します", + "expressionRepeatImage.renderer.repeatImage.displayName": "画像の繰り返し", + "expressionRepeatImage.renderer.repeatImage.helpDescription": "画像を指定回数繰り返し表示します", "xpack.canvas.renderer.table.displayName": "データテーブル", "xpack.canvas.renderer.table.helpDescription": "表形式データを {HTML} としてレンダリングします", "xpack.canvas.renderer.text.displayName": "プレインテキスト", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dbe67290bbe3a..de212d601660d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6570,7 +6570,7 @@ "xpack.canvas.error.esService.fieldsFetchErrorMessage": "无法为“{index}”提取 Elasticsearch 字段", "xpack.canvas.error.esService.indicesFetchErrorMessage": "无法提取 Elasticsearch 索引", "xpack.canvas.error.RenderWithFn.renderErrorMessage": "呈现“{functionName}”失败。", - "xpack.canvas.error.repeatImage.missingMaxArgument": "如果提供 {emptyImageArgument},则必须设置 {maxArgument}", + "expressionRepeatImage.error.repeatImage.missingMaxArgument": "如果提供 {emptyImageArgument},则必须设置 {maxArgument}", "xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage": "无法克隆 Workpad", "xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage": "无法上传 Workpad", "xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage": "无法删除所有 Workpad", @@ -6817,11 +6817,11 @@ "xpack.canvas.functions.render.args.containerStyleHelpText": "容器的样式,包括背景、边框和透明度。", "xpack.canvas.functions.render.args.cssHelpText": "要限定于元素的任何定制 {CSS} 块。", "xpack.canvas.functions.renderHelpText": "将 {CONTEXT} 呈现为特定元素,并设置元素级别选项,例如背景和边框样式。", - "xpack.canvas.functions.repeatImage.args.emptyImageHelpText": "使用此图像填充元素的 {CONTEXT} 和 {maxArg} 参数之间的差距。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", - "xpack.canvas.functions.repeatImage.args.imageHelpText": "要重复的图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", - "xpack.canvas.functions.repeatImage.args.maxHelpText": "图像可以重复的最大次数。", - "xpack.canvas.functions.repeatImage.args.sizeHelpText": "图像的最大高度或宽度,以像素为单位。图像的高大于宽时,此函数将限制高度。", - "xpack.canvas.functions.repeatImageHelpText": "配置重复图像元素。", + "expressionRepeatImage.functions.repeatImage.args.emptyImageHelpText": "使用此图像填充元素的 {CONTEXT} 和 {maxArg} 参数之间的差距。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", + "expressionRepeatImage.functions.repeatImage.args.imageHelpText": "要重复的图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", + "expressionRepeatImage.functions.repeatImage.args.maxHelpText": "图像可以重复的最大次数。", + "expressionRepeatImage.functions.repeatImage.args.sizeHelpText": "图像的最大高度或宽度,以像素为单位。图像的高大于宽时,此函数将限制高度。", + "expressionRepeatImage.functions.repeatImageHelpText": "配置重复图像元素。", "xpack.canvas.functions.replace.args.flagsHelpText": "指定标志。请参见 {url}。", "xpack.canvas.functions.replace.args.patternHelpText": "{JS} 正则表达式的文本或模式。例如,{example}。您可以在此处使用捕获组。", "xpack.canvas.functions.replace.args.replacementHelpText": "字符串匹配部分的替代。捕获组可以通过其索引进行访问。例如,{example}。", @@ -7012,8 +7012,10 @@ "xpack.canvas.renderer.plot.helpDescription": "根据您的数据呈现 XY 坐标图", "xpack.canvas.renderer.progress.displayName": "进度指示", "xpack.canvas.renderer.progress.helpDescription": "呈现显示元素百分比的进度指示", - "xpack.canvas.renderer.repeatImage.displayName": "图像重复", - "xpack.canvas.renderer.repeatImage.helpDescription": "重复图像给定次数", + "expressionRepeatImage.renderer.repeatImage.displayName": "图像重复", + "expressionRepeatImage.renderer.repeatImage.helpDescription": "重复图像给定次数", + "expressionRevealImage.renderer.revealImage.displayName": "图像显示", + "expressionRevealImage.renderer.revealImage.helpDescription": "显示一定百分比的图像,以制作定制的仪表样式图表", "xpack.canvas.renderer.table.displayName": "数据表", "xpack.canvas.renderer.table.helpDescription": "将表格数据呈现为 {HTML}", "xpack.canvas.renderer.text.displayName": "纯文本", @@ -7516,8 +7518,6 @@ "expressionRevealImage.functions.revealImage.args.originHelpText": "要开始图像填充的位置。例如 {list} 或 {end}。", "expressionRevealImage.functions.revealImage.invalidPercentErrorMessage": "无效值:“{percent}”。百分比必须介于 0 和 1 之间", "expressionRevealImage.functions.revealImageHelpText": "配置图像显示元素。", - "expressionRevealImage.renderer.revealImage.displayName": "图像显示", - "expressionRevealImage.renderer.revealImage.helpDescription": "显示一定百分比的图像,以制作定制的仪表样式图表", "xpack.cases.connectors.cases.externalIncidentAdded": " (由 {user} 于 {date}添加) ", "xpack.cases.connectors.cases.externalIncidentCreated": " (由 {user} 于 {date}创建) ", "xpack.cases.connectors.cases.externalIncidentDefault": " (由 {user} 于 {date}创建) ",